Merge origin/main into the SSH host-key + reconnect branch

One conflict, and it was structural rather than textual: main split
ssh-connection.test.ts from 2904 lines into seventeen files with a shared harness
and fixtures (#14728), taking it off the max-lines suppression list, while this
branch had added host key verification tests to the old monolith.

Resolved by adopting main's split rather than reinstating the big file, since
restoring it would put back the suppression the refactor just removed. The seven
host key tests move to ssh-connection-host-key-verification.test.ts unchanged,
including their comments — they are the reasoning, not decoration.

The load-bearing piece was the ssh2 mock, which now lives in the shared harness
and is therefore used by all seventeen files. It has to keep the callback form:
ssh2 calls hostVerifier(key, verify) and only accepts synchronously when the
return is not undefined, so the old single-argument mock would pass against a
verifier that never decides — exactly the regression this work exists to prevent.
It also has to abort on denial, or a refused key looks like a successful connect.
Defaulting the presented key to a valid ed25519 blob keeps the other sixteen
files unaffected, which the full run confirms: 1549 passed, 13 skipped, zero
failures, typecheck clean.

The one remaining caller of the old signature — the obsolete-generation
fingerprint test — moved to real key blobs, because the verifier now identifies a
key before recording a fingerprint and a placeholder string would be refused
before reaching the generation check that test covers.
This commit is contained in:
Neil
2026-08-15 21:17:46 -07:00
2413 changed files with 437534 additions and 354294 deletions
+228
View File
@@ -0,0 +1,228 @@
import { appendFileSync, readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { mergeLocBlock, renderLocBlock, sumChangedFiles } from './pr-test-loc-table.mjs'
export const PR_FILES_PAGE_LIMIT = 3000
export function nextLink(linkHeader) {
if (linkHeader == null || linkHeader.length === 0) {
return undefined
}
for (const part of linkHeader.split(',')) {
const match = part.match(/<([^>]+)>\s*;\s*rel="next"/)
if (match != null) {
return match[1]
}
}
return undefined
}
function githubHeaders(token) {
return {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'User-Agent': 'orca-pr-test-loc',
'X-GitHub-Api-Version': '2022-11-28'
}
}
export async function listPullFiles({ owner, repo, pullNumber, token, fetchImpl = fetch }) {
const files = []
let url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100`
while (url != null) {
const response = await fetchImpl(url, { headers: githubHeaders(token) })
if (!response.ok) {
throw new Error(
`Failed to list PR #${pullNumber} files: ${response.status} ${response.statusText}`
)
}
const page = await response.json()
if (!Array.isArray(page)) {
throw new Error(`Unexpected PR files payload for #${pullNumber}`)
}
files.push(...page)
if (files.length >= PR_FILES_PAGE_LIMIT) {
console.log(
`PR #${pullNumber} file list hit GitHub's ${PR_FILES_PAGE_LIMIT}-file cap; totals may be short.`
)
return files.slice(0, PR_FILES_PAGE_LIMIT)
}
url = nextLink(response.headers.get('link'))
}
return files
}
function writeGithubOutput(totals) {
const block = `${renderLocBlock(totals)}\n`
const outputPath = process.env.GITHUB_OUTPUT
if (outputPath != null) {
appendFileSync(outputPath, `summary<<ORCA_PR_LOC_EOF\n${block}ORCA_PR_LOC_EOF\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath != null) {
appendFileSync(summaryPath, `${block}\n`)
}
}
async function updatePullRequest({ owner, repo, pullNumber, token, totals, fetchImpl = fetch }) {
const headers = githubHeaders(token)
const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`
const response = await fetchImpl(url, { headers })
if (response.status === 403) {
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
return 0
}
if (!response.ok) {
throw new Error(`Failed to read PR #${pullNumber}: ${response.status} ${response.statusText}`)
}
const pull = await response.json()
const nextBody = mergeLocBlock(pull.body, totals)
if (nextBody === (pull.body ?? '')) {
console.log(`PR #${pullNumber} LoC header already current.`)
return 0
}
const update = await fetchImpl(url, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({ body: nextBody })
})
if (update.status === 403) {
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
return 0
}
if (!update.ok) {
throw new Error(`Failed to update PR #${pullNumber}: ${update.status} ${update.statusText}`)
}
console.log(`Updated LoC header on PR #${pullNumber}.`)
return 0
}
function resolveRepository() {
const repository = process.env.GITHUB_REPOSITORY
if (repository == null || !repository.includes('/')) {
return undefined
}
const slash = repository.indexOf('/')
return { owner: repository.slice(0, slash), repo: repository.slice(slash + 1) }
}
function resolveToken() {
return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
}
function printUsage() {
console.error(
`Usage: ${process.argv[1]} --from-pr <number> | --update-pr <number> [--files-json <file>] [--merge-body <file>]`
)
}
async function main(argv) {
let filesJsonPath
let mergeBodyPath
let fromPrNumber
let updatePrNumber
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === '--files-json') {
filesJsonPath = argv[i + 1]
i += 1
continue
}
if (arg === '--merge-body') {
mergeBodyPath = argv[i + 1]
i += 1
continue
}
if (arg === '--from-pr') {
fromPrNumber = argv[i + 1]
i += 1
continue
}
if (arg === '--update-pr') {
updatePrNumber = argv[i + 1]
i += 1
continue
}
printUsage()
return 2
}
const pullNumber = updatePrNumber ?? fromPrNumber
if (
(argv.includes('--files-json') && filesJsonPath == null) ||
(argv.includes('--merge-body') && mergeBodyPath == null) ||
(argv.includes('--from-pr') && fromPrNumber == null) ||
(argv.includes('--update-pr') && updatePrNumber == null) ||
(filesJsonPath == null && pullNumber == null)
) {
printUsage()
return 2
}
let files
if (filesJsonPath != null) {
files = JSON.parse(readFileSync(filesJsonPath, 'utf8'))
} else {
const repository = resolveRepository()
const token = resolveToken()
if (repository == null || token == null) {
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required to read a pull request.')
return 2
}
files = await listPullFiles({
...repository,
pullNumber: Number(pullNumber),
token
})
}
const totals = sumChangedFiles(files)
writeGithubOutput(totals)
if (mergeBodyPath != null) {
process.stdout.write(mergeLocBlock(readFileSync(mergeBodyPath, 'utf8'), totals))
return 0
}
console.log(renderLocBlock(totals))
if (updatePrNumber == null) {
return 0
}
const repository = resolveRepository()
const token = resolveToken()
if (repository == null || token == null) {
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required with --update-pr.')
return 2
}
return updatePullRequest({
...repository,
pullNumber: Number(updatePrNumber),
token,
totals
})
}
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2))
.then((code) => {
process.exitCode = code
})
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
+86
View File
@@ -0,0 +1,86 @@
export const LOC_BLOCK_START = '<!-- orca-pr-loc -->'
export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
export const LOC_HANDS_OFF_COMMENT =
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
export function isTestPath(path) {
const normalized = path.replaceAll('\\', '/')
return TEST_DIR_SEGMENT.test(normalized) || TEST_FILENAME.test(normalized)
}
export function emptyLocTotals() {
return {
test: { files: 0, added: 0, deleted: 0 },
nonTest: { files: 0, added: 0, deleted: 0 }
}
}
export function sumChangedFiles(files) {
const totals = emptyLocTotals()
for (const file of files) {
const path = file.filename
if (path == null) {
continue
}
const bucket = isTestPath(path) ? totals.test : totals.nonTest
bucket.files += 1
bucket.added += Number(file.additions ?? 0)
bucket.deleted += Number(file.deletions ?? 0)
}
return totals
}
function signed(count) {
if (count === 0) {
return '0'
}
return count > 0 ? `+${count}` : `−${Math.abs(count)}`
}
function locTableRow(label, bucket) {
return `| ${label} | ${bucket.files ?? 0} | ${signed(bucket.added)} | ${signed(-(bucket.deleted ?? 0))} | ${signed((bucket.added ?? 0) - (bucket.deleted ?? 0))} |`
}
export function formatLocTable({ test, nonTest }) {
return [
'| | Files | Added | Deleted | Net |',
'| :--- | ---: | ---: | ---: | ---: |',
locTableRow('Test', test),
locTableRow('Prod', nonTest)
].join('\n')
}
export function renderLocBlock(totals) {
return [
LOC_BLOCK_START,
LOC_HANDS_OFF_COMMENT,
'',
formatLocTable(totals),
'',
LOC_BLOCK_END
].join('\n')
}
export function mergeLocBlock(body, totals) {
const block = renderLocBlock(totals)
const current = body ?? ''
const start = current.indexOf(LOC_BLOCK_START)
const end = current.indexOf(LOC_BLOCK_END)
if (start !== -1 && end !== -1 && end > start) {
const rest = current.slice(end + LOC_BLOCK_END.length).replace(/^\r?\n/, '')
if (rest.trim().length === 0) {
return `${current.slice(0, start)}${block}\n`
}
return `${current.slice(0, start)}${block}\n\n${rest.replace(/^\r?\n+/, '')}`
}
if (current.trim().length === 0) {
return `${block}\n`
}
return `${block}\n\n${current.replace(/^\r?\n+/, '')}`
}
+4
View File
@@ -29,6 +29,9 @@ on:
- 'electron.vite.config.ts'
- 'config/build-plugins/**'
- 'src/main/daemon/**'
- 'src/main/providers/local-pty-provider.ts'
- 'src/main/providers/windows-shell-args.ts'
- 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
@@ -106,6 +109,7 @@ jobs:
src/main/computer/macos-native-provider-socket.test.ts
src/main/computer/macos-computer-use-permissions.test.ts
src/main/computer/macos-computer-use-permission-status.test.ts
src/main/providers/windows-shell-preflight-runtime.windows.test.ts
src/main/computer/desktop-script-provider-client.test.ts
src/main/computer/desktop-script-provider-cache.test.ts
src/main/computer/desktop-script-provider-actions.test.ts
+36
View File
@@ -0,0 +1,36 @@
name: PR test LoC
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
concurrency:
group: pr-test-loc-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
loc:
name: test vs non-test LoC
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
# Why no checkout: the Files API already has per-file additions/deletions.
- name: Count test vs non-test LoC
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
for script in pr-test-loc-table.mjs pr-test-loc-summary.mjs; do
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=pull/${{ github.event.pull_request.number }}/head" \
--jq .content | base64 --decode > "$RUNNER_TEMP/${script}"
done
node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "${{ github.event.pull_request.number }}"
+32 -2
View File
@@ -50,6 +50,27 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
- name: Check VM runtime rollback compatibility
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \
src/shared/ephemeral-vm-runtime-store.ts \
src/shared/ephemeral-vm-runtime-feature-store.ts \
src/shared/ephemeral-vm-runtime-rollback-projection.ts \
src/shared/ephemeral-vm-runtimes.ts \
src/shared/ephemeral-vm-recipes.ts \
src/shared/orca-yaml-hook-types.ts \
src/main/ephemeral-vm-runtime-service.ts \
src/main/ephemeral-vm-runtime-provisioning-persistence.ts \
src/main/ephemeral-vm-failed-start-cleanup.ts; then
echo "VM runtime persistence is unchanged."
exit 0
fi
node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs \
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
@@ -243,7 +264,10 @@ jobs:
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready.test.ts \
src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \
src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
@@ -279,7 +303,10 @@ jobs:
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
@@ -479,6 +506,9 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
fi
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
---
-148
View File
@@ -5,180 +5,89 @@
inline src/cli/handlers/automations.ts
inline src/cli/handlers/orchestration.ts
inline src/cli/help.ts
inline src/cli/index.test.ts
inline src/main/agent-hooks/server.test.ts
inline src/main/agent-hooks/server.ts
inline src/main/amp/hook-service.ts
inline src/main/antigravity/hook-service.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
inline src/main/browser/agent-browser-bridge.test.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-guest-ui.ts
inline src/main/browser/browser-manager-grab.test.ts
inline src/main/browser/browser-manager.test.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/browser-screencast-stream.ts
inline src/main/browser/browser-session-registry.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/browser/cdp-ws-proxy.ts
inline src/main/browser/grab-guest-script.ts
inline src/main/browser/snapshot-engine.ts
inline src/main/claude-accounts/runtime-auth-service.test.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/claude-accounts/service.test.ts
inline src/main/claude-accounts/service.ts
inline src/main/claude-usage/scanner.ts
inline src/main/claude-usage/store.ts
inline src/main/cli/cli-installer.test.ts
inline src/main/cli/cli-installer.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.test.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.test.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex-usage/scanner.ts
inline src/main/codex-usage/store.test.ts
inline src/main/codex-usage/store.ts
inline src/main/codex/config-toml-trust.test.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.test.ts
inline src/main/codex/hook-service.ts
inline src/main/copilot/hook-service.ts
inline src/main/daemon/client.ts
inline src/main/daemon/daemon-health.ts
inline src/main/daemon/daemon-init.test.ts
inline src/main/daemon/daemon-init.ts
inline src/main/daemon/daemon-pty-adapter.test.ts
inline src/main/daemon/daemon-pty-adapter.ts
inline src/main/daemon/daemon-server.ts
inline src/main/daemon/pty-subprocess.test.ts
inline src/main/daemon/pty-subprocess.ts
inline src/main/daemon/session.ts
inline src/main/daemon/shell-ready.ts
inline src/main/git/remove-worktree.test.ts
inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.test.ts
inline src/main/git/status.ts
inline src/main/git/worktree.test.ts
inline src/main/git/worktree.ts
inline src/main/github/client.test.ts
inline src/main/github/client.ts
inline src/main/github/issues.ts
inline src/main/github/pr-refresh-coordinator.test.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
inline src/main/github/work-item-details.ts
inline src/main/gitlab/client-mr.test.ts
inline src/main/gitlab/client.ts
inline src/main/gitlab/issues.ts
inline src/main/gitlab/work-item-details.ts
inline src/main/hermes/hook-service.ts
inline src/main/hooks.test.ts
inline src/main/hooks.ts
inline src/main/index.ts
inline src/main/ipc/browser.ts
inline src/main/ipc/crash-reporting.ts
inline src/main/ipc/filesystem-auth.ts
inline src/main/ipc/filesystem-mutations.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.test.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/github.test.ts
inline src/main/ipc/github.ts
inline src/main/ipc/gitlab.ts
inline src/main/ipc/linear.ts
inline src/main/ipc/notifications.test.ts
inline src/main/ipc/notifications.ts
inline src/main/ipc/pet.ts
inline src/main/ipc/preflight.test.ts
inline src/main/ipc/pty.test.ts
inline src/main/ipc/pty.ts
inline src/main/ipc/remote-workspace.ts
inline src/main/ipc/repos-remote.test.ts
inline src/main/ipc/repos.ts
inline src/main/ipc/runtime-environments.test.ts
inline src/main/ipc/ssh.test.ts
inline src/main/ipc/ssh.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/ipc/worktrees.test.ts
inline src/main/ipc/worktrees.ts
inline src/main/jira/client.ts
inline src/main/jira/issues.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/client.ts
inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
inline src/main/opencode-usage/scanner.ts
inline src/main/opencode-usage/store.ts
inline src/main/opencode/hook-service.ts
inline src/main/persistence.test.ts
inline src/main/persistence.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.test.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/providers/local-pty-shell-ready.test.ts
inline src/main/providers/local-pty-shell-ready.ts
inline src/main/providers/ssh-git-provider.test.ts
inline src/main/providers/ssh-git-provider.ts
inline src/main/rate-limits/claude-fetcher.test.ts
inline src/main/rate-limits/claude-fetcher.ts
inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/codex-fetcher.ts
inline src/main/rate-limits/service.test.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.test.ts
inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime-git.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/orchestration/coordinator.ts
inline src/main/runtime/orchestration/db.ts
inline src/main/runtime/rpc/methods/files.ts
inline src/main/runtime/rpc/methods/git.ts
inline src/main/runtime/rpc/methods/github.ts
inline src/main/runtime/rpc/methods/orchestration.test.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/rpc/methods/terminal.ts
inline src/main/runtime/rpc/terminal-multiplex.test.ts
inline src/main/runtime/runtime-rpc.test.ts
inline src/main/runtime/runtime-rpc.ts
inline src/main/source-control/hosted-review-creation.ts
inline src/main/speech/model-manager.ts
inline src/main/speech/stt-service.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.test.ts
inline src/main/ssh/ssh-connection.ts
inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/main/star-nag/service.test.ts
inline src/main/text-generation/commit-message-text-generation.test.ts
inline src/main/text-generation/commit-message-text-generation.ts
inline src/main/updater.test.ts
inline src/main/updater.ts
inline src/main/window/attach-main-window-services.ts
inline src/main/window/createMainWindow.test.ts
inline src/main/window/createMainWindow.ts
inline src/main/workspace-space-analysis.ts
inline src/preload/index.ts
inline src/relay/agent-hook-server.ts
inline src/relay/dispatcher.ts
inline src/relay/external-automations-handler.ts
inline src/relay/fs-handler.ts
inline src/relay/git-handler.test.ts
inline src/relay/git-handler.ts
inline src/relay/pty-handler.test.ts
inline src/relay/pty-handler.ts
inline src/relay/relay.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/App.tsx
inline src/renderer/src/components/GitHubItemDialog.tsx
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
@@ -190,23 +99,16 @@ inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/UpdateCard.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/EditorContent.tsx
inline src/renderer/src/components/editor/IpynbViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/editor/MonacoEditor.tsx
inline src/renderer/src/components/editor/editor-autosave-controller.ts
inline src/renderer/src/components/editor/ipynb-parse.ts
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/github-project/ProjectCell.tsx
inline src/renderer/src/components/github-project/ProjectPicker.tsx
@@ -215,108 +117,59 @@ inline src/renderer/src/components/linear-project-view-surfaces.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
inline src/renderer/src/components/right-sidebar/index.tsx
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/AgentsPane.tsx
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
inline src/renderer/src/components/stats/usage-overview-model.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
inline src/renderer/src/components/terminal-pane/pty-connection.ts
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useIpcEvents.test.ts
inline src/renderer/src/hooks/useIpcEvents.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/active-agent-note-send.test.ts
inline src/renderer/src/lib/file-type-icons.ts
inline src/renderer/src/lib/pane-manager/pane-manager.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/lib/worktree-activation.test.ts
inline src/renderer/src/lib/worktree-activation.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.test.ts
inline src/renderer/src/runtime/runtime-file-client.ts
inline src/renderer/src/runtime/runtime-git-client.ts
inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.test.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.test.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.test.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/diffComments.ts
inline src/renderer/src/store/slices/editor.test.ts
inline src/renderer/src/store/slices/editor.ts
inline src/renderer/src/store/slices/github.test.ts
inline src/renderer/src/store/slices/github.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/jira.ts
inline src/renderer/src/store/slices/linear.test.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/repos.ts
inline src/renderer/src/store/slices/store-cascades.test.ts
inline src/renderer/src/store/slices/store-session-cascades.test.ts
inline src/renderer/src/store/slices/tabs.test.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/terminals.ts
inline src/renderer/src/store/slices/ui.test.ts
inline src/renderer/src/store/slices/ui.ts
inline src/renderer/src/store/slices/workspace-cleanup.ts
inline src/renderer/src/store/slices/worktrees.test.ts
inline src/renderer/src/store/slices/worktrees.ts
inline src/renderer/src/web/web-preload-api.test.ts
inline src/renderer/src/web/web-preload-api.ts
inline src/renderer/src/web/web-runtime-client.ts
inline src/shared/agent-hook-listener.test.ts
inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/github/project-types.ts
inline src/shared/keybindings.test.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
inline src/shared/remote-runtime-client.ts
@@ -325,7 +178,6 @@ inline src/shared/source-control-ai.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline tests/e2e/helpers/terminal.ts
inline tests/e2e/terminal-panes.spec.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
mobile-config app/h/*/session/*.tsx
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
function source(path) {
return readFileSync(join(projectDir, path), 'utf8')
}
function sourceBetween(contents, startMarker, endMarker) {
const start = contents.indexOf(startMarker)
const end = contents.indexOf(endMarker, start + startMarker.length)
if (start === -1 || end === -1) {
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
}
return contents.slice(start, end)
}
describe('computer-use mouse button routing', () => {
it('maps the macOS middle button onto the otherMouse event family', () => {
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
const mapping = sourceBetween(
macOS,
'extension MouseButtonSelection {',
'private func mouseButton('
)
expect(mapping).toContain('return .center')
expect(mapping).toContain('return .otherMouseDown')
expect(mapping).toContain('return .otherMouseUp')
// A middle press posted as a left event type would silently left-click.
expect(mapping).not.toContain('case .middle:\n return .leftMouseDown')
})
it('validates the macOS mouse button before any accessibility shortcut runs', () => {
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
const click = sourceBetween(
macOS,
'private func click(params:',
'private func performClickAction('
)
expect(click).toContain('let button = try mouseButton(params["mouseButton"]?.string)')
expect(click).toContain('button.hasAccessibilityAction')
// An unvalidated raw string reaches AXPress and reports a left click as success.
expect(click).not.toContain('params["mouseButton"]?.string ?? "left"')
})
it('keeps every platform from resolving a middle click through its accessibility path', () => {
const windows = source('native/computer-use-windows/runtime.ps1')
const windowsClick = sourceBetween(
windows,
'$handledByPattern = $false',
'if (-not $handledByPattern)'
)
expect(windowsClick).toContain('$Operation.mouse_button -ne "middle"')
const linux = source('native/computer-use-linux/runtime.py')
const linuxClick = sourceBetween(linux, 'has_modifiers = bool(', 'if not handled:')
expect(linuxClick).toContain('operation.get("mouse_button", "left") == "left"')
})
})
@@ -0,0 +1,184 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterAll, expect, test } from 'vitest'
const targetRoot = process.env.STA_4274_TARGET_ROOT
const operation = process.env.STA_4274_OPERATION
const ownedDirs: string[] = []
function makeOwnedDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix))
ownedDirs.push(dir)
return dir
}
afterAll(() => {
for (const dir of ownedDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
const ordinary = {
id: 'ordinary-runtime',
recipeId: 'ordinary-recipe',
recipe: {
id: 'ordinary-recipe',
name: 'Ordinary VM',
create: './ordinary-create.sh',
destroy: './ordinary-destroy.sh'
},
status: 'running' as const,
cleanupStatus: 'not_started' as const,
createdAt: 1_000,
updatedAt: 1_000,
recipeResult: {
schemaVersion: 1 as const,
connection: {
type: 'ssh' as const,
projectRoot: '/workspace/ordinary',
target: {
label: 'Ordinary VM',
host: 'ordinary.example.com',
port: 22,
username: 'developer'
}
},
userData: { resourceId: 'ordinary-resource' }
}
}
const provisionedRoot = {
id: 'provisioned-root-runtime',
recipeId: 'provisioned-root-recipe',
recipe: {
id: 'provisioned-root-recipe',
name: 'Provisioned Root VM',
create: './provisioned-root-create.sh',
destroy: './provisioned-root-destroy.sh',
checkoutMode: 'provisioned-root' as const
},
status: 'running' as const,
cleanupStatus: 'not_started' as const,
createdAt: 2_000,
updatedAt: 2_000,
recipeResult: {
schemaVersion: 2 as const,
checkoutMode: 'provisioned-root' as const,
connection: {
type: 'ssh' as const,
projectRoot: '/workspace/provisioned',
target: {
label: 'Provisioned Root VM',
host: 'provisioned.example.com',
port: 22,
username: 'developer'
}
},
userData: { resourceId: 'provisioned-resource' }
}
}
test.skipIf(!targetRoot || !operation)(`STA-4274 ${operation ?? 'disabled'}`, async () => {
if (!targetRoot || !operation) {
throw new Error('STA_4274_TARGET_ROOT and STA_4274_OPERATION are required')
}
const userDataPath = process.env.STA_4274_USER_DATA_PATH ?? makeOwnedDir('sta-4274-')
const moduleUrl = pathToFileURL(
resolve(targetRoot, 'src/shared/ephemeral-vm-runtime-store.ts')
).href
const store = await import(/* @vite-ignore */ moduleUrl)
if (operation === 'write-legacy') {
store.upsertEphemeralVmRuntime(userDataPath, ordinary)
return
}
if (operation === 'write-mixed') {
store.upsertEphemeralVmRuntime(userDataPath, ordinary)
store.upsertEphemeralVmRuntime(userDataPath, provisionedRoot)
return
}
if (operation === 'read') {
const runtimes = store.listEphemeralVmRuntimes(userDataPath)
expect(runtimes.map((record: { id: string }) => record.id)).toEqual([
'provisioned-root-runtime',
'ordinary-runtime'
])
return
}
if (operation === 'read-rollback-projection') {
const runtimes = store.listEphemeralVmRuntimes(userDataPath)
expect(runtimes).toHaveLength(2)
expect(runtimes[0].recipe).not.toHaveProperty('checkoutMode')
expect(runtimes[0].recipeResult).toMatchObject({ schemaVersion: 1 })
return
}
if (operation === 'mutate-lifecycle') {
store.updateEphemeralVmRuntimeStatus(userDataPath, 'ordinary-runtime', {
status: 'suspended',
updatedAt: 3_000
})
const repoPath = makeOwnedDir('sta-4274-cleanup-')
const cleanupPath = join(repoPath, 'cleanup.js')
const proofPath = join(repoPath, 'cleanup-proof')
writeFileSync(
cleanupPath,
[
"let input = ''",
"process.stdin.on('data', (chunk) => { input += chunk })",
"process.stdin.on('end', () => {",
' const payload = JSON.parse(input)',
' if (payload.recipeResult.schemaVersion !== 1) process.exit(12)',
" if (payload.recipeResult.userData.resourceId !== 'provisioned-resource') process.exit(13)",
` require('fs').writeFileSync(${JSON.stringify(proofPath)}, 'destroyed')`,
'})'
].join('\n')
)
const serviceUrl = pathToFileURL(
resolve(targetRoot, 'src/main/ephemeral-vm-runtime-service.ts')
).href
const service = await import(/* @vite-ignore */ serviceUrl)
const result = await service.cleanupEphemeralVmRuntime({
userDataPath,
repoPath,
runtimeId: 'provisioned-root-runtime',
recipe: {
id: 'provisioned-root-recipe',
name: 'Provisioned Root VM',
create: './provisioned-root-create.sh',
destroy: `${JSON.stringify(process.execPath)} ${JSON.stringify(cleanupPath)}`
},
now: 3_000
})
expect(result).toMatchObject({
ok: true,
runtime: { status: 'cleaned', cleanupStatus: 'succeeded' }
})
expect(existsSync(proofPath)).toBe(true)
return
}
if (operation === 'read-after-downgrade') {
expect(store.listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
id: 'provisioned-root-runtime',
status: 'cleaned',
cleanupStatus: 'succeeded',
recipe: expect.objectContaining({ checkoutMode: 'provisioned-root' }),
recipeResult: expect.objectContaining({
schemaVersion: 2,
checkoutMode: 'provisioned-root'
})
}),
expect.objectContaining({ id: 'ordinary-runtime', status: 'suspended' })
])
return
}
if (operation === 'read-legacy') {
expect(
store.listEphemeralVmRuntimes(userDataPath).map((record: { id: string }) => record.id)
).toEqual(['ordinary-runtime'])
return
}
throw new Error(`Unknown operation: ${operation}`)
})
@@ -14,6 +14,9 @@ const sshDockerRunner = readFileSync(
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
(step) => step.name === 'Filter changed E2E specs'
)
const rollbackStep = prWorkflow.jobs.static_analysis.steps.find(
(step) => step.name === 'Check VM runtime rollback compatibility'
)
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
@@ -121,4 +124,11 @@ describe('PR E2E gate contract', () => {
expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"')
expect(filterStep.run).toContain('set -euo pipefail')
})
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
})
})
+175
View File
@@ -0,0 +1,175 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { listPullFiles, nextLink } from '../../.github/scripts/pr-test-loc-summary.mjs'
import {
LOC_HANDS_OFF_COMMENT,
isTestPath,
mergeLocBlock,
renderLocBlock,
sumChangedFiles
} from '../../.github/scripts/pr-test-loc-table.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const locScript = join(projectDir, '.github/scripts/pr-test-loc-summary.mjs')
const tempDirs = []
function runLoc(args, { env } = {}) {
return spawnSync(process.execPath, [locScript, ...args], {
cwd: projectDir,
encoding: 'utf8',
env: { ...process.env, ...env }
})
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('PR test LoC summary', () => {
it('classifies colocated tests and e2e/tests directories', () => {
expect(isTestPath('src/main/foo.test.ts')).toBe(true)
expect(isTestPath('src/main/foo.spec.tsx')).toBe(true)
expect(isTestPath('src/e2e/login.ts')).toBe(true)
expect(isTestPath('tests/tools/probe.mjs')).toBe(true)
expect(isTestPath('src/main/foo-test-setup.ts')).toBe(false)
expect(isTestPath('src/main/foo.ts')).toBe(false)
})
it('sums GitHub pull-file additions and deletions', () => {
const totals = sumChangedFiles([
{ filename: 'src/app.ts', additions: 4, deletions: 1 },
{ filename: 'src/app.test.ts', additions: 12, deletions: 3 },
{ filename: 'icon.png', additions: 0, deletions: 0 }
])
expect(totals).toEqual({
test: { files: 1, added: 12, deleted: 3 },
nonTest: { files: 2, added: 4, deleted: 1 }
})
})
it('reads the next page from a GitHub Link header', () => {
expect(
nextLink(
'<https://api.github.com/repos/stablyai/orca/pulls/1/files?page=2>; rel="next", <https://api.github.com/repos/stablyai/orca/pulls/1/files?page=3>; rel="last"'
)
).toBe('https://api.github.com/repos/stablyai/orca/pulls/1/files?page=2')
expect(
nextLink('<https://api.github.com/repos/stablyai/orca/pulls/1/files?page=1>; rel="prev"')
).toBe(undefined)
})
it('paginates pull files until Link rel=next is gone', async () => {
const pages = {
'https://api.github.com/repos/stablyai/orca/pulls/9/files?per_page=100': {
body: [{ filename: 'src/app.ts', additions: 2, deletions: 0 }],
link: '<https://api.github.com/repos/stablyai/orca/pulls/9/files?page=2>; rel="next"'
},
'https://api.github.com/repos/stablyai/orca/pulls/9/files?page=2': {
body: [{ filename: 'src/app.test.ts', additions: 5, deletions: 1 }],
link: null
}
}
const files = await listPullFiles({
owner: 'stablyai',
repo: 'orca',
pullNumber: 9,
token: 'test-token',
fetchImpl: async (url) => {
const page = pages[url]
if (page == null) {
throw new Error(`unexpected url ${String(url)}`)
}
return {
ok: true,
json: async () => page.body,
headers: { get: (name) => (name === 'link' ? page.link : null) }
}
}
})
expect(sumChangedFiles(files)).toEqual({
test: { files: 1, added: 5, deleted: 1 },
nonTest: { files: 1, added: 2, deleted: 0 }
})
})
it('replaces an existing header and prepends when missing', () => {
const totals = {
test: { files: 1, added: 2, deleted: 1 },
nonTest: { files: 1, added: 4, deleted: 0 }
}
const block = renderLocBlock(totals)
expect(block).toContain(LOC_HANDS_OFF_COMMENT)
expect(block).toContain('| Test | 1 | +2 | −1 | +1 |')
expect(block).toContain('| Prod | 1 | +4 | 0 | +4 |')
expect(block).not.toContain('| Total |')
expect(mergeLocBlock('## ELI5\n\nHello\n', totals)).toBe(`${block}\n\n## ELI5\n\nHello\n`)
expect(mergeLocBlock(`${block}\n\n## ELI5\n`, totals)).toBe(`${block}\n\n## ELI5\n`)
expect(
mergeLocBlock(
'<!-- orca-pr-loc -->\n**LoC** · test **+1 / −0**\n<!-- /orca-pr-loc -->\n\n## ELI5\n',
totals
)
).toBe(`${block}\n\n## ELI5\n`)
})
it('counts and merges from a files JSON fixture', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-pr-test-loc-'))
tempDirs.push(root)
const filesPath = join(root, 'files.json')
const bodyPath = join(root, 'body.md')
writeFileSync(
filesPath,
JSON.stringify([
{ filename: 'src/app.ts', additions: 2, deletions: 0 },
{ filename: 'src/app.test.ts', additions: 6, deletions: 1 }
])
)
writeFileSync(bodyPath, '## ELI5\n\nHello\n')
const result = runLoc(['--files-json', filesPath, '--merge-body', bodyPath])
expect(result.status).toBe(0)
expect(result.stdout.startsWith('<!-- orca-pr-loc -->')).toBe(true)
expect(result.stdout).toContain(LOC_HANDS_OFF_COMMENT)
expect(result.stdout).toContain('| Test | 1 | +6 | −1 | +5 |')
expect(result.stdout).toContain('| Prod | 1 | +2 | 0 | +2 |')
expect(result.stdout).not.toContain('| Total |')
expect(result.stdout).toContain('## ELI5\n\nHello\n')
expect(result.stdout.match(/<!-- orca-pr-loc -->/g)).toHaveLength(1)
})
it('exits 2 with usage when no PR or files JSON is supplied', () => {
const result = runLoc([])
expect(result.status).toBe(2)
expect(result.stderr).toContain('--from-pr')
})
it('is a no-checkout GitHub-hosted PR workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8')
)
const locJob = workflow.jobs.loc
const serialized = JSON.stringify(workflow)
expect(locJob['runs-on']).toBe('ubuntu-latest')
expect(locJob.steps).toHaveLength(1)
expect(locJob.steps[0].run).toContain('gh api')
expect(locJob.steps[0].run).toContain('pr-test-loc-table.mjs')
expect(locJob.steps[0].run).toContain('pr-test-loc-summary.mjs')
expect(locJob.steps[0].run).toContain('--update-pr')
expect(workflow.permissions['pull-requests']).toBe('write')
expect(serialized).not.toContain('actions/checkout')
expect(serialized).not.toContain('self-hosted')
})
})
@@ -10,7 +10,10 @@ const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const shellContractFiles = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
@@ -0,0 +1,118 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import process from 'node:process'
const BASELINE_COMMIT = 'bf0c77d5bc800e19117084c27fd1441eda9134ad'
const AFFECTED_MAIN_COMMIT = '25abb9368d98ad84a174f530e02f4228d2269062'
const root = process.cwd()
const driver = process.argv[2] ?? 'config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts'
const config = 'config/vitest.config.ts'
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-sta-4274-repro-'))
try {
const baselineRoot = extractSource(BASELINE_COMMIT, 'baseline')
const affectedRoot = extractSource(AFFECTED_MAIN_COMMIT, 'affected-main')
const revertedRoot = extractSource('HEAD', 'candidate-reverted')
restoreAffectedStoreFiles(revertedRoot)
const baselineData = makeDataDir('baseline-data')
runOracle('baseline write', baselineRoot, 'write-legacy', baselineData)
runOracle('baseline read', baselineRoot, 'read-legacy', baselineData)
const affectedData = makeDataDir('affected-data')
runOracle('affected write', affectedRoot, 'write-mixed', affectedData)
runOracle('affected current read', affectedRoot, 'read', affectedData)
runOracle('affected rollback read', baselineRoot, 'read', affectedData, {
expectFailure: 'file is invalid'
})
const candidateData = makeDataDir('candidate-data')
runOracle('candidate write', root, 'write-mixed', candidateData)
runOracle(
'candidate rollback projection',
baselineRoot,
'read-rollback-projection',
candidateData
)
runOracle('rollback lifecycle mutation', baselineRoot, 'mutate-lifecycle', candidateData)
runOracle('candidate re-upgrade', root, 'read-after-downgrade', candidateData)
const revertedData = makeDataDir('reverted-data')
runOracle('reverted write', revertedRoot, 'write-mixed', revertedData)
runOracle('reverted rollback read', baselineRoot, 'read', revertedData, {
expectFailure: 'file is invalid'
})
} finally {
rmSync(tempRoot, { recursive: true, force: true })
}
function extractSource(commit, name) {
const destination = join(tempRoot, name)
const archive = join(tempRoot, `${name}.tar`)
mkdirSync(destination)
run('git', ['archive', '--format=tar', `--output=${archive}`, commit, 'src/main', 'src/shared'])
run('tar', ['-xf', archive, '-C', destination])
return destination
}
function restoreAffectedStoreFiles(destination) {
for (const relativePath of [
'src/shared/ephemeral-vm-runtime-store.ts',
'src/shared/ephemeral-vm-runtimes.ts'
]) {
const result = run('git', ['show', `${AFFECTED_MAIN_COMMIT}:${relativePath}`], {
encoding: 'utf8'
})
writeFileSync(join(destination, relativePath), result.stdout)
}
}
function makeDataDir(name) {
const destination = join(tempRoot, name)
mkdirSync(destination)
return destination
}
function runOracle(label, targetRoot, operation, userDataPath, options = {}) {
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(pnpm, ['exec', 'vitest', 'run', driver, '--config', config], {
cwd: root,
encoding: 'utf8',
env: {
...process.env,
STA_4274_TARGET_ROOT: resolve(targetRoot),
STA_4274_OPERATION: operation,
STA_4274_USER_DATA_PATH: userDataPath
}
})
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
if (options.expectFailure) {
if (result.status === 0 || !output.includes(options.expectFailure)) {
throw new Error(
`${label} did not fail with ${JSON.stringify(options.expectFailure)}\n${output}`
)
}
process.stdout.write(`EXPECTED_FAIL ${label}\n`)
return
}
if (result.status !== 0) {
throw new Error(`${label} failed\n${output}`)
}
process.stdout.write(`PASS ${label}\n`)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: root,
encoding: options.encoding,
maxBuffer: 16 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} failed\n${String(result.stdout ?? '')}${String(result.stderr ?? '')}`
)
}
return result
}
+11
View File
@@ -14,8 +14,13 @@
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
"../src/main/agent-hooks/runtime-home-hook-command.ts",
"../src/main/amp/agent-status-plugin-source.ts",
"../src/main/amp/hook-service.ts",
"../src/main/amp/managed-plugin-install-status.ts",
"../src/main/antigravity/hook-events.ts",
"../src/main/antigravity/hook-script.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/antigravity/hooks-json-bundle.ts",
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/claude/statusline-script.ts",
@@ -62,6 +67,9 @@
"../src/main/codex-cli/command.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
"../src/main/copilot/copilot-managed-hook-definitions.ts",
"../src/main/copilot/copilot-managed-script.ts",
"../src/main/copilot/copilot-remote-hook-install.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
@@ -71,6 +79,9 @@
"../src/main/devin/hook-settings.ts",
"../src/main/devin/hook-service.ts",
"../src/main/devin/hook-config-json.ts",
"../src/main/hermes/hermes-config-yaml.ts",
"../src/main/hermes/hermes-home-filesystem.ts",
"../src/main/hermes/hermes-managed-plugin-source.ts",
"../src/main/hermes/hook-service.ts",
"../src/main/kimi/hook-service.ts",
"../src/main/kimi/kimi-hook-config-toml.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 20m">
<title>downloads: 20m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 21m">
<title>downloads: 21m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">20m</text>
<text x="90" y="14">20m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">21m</text>
<text x="90" y="14">21m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
---
+2 -2
View File
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android :** [Télécharger l'APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **Android :** [Télécharger l'APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
- **Android:** [APK 0.0.43 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **Android:** [Baixar APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
---
@@ -207,6 +207,7 @@ import {
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import { hasPendingTerminalHandleRecoveryNeed } from '../../../../src/session/pending-terminal-handle-recovery'
import {
createInitialSessionAutoCreateState,
useInitialSessionTerminalAutoCreate,
@@ -2283,6 +2284,10 @@ export default function SessionScreen() {
() =>
closedTabTombstonesRef.current.size > 0 ||
pendingBrowserFocusPageIdRef.current !== null ||
// Why: a pending-handle terminal only turns ready in a fresh snapshot. A live
// stream otherwise parks the poll, and a host that mints the handle without
// republishing strands the pane on its spinner forever (STA-4256).
hasPendingTerminalHandleRecoveryNeed(sessionTabsRef.current, activeSessionTabIdRef.current) ||
// Why: a chat-covered handle that ran out of rearms and left `terminal.list`
// was reminted by a desktop graph reload. Only a fresh tab snapshot carries
// the replacement handle, so force one instead of holding the composer locked.
+8 -8
View File
@@ -5606,8 +5606,8 @@ packages:
multitars@1.0.0:
resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==}
nanoid@3.3.17:
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -9906,7 +9906,7 @@ snapshots:
'@react-navigation/routers': 7.5.3
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
nanoid: 3.3.17
nanoid: 3.3.18
query-string: 7.1.3
react: 19.2.6
react-is: 19.2.6
@@ -9942,14 +9942,14 @@ snapshots:
'@react-navigation/core': 7.17.2(react@19.2.6)
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
nanoid: 3.3.17
nanoid: 3.3.18
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
use-latest-callback: 0.2.6(react@19.2.6)
'@react-navigation/routers@7.5.3':
dependencies:
nanoid: 3.3.17
nanoid: 3.3.18
'@rolldown/binding-android-arm64@1.1.3':
optional: true
@@ -12166,7 +12166,7 @@ snapshots:
expo-symbols: 55.0.8(expo-font@55.0.8)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.17
nanoid: 3.3.18
query-string: 7.1.3
react: 19.2.6
react-fast-compare: 3.2.2
@@ -13866,7 +13866,7 @@ snapshots:
multitars@1.0.0: {}
nanoid@3.3.17: {}
nanoid@3.3.18: {}
natural-compare@1.4.0: {}
@@ -14162,7 +14162,7 @@ snapshots:
postcss@8.5.25:
dependencies:
nanoid: 3.3.17
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -7,11 +7,11 @@ import { colors } from '../theme/mobile-theme'
import { useHostClient } from '../transport/client-context'
import type { RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { getWorktreeLabel } from '../session/worktree-label'
import {
buildMobileAiVaultResumeLaunch,
createMobileAiVaultResumeMutationRegistry,
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal,
+3 -23
View File
@@ -155,7 +155,6 @@ export function MobileBrowserPane({
const [frameMetadata, setFrameMetadata] = useState<BrowserScreencastFrameMetadata | null>(
cachedInitialFrame?.metadata ?? null
)
const [ready, setReady] = useState(cachedInitialFrame !== null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dialog, setDialog] = useState<BrowserDialogState | null>(null)
@@ -174,7 +173,6 @@ export function MobileBrowserPane({
const browserLayerRefs = useRef<[View | null, View | null]>([null, null])
const pendingFrameLayerRef = useRef<FrameLayer | null>(null)
const visibleFrameLayerRef = useRef<FrameLayer>(0)
const readyRef = useRef(cachedInitialFrame !== null)
const busyRef = useRef(false)
const lastAppliedFrameAtRef = useRef(0)
const pendingThrottledFrameRef = useRef<{
@@ -309,10 +307,6 @@ export function MobileBrowserPane({
busyRef.current = false
setBusy(false)
}
if (!readyRef.current) {
readyRef.current = true
setReady(true)
}
}, [])
const clearFrameThrottle = useCallback(() => {
@@ -400,16 +394,12 @@ export function MobileBrowserPane({
frameMetadataRef.current = cachedFrame.metadata
setFrameUri(cachedFrame.uri)
setFrameMetadata(cachedFrame.metadata)
readyRef.current = true
setReady(true)
} else {
frameUriRef.current = null
frameMountedRef.current = false
setFrameUri(null)
setFrameMetadata(null)
frameMetadataRef.current = null
readyRef.current = false
setReady(false)
}
} else {
frameMountedRef.current = true
@@ -478,10 +468,6 @@ export function MobileBrowserPane({
}
if (event.type === 'ready') {
clearStartupTimer()
if (!readyRef.current) {
readyRef.current = true
setReady(true)
}
if (busyRef.current) {
busyRef.current = false
setBusy(false)
@@ -495,10 +481,6 @@ export function MobileBrowserPane({
}
} else if (event.type === 'end') {
clearStartupTimer()
if (readyRef.current) {
readyRef.current = false
setReady(false)
}
if (busyRef.current) {
busyRef.current = false
setBusy(false)
@@ -518,10 +500,6 @@ export function MobileBrowserPane({
}
const message = event.message ?? event.error?.message ?? 'Browser stream failed.'
if (shouldSurfaceBrowserError(message)) {
if (readyRef.current) {
readyRef.current = false
setReady(false)
}
setError(message)
}
}
@@ -1209,7 +1187,9 @@ export function MobileBrowserPane({
) : null}
{!renderedFrameSource || busy || error ? (
<View pointerEvents="none" style={styles.overlay}>
{busy || (!ready && !error) ? (
{/* Why: a stream can report ready and then deliver no frames, so key the
indicator off actually having pixels or it clears into a blank pane. */}
{busy || (!renderedFrameSource && !error) ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
{error ? <Text style={styles.errorText}>{error}</Text> : null}
@@ -0,0 +1,154 @@
import { Buffer } from 'buffer'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import {
BrowserScreencastOpcode,
type BrowserScreencastFrame
} from '../transport/browser-screencast-protocol'
import type { RpcClient } from '../transport/rpc-client'
import { MobileBrowserPane, type MobileBrowserTab } from './MobileBrowserPane'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
Image: 'Image',
PanResponder: { create: () => ({ panHandlers: {} }) },
PixelRatio: { get: () => 2 },
Platform: { OS: 'android' },
Pressable: 'Pressable',
StyleSheet: {
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
create: (styles: unknown) => styles
},
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
// Why: covers icons reached transitively too (the view-mode switch), not just the pane's own
// imports — vitest throws on the first unmocked export rather than rendering without it.
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
Monitor: 'Monitor',
RefreshCw: 'RefreshCw',
Smartphone: 'Smartphone'
}))
type Subscription = {
listener: (payload: unknown) => void
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
}
let pageCounter = 0
function makeFrame(): BrowserScreencastFrame {
return {
opcode: BrowserScreencastOpcode.Frame,
seq: 1,
format: 'jpeg',
metadata: { deviceWidth: 360, deviceHeight: 640, pageScaleFactor: 1 },
image: new TextEncoder().encode('frame')
}
}
function spinnerCount(renderer: ReactTestRenderer): number {
return renderer.root.findAllByType('ActivityIndicator').length
}
async function renderPane(): Promise<{ renderer: ReactTestRenderer; stream: Subscription }> {
pageCounter += 1
const subscriptions: Subscription[] = []
const client = {
subscribe: (
_method: string,
_params: unknown,
listener: (payload: unknown) => void,
options?: { onBinaryFrame?: (frame: BrowserScreencastFrame) => void }
) => {
subscriptions.push({ listener, onBinaryFrame: options?.onBinaryFrame })
return () => {}
},
request: vi.fn()
} as unknown as RpcClient
const tab: MobileBrowserTab = {
type: 'browser',
id: `tab-${pageCounter}`,
title: 'Dashboard',
browserWorkspaceId: 'bw-1',
browserPageId: `page-${pageCounter}`,
url: 'https://dashboard.example',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: true
}
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(
createElement(MobileBrowserPane, {
client,
// Why: unique worktree id keeps each test on a cold module-level frame cache.
worktreeId: `wt-${pageCounter}`,
tab,
screencastSupported: true,
keyboardLift: 0,
bottomInset: 0,
onToast: () => {}
}),
{ createNodeMock: () => ({ setNativeProps: () => {} }) }
)
await Promise.resolve()
})
const mounted: ReactTestRenderer = renderer
const viewport = mounted.root
.findAllByType('View')
.find((node) => typeof node.props.onLayout === 'function')
if (!viewport) {
throw new Error('Viewport with onLayout not found')
}
act(() => {
viewport.props.onLayout({ nativeEvent: { layout: { width: 360, height: 640 } } })
})
const stream = subscriptions[0]
if (!stream) {
throw new Error('browser.screencast subscription not created')
}
return { renderer: mounted, stream }
}
describe('MobileBrowserPane with a stream that reports ready but sends no frames', () => {
// Why: a host that stops painting still reports `ready`, so the pane used to clear its
// indicator and leave an unexplained black rectangle.
it('keeps showing the loading indicator instead of an empty black pane', async () => {
const { renderer, stream } = await renderPane()
act(() => {
stream.listener({ type: 'ready', tab: { url: 'https://dashboard.example' } })
})
expect(spinnerCount(renderer)).toBeGreaterThan(0)
})
it('clears the indicator once real pixels arrive', async () => {
const { renderer, stream } = await renderPane()
act(() => {
stream.listener({ type: 'ready', tab: { url: 'https://dashboard.example' } })
})
act(() => {
stream.onBinaryFrame?.(makeFrame())
})
expect(spinnerCount(renderer)).toBe(0)
const source = renderer.root
.findAllByType('Image')
.map((image) => (image.props.source as { uri?: string } | null)?.uri)
.find((uri) => typeof uri === 'string')
expect(source).toContain(Buffer.from(makeFrame().image).toString('base64'))
})
})
@@ -44,18 +44,32 @@ vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
import { setCachedRepos } from '../cache/repo-cache'
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
import { NewWorktreeModal } from './NewWorktreeModal'
const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }]
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
function repoPickerNames(renderer: ReactTestRenderer | null): string[] {
const pickers = renderer?.root.findAll((node) => node.type === 'PickerListDrawer') ?? []
const repoPicker = pickers.find((node) => node.props.title === 'Repository')
return ((repoPicker?.props.items ?? []) as { label: string }[]).map((item) => item.label)
const repos = [
{
id: 'repo-1',
displayName: 'orca',
path: '/src/orca',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
function pickerItems(
renderer: ReactTestRenderer,
title: string
): { label: string; detail: string }[] {
const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer')
const picker = pickers.find((node) => node.props.title === title)
return picker?.props.items ?? []
}
describe('NewWorktreeModal repo list', () => {
let renderer: ReactTestRenderer | null = null
describe('NewWorktreeModal project targets', () => {
let renderer: ReactTestRenderer
beforeEach(() => {
setCachedRepos('host-1', repos)
@@ -63,7 +77,6 @@ describe('NewWorktreeModal repo list', () => {
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
@@ -71,6 +84,9 @@ describe('NewWorktreeModal repo list', () => {
if (method === 'repo.list') {
return Promise.reject(new Error('connection closed'))
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
const client = { sendRequest } as unknown as RpcClient
@@ -92,6 +108,60 @@ describe('NewWorktreeModal repo list', () => {
})
expect(sendRequest).toHaveBeenCalledWith('repo.list')
expect(repoPickerNames(renderer)).toEqual(['orca'])
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
])
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
])
})
it('groups same-name checkouts under one project with separate run targets', async () => {
const listedRepos = [
...repos,
{
id: 'repo-2',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
const client = {
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: listedRepos } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
} as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
})
)
await Promise.resolve()
})
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
])
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }),
expect.objectContaining({
label: 'SSH · build-server',
detail: '/home/dev/orca'
})
])
})
})
+84 -59
View File
@@ -10,7 +10,7 @@ import {
ActivityIndicator,
Keyboard
} from 'react-native'
import { ChevronDown, ChevronUp } from 'lucide-react-native'
import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
@@ -20,6 +20,7 @@ import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-naviga
import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names'
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
import {
isSetupHookTrusted,
@@ -30,8 +31,10 @@ import {
} from '../tasks/setup-hook-trust'
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
import {
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL,
@@ -64,17 +67,26 @@ import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
import {
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceRunTarget
} from './new-workspace-project-targets'
type Repo = {
id: string
displayName: string
path: string
badgeColor?: string
connectionId?: string | null
kind?: 'git' | 'folder'
upstream?: { owner: string; repo: string } | null
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
}
type Repo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<
SharedRepo,
| 'badgeColor'
| 'connectionId'
| 'executionHostId'
| 'kind'
| 'upstream'
| 'repoIcon'
| 'gitRemoteIdentity'
>
>
type SetupDecision = 'inherit' | 'run' | 'skip'
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
@@ -187,6 +199,18 @@ function NewWorktreeModalContent({
const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null))
const [repos, setRepos] = useState<Repo[]>(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
// Why: a deleted workspace's directory can still hold agent conversation state keyed by cwd, so
// its name must never be suggested again. Fetched per selected repo while the sheet is open.
// Keyed on the path set rather than the array so a poll that changes nothing does not refetch.
const retiredNamesRefreshKey = useMemo(
() => [...(existingWorktreePaths ?? [])].sort().join('\0'),
[existingWorktreePaths]
)
const retiredWorktreeNames = useRetiredWorktreeNames(
client,
selectedRepo?.id,
retiredNamesRefreshKey
)
const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } =
useNewWorktreeDrawerNavigation(visible)
const createInFlightRef = useRef(false)
@@ -201,10 +225,8 @@ function NewWorktreeModalContent({
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [note, setNote] = useState('')
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities(
client,
visible
)
const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } =
useNewWorktreeRuntimeCapabilities(client, visible)
const [showAdvanced, setShowAdvanced] = useState(false)
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
@@ -602,7 +624,9 @@ function NewWorktreeModalContent({
// the authoritative collision is checked server-side against git
// branches/remotes/PRs, so we also retry-with-suffix on conflict.
const trimmedName = composer.name.trim()
const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? [])
const baseName =
trimmedName ||
getSuggestedCreatureName(existingWorktreePaths ?? [], undefined, retiredWorktreeNames)
let setupDecision: SetupDecision = 'inherit'
if (setupCommand) {
@@ -656,6 +680,9 @@ function NewWorktreeModalContent({
client,
repoId: selectedRepo.id,
baseName,
// `baseName` is the suggestion exactly when the user typed nothing, so no identity check
// is needed — desktop's seeded composer needs one (useComposerState `nameWasGenerated`).
nameWasGenerated: !trimmedName,
createdWithAgentId,
comment: trimmedNote,
setupDecision,
@@ -695,10 +722,17 @@ function NewWorktreeModalContent({
isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents)
)
const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL]
const repoPickerItems = useMemo(
() => repos.map((repo) => ({ id: repo.id, label: repo.displayName, repo })),
[repos]
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
const selectedProject =
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
const runTargetPickerItems = useMemo(
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform),
[hostPlatform, repos, selectedProjectId]
)
const selectedRunTarget = selectedRepo
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform)
: null
function prepareSelectionPickerOpen(): void {
// Why: picker taps can beat an open soft keyboard; dismissing it prevents the
@@ -786,10 +820,7 @@ function NewWorktreeModalContent({
>
<BottomDrawer visible={formSheetVisible} interactive={formSheetInteractive} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
Pick a repository and agent to spin up a new workspace.
</Text>
<Text style={styles.title}>Create worktree</Text>
</View>
{loading ? (
@@ -798,33 +829,23 @@ function NewWorktreeModalContent({
</View>
) : repos.length === 0 ? (
<View style={styles.loadingContainer}>
<Text style={styles.emptyText}>No repositories found</Text>
<Text style={styles.emptyText}>No projects found</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Repository</Text>
<Pressable
style={styles.fieldButton}
onPress={() => {
prepareSelectionPickerOpen()
transitionDrawer('repo')
}}
>
{selectedRepo ? (
<View
style={[styles.repoDot, { backgroundColor: repoBadgeColor(selectedRepo) }]}
/>
) : null}
<Text
style={[styles.fieldButtonText, !selectedRepo && styles.fieldButtonPlaceholder]}
numberOfLines={1}
>
{selectedRepo?.displayName ?? 'Select a repository'}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
<NewWorktreeProjectTargetFields
project={selectedProject}
runTarget={selectedRunTarget}
projectBadgeColor={selectedRepo ? repoBadgeColor(selectedRepo) : null}
onOpenProject={() => {
prepareSelectionPickerOpen()
transitionDrawer('project')
}}
onOpenRunTarget={() => {
prepareSelectionPickerOpen()
transitionDrawer('runTarget')
}}
/>
<SmartWorkspaceSourceField
composer={composer}
@@ -894,7 +915,7 @@ function NewWorktreeModalContent({
>
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
<Text style={styles.fieldButtonText} numberOfLines={1}>
{sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label}
{sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
@@ -996,7 +1017,7 @@ function NewWorktreeModalContent({
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.createText}>
{sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'}
{sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
</Text>
)}
</Pressable>
@@ -1025,10 +1046,10 @@ function NewWorktreeModalContent({
/>
<PickerListDrawer
visible={visible && drawerView === 'repo'}
title="Repository"
items={repoPickerItems}
selectedId={selectedRepo?.id ?? ''}
visible={visible && drawerView === 'project'}
title="Project"
items={projectPickerItems}
selectedId={selectedProjectId ?? ''}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={(item) => {
@@ -1036,6 +1057,16 @@ function NewWorktreeModalContent({
}}
/>
<PickerListDrawer
visible={visible && drawerView === 'runTarget'}
title="Run on"
items={runTargetPickerItems}
selectedId={selectedRepo?.id ?? ''}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
/>
<PickerListDrawer
visible={visible && drawerView === 'agent'}
title="Agent"
@@ -1072,11 +1103,6 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.textPrimary
},
subtitle: {
fontSize: 13,
color: colors.textMuted,
marginTop: 2
},
loadingContainer: {
paddingVertical: spacing.xl,
alignItems: 'center'
@@ -1110,7 +1136,6 @@ const styles = StyleSheet.create({
borderColor: colors.borderSubtle
},
fieldButtonText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
@@ -0,0 +1,111 @@
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
import { ChevronDown, Monitor } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Selection = { label: string; detail?: string }
export function NewWorktreeProjectTargetFields({
project,
runTarget,
projectBadgeColor,
onOpenProject,
onOpenRunTarget
}: {
project: Selection | null
runTarget: Selection | null
projectBadgeColor: string | null
onOpenProject: () => void
onOpenRunTarget: () => void
}) {
return (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<Pressable style={styles.fieldButton} onPress={onOpenProject}>
{projectBadgeColor ? (
<View style={[styles.projectDot, { backgroundColor: projectBadgeColor }]} />
) : null}
<SelectionCopy selection={project} placeholder="Select a project" />
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
<View style={styles.field}>
<Text style={styles.label}>Run on</Text>
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
<Monitor size={14} color={colors.textMuted} />
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
</>
)
}
function SelectionCopy({
selection,
placeholder
}: {
selection: Selection | null
placeholder: string
}) {
return (
<View style={styles.fieldButtonCopy}>
<Text
style={[styles.fieldButtonText, !selection && styles.fieldButtonPlaceholder]}
numberOfLines={1}
>
{selection?.label ?? placeholder}
</Text>
{selection?.detail ? (
<Text style={styles.fieldButtonDetail} numberOfLines={1}>
{selection.detail}
</Text>
) : null}
</View>
)
}
const styles = StyleSheet.create({
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
fieldButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm
},
projectDot: {
width: 8,
height: 8,
borderRadius: 999
},
fieldButtonCopy: {
flex: 1,
minWidth: 0
},
fieldButtonText: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
fieldButtonDetail: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginTop: 1
},
fieldButtonPlaceholder: {
color: colors.textMuted
}
})
+26 -9
View File
@@ -6,7 +6,9 @@ import { colors, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
type Props<T extends { id: string; label: string }> = {
type PickerListItem = { id: string; label: string; detail?: string }
type Props<T extends PickerListItem> = {
visible: boolean
title: string
items: T[]
@@ -16,7 +18,7 @@ type Props<T extends { id: string; label: string }> = {
renderIcon?: (item: T) => ReactNode
}
export function PickerListDrawer<T extends { id: string; label: string }>({
export function PickerListDrawer<T extends PickerListItem>({
visible,
title,
items,
@@ -87,12 +89,19 @@ export function PickerListDrawer<T extends { id: string; label: string }>({
onPress={() => closeThenSelect(item)}
>
{renderIcon?.(item)}
<Text
style={[styles.itemText, selected && styles.itemTextSelected]}
numberOfLines={1}
>
{item.label}
</Text>
<View style={styles.itemCopy}>
<Text
style={[styles.itemText, selected && styles.itemTextSelected]}
numberOfLines={1}
>
{item.label}
</Text>
{item.detail ? (
<Text style={styles.itemDetail} numberOfLines={1}>
{item.detail}
</Text>
) : null}
</View>
{selected && <Check size={14} color={colors.textPrimary} />}
</Pressable>
)
@@ -142,10 +151,18 @@ const styles = StyleSheet.create({
backgroundColor: colors.bgRaised
},
itemText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
itemCopy: {
flex: 1,
minWidth: 0
},
itemDetail: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginTop: 1
},
itemTextSelected: {
fontWeight: '600'
}
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
import {
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceRunTarget
} from './new-workspace-project-targets'
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
describe('new workspace project targets', () => {
it('groups local and SSH checkouts of the same project', () => {
const upstream = { owner: 'stablyai', repo: 'orca' }
const options = buildNewWorkspaceProjectOptions([
{ id: 'local', displayName: 'orca', path: '/src/orca', upstream },
{
id: 'ssh',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
upstream
}
])
expect(options).toHaveLength(1)
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
})
it('shows the provider slug recovered from canonical git identity', () => {
const options = buildNewWorkspaceProjectOptions([
{
id: 'local',
displayName: 'orca',
path: '/src/orca',
gitRemoteIdentity: {
canonicalKey: 'github.com/stablyai/orca',
remoteName: 'origin',
remoteUrl: 'git@github.com:stablyai/orca.git'
}
}
])
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
})
it('labels local, SSH, and paired runtime targets', () => {
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }, 'darwin')
).toEqual({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' })
).toEqual({ label: 'This computer', detail: 'C:\\src\\orca' })
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }, 'win32')
).toEqual({ label: 'Local Windows', detail: 'C:\\src\\orca' })
expect(
getNewWorkspaceRunTarget({
id: 'ssh',
displayName: 'orca',
path: 'C:\\src\\orca',
executionHostId: 'ssh:Windows%20VM'
})
).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' })
expect(
getNewWorkspaceRunTarget({
id: 'runtime',
displayName: 'orca',
path: '/src/orca',
executionHostId: 'runtime:devbox'
})
).toEqual({ label: 'Remote · devbox', detail: '/src/orca' })
})
it('shows one target per host when the project has multiple local worktrees', () => {
const upstream = { owner: 'stablyai', repo: 'orca' }
const repos = [
{ id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream },
{ id: 'local-b', displayName: 'orca-b', path: '/src/orca-b', upstream },
{
id: 'ssh',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
upstream
}
]
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([
expect.objectContaining({ id: 'local-a', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' })
])
})
})
@@ -0,0 +1,106 @@
import type { Repo } from '../../../src/shared/repo-types'
import {
getExecutionHostLabel,
getLocalExecutionHostLabel,
getRepoExecutionHostId,
parseExecutionHostId
} from '../../../src/shared/execution-host'
import {
getProjectIdentityKey,
getProjectProviderIdentity
} from '../../../src/shared/project-host-setup-projection'
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<Repo, 'connectionId' | 'executionHostId' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
>
export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
id: string
label: string
detail?: string
repo: TRepo
}
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
id: string
label: string
detail: string
repo: TRepo
}
export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[]
): NewWorkspaceProjectOption<TRepo>[] {
const options = new Map<string, NewWorkspaceProjectOption<TRepo>>()
const hostIdsByProject = new Map<string, Set<string>>()
for (const repo of repos) {
const id = getProjectIdentityKey(repo)
const hostIds = hostIdsByProject.get(id) ?? new Set<string>()
hostIds.add(getRepoExecutionHostId(repo))
hostIdsByProject.set(id, hostIds)
if (!options.has(id)) {
options.set(id, {
id,
label: repo.displayName,
repo
})
}
}
return [...options.values()].map((option) => {
const providerIdentity = getProjectProviderIdentity(option.repo)
const providerDetail = providerIdentity
? `${providerIdentity.owner}/${providerIdentity.repo}`
: ''
const hostCount = hostIdsByProject.get(option.id)?.size ?? 0
const detail = providerDetail || (hostCount > 1 ? `${hostCount} hosts configured` : '')
return detail ? { ...option, detail } : option
})
}
export function getNewWorkspaceRunTarget(
repo: WorkspaceRepo,
localPlatform: NodeJS.Platform | null = null
): {
label: string
detail: string
} {
const hostId = getRepoExecutionHostId(repo)
const host = parseExecutionHostId(hostId)
const hostLabel = getExecutionHostLabel(hostId)
if (host?.kind === 'ssh') {
return { label: `SSH · ${hostLabel}`, detail: repo.path }
}
if (host?.kind === 'runtime') {
return { label: `Remote · ${hostLabel}`, detail: repo.path }
}
return {
label: localPlatform ? getLocalExecutionHostLabel(localPlatform) : 'This computer',
detail: repo.path
}
}
export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[],
projectId: string | null,
localPlatform: NodeJS.Platform | null = null
): NewWorkspaceRunTargetOption<TRepo>[] {
if (!projectId) {
return []
}
const options = new Map<string, NewWorkspaceRunTargetOption<TRepo>>()
for (const repo of repos) {
if (getProjectIdentityKey(repo) !== projectId) {
continue
}
const hostId = getRepoExecutionHostId(repo)
if (!options.has(hostId)) {
options.set(hostId, {
id: repo.id,
...getNewWorkspaceRunTarget(repo, localPlatform),
repo
})
}
}
return [...options.values()]
}
@@ -30,7 +30,7 @@ describe('resolveNewWorktreeFormSheetVisible', () => {
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'repo',
drawerView: 'project',
formPinnedUnderSource: false
})
).toBe(false)
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
export type NewWorktreeDrawerView =
| 'form'
| 'transition'
| 'source'
| 'project'
| 'runTarget'
| 'agent'
| 'trust'
// Why: iOS cannot reliably present a second native modal until the first drawer's
// exit commits; one extra frame keeps transitions sequential on slower devices.
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { MARINE_CREATURES } from '../../../src/shared/marine-creatures'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
const pickFirst = () => 0
const lower = (index: number) => MARINE_CREATURES[index].toLowerCase()
const retiring = (...names: string[]) => ({ exhaustedTiers: 0, names })
describe('getSuggestedCreatureName (mobile)', () => {
it('picks an unused name', () => {
expect(getSuggestedCreatureName([], pickFirst)).toBe(lower(0))
})
it('dedupes against the basename of a live worktree path', () => {
expect(getSuggestedCreatureName([`/home/u/worktrees/${MARINE_CREATURES[0]}`], pickFirst)).toBe(
lower(1)
)
})
it('handles Windows separators and trailing separators like the desktop does', () => {
expect(getSuggestedCreatureName(['C:\\worktrees\\Nautilus\\\\'], pickFirst)).not.toBe(
'nautilus'
)
})
it('never reissues a retired name whose workspace is already deleted', () => {
// Parity with desktop: the deleted workspace's directory may still hold agent
// conversation state keyed by cwd, so the name must not come back.
expect(getSuggestedCreatureName([], pickFirst, retiring(MARINE_CREATURES[0]))).toBe(lower(1))
})
it('treats retired names case-insensitively', () => {
expect(
getSuggestedCreatureName([], pickFirst, retiring(MARINE_CREATURES[0].toUpperCase()))
).toBe(lower(1))
})
it('applies retirement on top of live paths rather than instead of them', () => {
expect(
getSuggestedCreatureName(
[`/home/u/worktrees/${MARINE_CREATURES[0]}`],
pickFirst,
retiring(MARINE_CREATURES[1])
)
).toBe(lower(2))
})
it('degrades to a suffixed tier when the pool is spent, skipping retired variants', () => {
const allPaths = MARINE_CREATURES.map((name) => `/home/u/worktrees/${name}`)
expect(getSuggestedCreatureName(allPaths, pickFirst, retiring(`${lower(0)}-2`))).toBe(
`${lower(1)}-2`
)
})
it('skips every tier the host reports as compacted rather than looking its names up', () => {
// Those names are absent from the wire payload because they are ALL spent, not available.
expect(getSuggestedCreatureName([], pickFirst, { exhaustedTiers: 2, names: [] })).toBe(
`${lower(0)}-3`
)
})
it('falls back to live-only dedupe when the host sends no retired names', () => {
// Hosts predating the wire field omit it; behavior must match the pre-change app.
expect(getSuggestedCreatureName([`/home/u/worktrees/${MARINE_CREATURES[0]}`], pickFirst)).toBe(
lower(1)
)
})
})
@@ -1,55 +1,36 @@
import { MARINE_CREATURES } from '../../../src/shared/marine-creatures'
import {
normalizeSuggestedName,
selectSuggestedCreatureName,
suggestionPathBasename
} from '../../../src/shared/worktree-name-suggestion'
import {
EMPTY_RETIRED_NAME_REGISTRY,
type RetiredNameRegistry
} from '../../../src/shared/worktree/retired-name-registry'
// Why: matches the desktop fallback in
// src/renderer/src/components/sidebar/worktree-name-suggestions.ts. The
// "already exists locally" collision is on the on-disk worktree directory
// name (the path basename), not the user-facing displayName — so we derive
// the used set from path basenames just like the desktop does.
// Why: this used to hand-duplicate the desktop algorithm and the two drifted apart by
// construction. Both now call the shared core so a name suggested on a phone and one suggested on
// the desktop obey the same rules. The dedupe is on the on-disk directory basename, not the
// user-facing displayName, because that is what actually collides.
function stripTrailingSeparators(p: string): string {
return p.replace(/[\\/]+$/, '')
}
// Why: cross-platform path basename — handles both POSIX ("/") and Windows
// ("\\") separators, mirroring src/renderer/src/lib/path.ts so the mobile
// suggestion logic agrees with the desktop's collision check.
function pathBasename(p: string): string {
const normalized = stripTrailingSeparators(p)
const idx = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'))
return idx === -1 ? normalized : normalized.slice(idx + 1)
}
function normalize(name: string): string {
return name.trim().toLowerCase()
}
function pickRandom<T>(items: readonly T[], random: () => number): T {
return items[Math.floor(random() * items.length)]
}
// Why: pick randomly from the unused pool (not the first in list order) so
// fresh worktrees don't all default to "Nautilus" and collide across repos.
/** `retired` holds names already spent in the selected repo, including workspaces that have since
* been deleted — their directories may still hold agent conversation state keyed by cwd, so
* reissuing the name would hand it to the next occupant. Its `exhaustedTiers` watermark covers
* the tiers the host has compacted away, whose names it therefore no longer sends individually.
*
* Hosts older than these fields send nothing, in which case this degrades to deduping against
* live paths only, exactly as it did before. */
export function getSuggestedCreatureName(
existingPaths: readonly string[],
random: () => number = Math.random
random: () => number = Math.random,
retired: RetiredNameRegistry = EMPTY_RETIRED_NAME_REGISTRY
): string {
const used = new Set<string>()
for (const p of existingPaths) {
used.add(normalize(pathBasename(p)))
for (const path of existingPaths) {
used.add(normalizeSuggestedName(suggestionPathBasename(path)))
}
// Lowercased to match branch-name convention (fix/seahorse, not fix/Seahorse).
const available = MARINE_CREATURES.map(normalize).filter((name) => !used.has(name))
if (available.length > 0) {
return pickRandom(available, random)
}
let suffix = 2
while (true) {
const numbered = MARINE_CREATURES.map((name) => `${normalize(name)}-${suffix}`).filter(
(name) => !used.has(name)
)
if (numbered.length > 0) {
return pickRandom(numbered, random)
}
suffix += 1
for (const retiredName of retired.names) {
used.add(normalizeSuggestedName(retiredName))
}
return selectSuggestedCreatureName(used, random, retired.exhaustedTiers)
}
@@ -5,11 +5,11 @@ import {
buildMobileAiVaultResumeLaunch,
buildMobileAiVaultResumeCommand,
createMobileAiVaultResumeMutationRegistry,
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal
} from './ai-vault-resume-launch'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
@@ -24,20 +24,6 @@ import {
} from './mobile-diff-review-rpc'
import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target'
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
])
export function buildMobileAiVaultResumeCommand(args: {
session: Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
Partial<Pick<AiVaultSession, 'filePath'>>
@@ -237,16 +223,6 @@ export function createMobileAiVaultResumeMutationRegistry(
}
}
export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null {
if (!statusResult || typeof statusResult !== 'object') {
return null
}
const hostPlatform = (statusResult as { hostPlatform?: unknown }).hostPlatform
return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform)
? (hostPlatform as NodeJS.Platform)
: null
}
export function readMobileRuntimeTerminalWindowsShell(statusResult: unknown): string | null {
if (!statusResult || typeof statusResult !== 'object') {
return null
@@ -186,4 +186,17 @@ describe('mobile session startup', () => {
newTabActions.indexOf("label: 'Markdown Note'")
)
})
it('counts a pending-handle active terminal as a tabs recovery need (STA-4256)', () => {
const recoveryNeed = sliceBetween(
'const hasSessionTabsRecoveryNeed = useCallback(',
'const getSessionTabsApplicationRevision ='
)
expect(recoveryNeed).toContain('hasPendingTerminalHandleRecoveryNeed(')
expect(recoveryNeed).toContain('sessionTabsRef.current')
expect(recoveryNeed).toContain('activeSessionTabIdRef.current')
// The predicate only reaches the poll loop through this hook's option.
expect(source).toContain('hasRecoveryNeed: hasSessionTabsRecoveryNeed')
})
})
@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types'
import { hasPendingTerminalHandleRecoveryNeed } from './pending-terminal-handle-recovery'
import {
MobileSessionTabsStreamHealth,
type SessionTabsApplyOutcome
} from './mobile-session-tabs-stream-health'
const TAB_ID = 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479'
function terminalTab(terminal: string | null): MobileSessionTab {
return {
type: 'terminal',
id: TAB_ID,
title: 'zsh',
parentTabId: 'tab-1',
leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
status: terminal === null ? 'pending-handle' : 'ready',
terminal,
isActive: true
}
}
function snapshot(terminal: string | null, type?: 'snapshot' | 'updated'): SessionTabsResult {
return {
worktree: 'id:repo::worktree',
publicationEpoch: 'host:1',
snapshotVersion: 1,
tabs: [terminalTab(terminal)],
activeTabId: TAB_ID,
activeTabType: 'terminal',
...(type ? { type } : {})
} as SessionTabsResult
}
describe('hasPendingTerminalHandleRecoveryNeed', () => {
it('reports a need while the active terminal tab has no handle', () => {
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], TAB_ID)).toBe(true)
})
it('clears once the host publishes the materialized handle', () => {
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab('term-1')], TAB_ID)).toBe(false)
})
it('ignores a pending terminal the user is not looking at', () => {
const other: MobileSessionTab = {
type: 'markdown',
id: 'md-1',
title: 'NOTES.md',
filePath: '/w/NOTES.md',
relativePath: 'NOTES.md',
isDirty: false,
isActive: false,
documentVersion: '1'
}
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null), other], 'md-1')).toBe(false)
})
it('reports no need with no selection or an unknown selection', () => {
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], null)).toBe(false)
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], 'gone')).toBe(false)
})
})
/**
* STA-4256: the phone rendered its spinner forever because a `live` tabs stream
* parks `poll()`, so a host that mints the handle without republishing was never
* asked again. These drive the real controller to prove the predicate is what
* keeps `session.tabs.list` flowing, and that it stops once the handle lands.
*/
describe('pending-handle recovery through MobileSessionTabsStreamHealth', () => {
function makeHarness() {
const pending: Array<(response: RpcResponse) => void> = []
const sendRequest = vi.fn(
() =>
new Promise<RpcResponse>((resolve) => {
pending.push(resolve)
})
)
const client = { sendRequest, getGeneration: () => 1 } as unknown as RpcClient
let tabs: MobileSessionTab[] = []
let activeTabId: string | null = null
const controller = new MobileSessionTabsStreamHealth<SessionTabsResult, MobileSessionTab>({
client,
scope: 'id:repo::worktree',
apply: (result): SessionTabsApplyOutcome<MobileSessionTab> => {
tabs = result.tabs
activeTabId = result.activeTabId
return { accepted: true, effectiveTabs: result.tabs }
},
consumeAccepted: () => {},
hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId)
})
return {
controller,
sendRequest,
resolveNext(result: SessionTabsResult) {
const resolve = pending.shift()
expect(resolve).toBeDefined()
resolve?.({ id: 'list', ok: true, result, _meta: { runtimeId: 'runtime-1' } })
},
readTabs: () => tabs
}
}
async function settle(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
}
/** Subscribe, snapshot, updated — the sequence that certifies the stream `live`. */
async function driveToLiveStream(
harness: ReturnType<typeof makeHarness>,
terminal: string | null
) {
harness.controller.setReconciliationActive(true)
const subscription = harness.controller.beginSubscription()
subscription.listener(snapshot(terminal, 'snapshot'))
await settle()
harness.resolveNext(snapshot(terminal))
await settle()
subscription.listener(snapshot(terminal, 'updated'))
await settle()
harness.sendRequest.mockClear()
expect(harness.controller.isCertified()).toBe(true)
}
it('keeps polling session.tabs.list while the active terminal has no handle', async () => {
const harness = makeHarness()
await driveToLiveStream(harness, null)
expect(harness.controller.poll()).not.toBeNull()
await settle()
expect(harness.sendRequest).toHaveBeenCalledWith('session.tabs.list', {
worktree: 'id:repo::worktree'
})
})
it('stops polling once a fresh snapshot carries the handle', async () => {
const harness = makeHarness()
await driveToLiveStream(harness, null)
expect(harness.controller.poll()).not.toBeNull()
await settle()
harness.resolveNext(snapshot('term-1'))
await settle()
expect(harness.readTabs()[0]).toMatchObject({ status: 'ready', terminal: 'term-1' })
expect(harness.controller.poll()).toBeNull()
})
it('parks the poll when the active terminal already has its handle', async () => {
const harness = makeHarness()
await driveToLiveStream(harness, 'term-1')
expect(harness.controller.poll()).toBeNull()
expect(harness.sendRequest).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,24 @@
import type { MobileSessionTab } from './mobile-session-route-types'
/**
* Whether the tab the user is looking at is a terminal the host published
* without a PTY handle (`status: 'pending-handle'`). The session screen renders
* a spinner for that tab and can only leave it when a snapshot carries the
* materialized handle — so while this holds, the tabs reconciler must keep
* asking. Without it a `live` stream parks the poll, and a host that mints the
* handle without republishing strands the pane on the spinner forever
* (STA-4256).
*
* Mirrors the route's `activePendingTerminalTab` derivation exactly, so this is
* true precisely when the spinner is on screen.
*/
export function hasPendingTerminalHandleRecoveryNeed(
tabs: readonly MobileSessionTab[],
activeTabId: string | null
): boolean {
if (activeTabId === null) {
return false
}
const active = tabs.find((tab) => tab.id === activeTabId)
return active?.type === 'terminal' && typeof active.terminal !== 'string'
}
@@ -34,6 +34,7 @@ describe('createBlankWorkspace', () => {
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
})
@@ -56,6 +57,25 @@ describe('createBlankWorkspace', () => {
expect('comment' in params).toBe(false)
})
it('marks the name as generated only when the user typed nothing', async () => {
// Why: the host retires generated names permanently; a name the user chose must stay reusable.
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-3' } }), calls)
await createBlankWorkspace({
client,
repoId: 'repo-1',
baseName: 'octopus',
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: true,
supportsIdempotentCutoverRetry: true
})
expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true })
})
it('sends startupAgent (not a pre-built command) so the host resolves launch args', async () => {
// Why: regression — the modal used to send a bare startupCommand ('claude')
// that skipped the host's default `--dangerously-skip-permissions`.
@@ -69,6 +89,7 @@ describe('createBlankWorkspace', () => {
createdWithAgentId: 'claude',
comment: 'spike',
setupDecision: 'run',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
})
@@ -100,6 +121,7 @@ describe('createBlankWorkspace', () => {
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
})
@@ -125,6 +147,7 @@ describe('createBlankWorkspace', () => {
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
})
@@ -143,6 +166,7 @@ describe('createBlankWorkspace', () => {
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'skip',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
})
@@ -16,17 +16,22 @@ export async function createBlankWorkspace(args: {
createdWithAgentId: TuiAgent | undefined
comment: string | undefined
setupDecision: WorkspaceCreateSetupDecision
/** True when `baseName` is a generated creature name rather than one the user typed; only then
* may the host retire it. */
nameWasGenerated: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
}): Promise<WorktreeCreateResult> {
return createWorktreeWithNameRetry({
client: args.client,
baseName: args.baseName,
nameWasGenerated: args.nameWasGenerated,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
buildParams: (name) => {
const params: Record<string, unknown> = {
repo: `id:${args.repoId}`,
setupDecision: args.setupDecision,
name,
...(args.nameWasGenerated ? { nameWasGenerated: true } : {}),
...agentLaunchCreateFields(args.createdWithAgentId)
}
if (args.comment) {
@@ -18,7 +18,7 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien
return {
id: '1',
ok: true,
result: { capabilities: outcome },
result: { capabilities: outcome, hostPlatform: 'darwin' },
_meta: { runtimeId: 'r' }
}
}
@@ -33,7 +33,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
)
).resolves.toEqual({
tasksSupported: true,
idempotentWorktreeCreateSupported: true
idempotentWorktreeCreateSupported: true,
hostPlatform: 'darwin'
})
})
@@ -44,14 +45,16 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
)
).resolves.toEqual({
tasksSupported: false,
idempotentWorktreeCreateSupported: true
idempotentWorktreeCreateSupported: true,
hostPlatform: 'darwin'
})
})
it('fails closed when capability detection is unavailable', async () => {
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
tasksSupported: false,
idempotentWorktreeCreateSupported: false
idempotentWorktreeCreateSupported: false,
hostPlatform: null
})
})
})
+12 -5
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
@@ -14,11 +15,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5
export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
idempotentWorktreeCreateSupported: boolean
hostPlatform: NodeJS.Platform | null
}
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
tasksSupported: false,
idempotentWorktreeCreateSupported: false
idempotentWorktreeCreateSupported: false,
hostPlatform: null
}
// Why: status.get is safe to replay and must settle before create, independently
@@ -32,13 +35,14 @@ export async function readNewWorktreeRuntimeCapabilities(
if (!response.ok) {
return UNSUPPORTED_CAPABILITIES
}
const capabilities =
((response as RpcSuccess).result as { capabilities?: string[] }).capabilities ?? []
const result = (response as RpcSuccess).result as { capabilities?: string[] }
const capabilities = result.capabilities ?? []
return {
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
idempotentWorktreeCreateSupported: capabilities.includes(
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
)
),
hostPlatform: readMobileRuntimeHostPlatform(result)
}
} catch (error) {
if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) {
@@ -53,9 +57,11 @@ export function useNewWorktreeRuntimeCapabilities(
enabled: boolean
): {
tasksSupported: boolean
hostPlatform: NodeJS.Platform | null
getWorktreeCreateCutoverSupport: () => Promise<boolean>
} {
const [tasksSupported, setTasksSupported] = useState(false)
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
const capabilityProbeRef = useRef<{
client: RpcClient | null
promise: Promise<NewWorktreeRuntimeCapabilities>
@@ -82,6 +88,7 @@ export function useNewWorktreeRuntimeCapabilities(
void getCapabilities().then((capabilities) => {
if (!stale) {
setTasksSupported(capabilities.tasksSupported)
setHostPlatform(capabilities.hostPlatform)
}
})
return () => {
@@ -93,5 +100,5 @@ export function useNewWorktreeRuntimeCapabilities(
() => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
[getCapabilities]
)
return { tasksSupported, getWorktreeCreateCutoverSupport }
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
}
@@ -154,6 +154,25 @@ describe('createWorktreeWithNameRetry', () => {
expect(attempts[1]!.params.name).toBe('topic-2')
})
it('advances generated retries without nesting suffixes', async () => {
const attempts: Attempt[] = []
const client = scriptedClient(
[{ errorMessage: 'already exists locally' }, { id: 'wt-generated' }],
attempts
)
const result = await createWorktreeWithNameRetry({
client,
baseName: 'nautilus-2',
nameWasGenerated: true,
buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }),
supportsIdempotentCutoverRetry: false
})
expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' })
expect(attempts.map((attempt) => attempt.params.name)).toEqual(['nautilus-2', 'nautilus-3'])
})
it('does not replay an ambiguous cutover when the host lacks idempotency support', async () => {
const attempts: Attempt[] = []
const client = scriptedClient([{ throws: new LogicalClientCutoverError() }], attempts)
+5 -1
View File
@@ -4,6 +4,7 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
getGeneratedWorktreeCreateRetryCandidate,
isRetryableWorktreeCreateConflict
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
@@ -26,6 +27,7 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
export type CreateWorktreeWithNameRetryArgs = {
client: RpcClient
baseName: string
nameWasGenerated?: boolean
buildParams: (name: string) => Record<string, unknown>
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
maxAttempts?: number
@@ -49,7 +51,9 @@ export async function createWorktreeWithNameRetry(
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
let lastError: string | null = null
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidateName = getClientWorktreeCreateCandidate(baseName, attempt)
const candidateName = args.nameWasGenerated
? getGeneratedWorktreeCreateRetryCandidate(baseName, attempt)
: getClientWorktreeCreateCandidate(baseName, attempt)
const candidateParams = buildParams(candidateName)
// Why: older hosts strip unknown fields, so only stamp and replay when the
// host advertises idempotency. One key per candidate makes cutover retries
@@ -0,0 +1,12 @@
const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_PARAM = 'supportsWorktreeVisibilitySourceDefaults'
export function projectMobileRpcRequestParams(method: string, params: unknown): unknown {
if (method !== 'worktree.ps') {
return params
}
const current =
params && typeof params === 'object' && !Array.isArray(params)
? (params as Record<string, unknown>)
: {}
return { ...current, [WORKTREE_VISIBILITY_SOURCE_DEFAULTS_PARAM]: true }
}
@@ -0,0 +1,20 @@
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
])
export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null {
const hostPlatform = (statusResult as { hostPlatform?: unknown } | null)?.hostPlatform
return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform)
? (hostPlatform as NodeJS.Platform)
: null
}
@@ -66,6 +66,23 @@ function deferred<T>() {
}
describe('stable logical RPC client', () => {
it('advertises source-default support on worktree catalog requests', async () => {
const session = new FakeSession('connected')
session.sendRequest.mockResolvedValue(success([]))
const client = createStableLogicalRpcClient(session, 'lan')
await client.sendRequest('worktree.ps', { limit: 10_000 })
await client.sendRequest('status.get')
expect(session.sendRequest).toHaveBeenNthCalledWith(
1,
'worktree.ps',
{ limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true },
undefined
)
expect(session.sendRequest).toHaveBeenNthCalledWith(2, 'status.get', undefined, undefined)
})
it('makes before break, rejects in-flight work, and replays subscriptions', async () => {
const oldSession = new FakeSession('connected')
const nextSession = new FakeSession('connecting')
@@ -5,6 +5,7 @@ import {
type MigrationDialStateForwarder
} from './migration-dial-state-forwarder'
import { waitForAuthenticated } from './replacement-session-authentication'
import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
@@ -84,22 +85,24 @@ export function createStableLogicalRpcClient(
return new Promise<RpcResponse>((resolve, reject) => {
const pending = { reject }
pendingRequests.add(pending)
void session.sendRequest(method, params, options).then(
(response) => {
pendingRequests.delete(pending)
if (closed) {
reject(new Error('Client closed'))
} else if (requestGeneration !== generation) {
reject(new LogicalClientCutoverError())
} else {
resolve(response)
void session
.sendRequest(method, projectMobileRpcRequestParams(method, params), options)
.then(
(response) => {
pendingRequests.delete(pending)
if (closed) {
reject(new Error('Client closed'))
} else if (requestGeneration !== generation) {
reject(new LogicalClientCutoverError())
} else {
resolve(response)
}
},
(error: unknown) => {
pendingRequests.delete(pending)
reject(error)
}
},
(error: unknown) => {
pendingRequests.delete(pending)
reject(error)
}
)
)
})
},
@@ -0,0 +1,143 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
EMPTY_RETIRED_NAME_REGISTRY,
type RetiredNameRegistry
} from '../../../src/shared/worktree/retired-name-registry'
import { useRetiredWorktreeNames } from './use-retired-worktree-names'
type Pending = { resolve: (response: unknown) => void; reject: (err: Error) => void }
/** Renders the hook against a deferred RPC client so a test decides exactly when each
* `worktree.listRetiredNames` lands, and for which repo. */
function mountNames() {
const pending: Pending[] = []
const requests: { method: string; params: unknown }[] = []
let latest: RetiredNameRegistry = EMPTY_RETIRED_NAME_REGISTRY
const client = {
sendRequest: (method: string, params: unknown) => {
requests.push({ method, params })
return new Promise<unknown>((resolve, reject) => pending.push({ resolve, reject }))
}
} as unknown as RpcClient
function Probe({ repoId, refreshKey }: { repoId: string | null; refreshKey: string }) {
latest = useRetiredWorktreeNames(client, repoId, refreshKey)
return null
}
let renderer!: ReturnType<typeof create>
act(() => {
renderer = create(createElement(Probe, { repoId: 'repo-1', refreshKey: 'a' }))
})
return {
get registry(): RetiredNameRegistry {
return latest
},
get names(): readonly string[] {
return latest.names
},
requests,
rerender(props: { repoId: string | null; refreshKey: string }) {
act(() => {
renderer.update(createElement(Probe, props))
})
},
async settle(
index: number,
retiredNamesByRepo: Record<string, unknown>,
retiredNameTiersByRepo: Record<string, unknown> = {}
) {
await act(async () => {
pending[index]!.resolve({ result: { retiredNamesByRepo, retiredNameTiersByRepo } })
await Promise.resolve()
})
},
async fail(index: number) {
await act(async () => {
pending[index]!.reject(new Error('host unreachable'))
await Promise.resolve()
})
}
}
}
describe('useRetiredWorktreeNames', () => {
it('reads the selected repo out of the response envelope', async () => {
const probe = mountNames()
expect(probe.requests[0]).toEqual({
method: 'worktree.listRetiredNames',
params: { repo: 'id:repo-1' }
})
await probe.settle(0, { 'repo-1': ['nautilus'], 'repo-2': ['seahorse'] })
expect(probe.names).toEqual(['nautilus'])
})
it('answers empty for a host that omits the field', async () => {
const probe = mountNames()
await probe.settle(0, {})
expect(probe.registry).toEqual(EMPTY_RETIRED_NAME_REGISTRY)
})
it('carries the compaction watermark through, so spent tiers are not suggested back', async () => {
const probe = mountNames()
await probe.settle(0, { 'repo-1': ['nautilus-2'] }, { 'repo-1': 1 })
expect(probe.registry).toEqual({ exhaustedTiers: 1, names: ['nautilus-2'] })
})
it('holds the watermark too when a refresh fails', async () => {
const probe = mountNames()
await probe.settle(0, { 'repo-1': [] }, { 'repo-1': 3 })
probe.rerender({ repoId: 'repo-1', refreshKey: 'b' })
await probe.fail(1)
expect(probe.registry.exhaustedTiers).toBe(3)
})
// Regression: this hook used to reset to [] on any error, un-retiring every name for the rest of
// the sheet session. Desktop always held the previous answer; both now share that rule.
it('keeps previously loaded names when a refresh fails', async () => {
const probe = mountNames()
await probe.settle(0, { 'repo-1': ['nautilus'] })
expect(probe.names).toEqual(['nautilus'])
probe.rerender({ repoId: 'repo-1', refreshKey: 'b' })
await probe.fail(1)
expect(probe.names).toEqual(['nautilus'])
})
// Regression: the effect used to depend on [client, repoId] only, so a workspace created while
// the sheet was open never made it into the pool.
it('refetches when the workspace list changes', async () => {
const probe = mountNames()
await probe.settle(0, { 'repo-1': ['nautilus'] })
probe.rerender({ repoId: 'repo-1', refreshKey: 'b' })
expect(probe.requests).toHaveLength(2)
// The previous answer stays visible while the refetch is in flight.
expect(probe.names).toEqual(['nautilus'])
await probe.settle(1, { 'repo-1': ['nautilus', 'seahorse'] })
expect(probe.names).toEqual(['nautilus', 'seahorse'])
})
it('drops names when the repo changes rather than showing another repo pool', async () => {
const probe = mountNames()
await probe.settle(0, { 'repo-1': ['nautilus'] })
probe.rerender({ repoId: 'repo-2', refreshKey: 'a' })
expect(probe.names).toEqual([])
})
it('reports nothing without a selected repo, and asks the host nothing', () => {
const probe = mountNames()
probe.rerender({ repoId: null, refreshKey: 'a' })
expect(probe.names).toEqual([])
expect(probe.requests).toHaveLength(1)
})
})
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import {
readRetiredNameRegistryForRepo,
retiredNamesAfterRefresh,
selectRetiredNameRegistry,
type RetiredNamesLoad
} from '../../../src/shared/worktree/retired-name-cache'
import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry'
import type { RpcClient } from '../transport/rpc-client'
/** Names already spent in a repo, including workspaces that have since been deleted.
*
* Why a targeted request rather than the workspace catalog: the catalog is served by `worktree.ps`,
* which carries rows only. Retired names are needed just while the create sheet is open and only
* for one repo, so this asks for exactly that.
*
* `refreshKey` must change on every workspace-list mutation. Caching rules live in
* `retired-name-cache` so this and the desktop hook cannot drift on what a failure means, and no
* loading state is reported because create is never gated on this fetch. */
export function useRetiredWorktreeNames(
client: RpcClient | null | undefined,
repoId: string | null | undefined,
refreshKey: unknown
): RetiredNameRegistry {
const [loaded, setLoaded] = useState<RetiredNamesLoad | null>(null)
const activeRepoId = client && repoId ? repoId : null
useEffect(() => {
if (!client || !activeRepoId) {
setLoaded(null)
return
}
let cancelled = false
const settle = (registry: RetiredNameRegistry | null): void => {
if (!cancelled) {
setLoaded((previous) => retiredNamesAfterRefresh(previous, activeRepoId, registry))
}
}
void client
.sendRequest('worktree.listRetiredNames', { repo: `id:${activeRepoId}` })
.then((response) =>
settle(
readRetiredNameRegistryForRepo((response as { result?: unknown }).result, activeRepoId)
)
)
.catch(() => settle(null))
return () => {
cancelled = true
}
}, [activeRepoId, client, refreshKey])
return selectRetiredNameRegistry(loaded, activeRepoId)
}
@@ -1,6 +1,6 @@
import type { Worktree } from './workspace-list-sections'
// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
// Why: matches desktop's PR_GROUP_META naming from worktree-list/rows/groups.ts.
// no PR/draft/unknown -> "In Progress", open -> "In Review", merged -> "Done", closed -> "Closed"
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
@@ -727,7 +727,7 @@ final class Provider {
private func click(params: [String: JSONValue]) throws -> [String: Any] {
let snapshot = try currentSnapshot(params: params)
let button = params["mouseButton"]?.string ?? "left"
let button = try mouseButton(params["mouseButton"]?.string)
let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount")
guard count <= SyntheticMouseClickDelivery.maxClickCount else {
throw ProviderError.coded(
@@ -741,13 +741,16 @@ final class Provider {
recoverWindow(snapshot.app, windowId: snapshot.windowId, windowBounds: snapshot.windowBounds)
if let elementIndex = try optionalInteger(params, "elementIndex") {
let record = try element(snapshot, elementIndex)
if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
if modifiers.isEmpty,
count <= 1,
button.hasAccessibilityAction,
let actionName = try performClickAction(record: record, mouseButton: button) {
return actionMetadata(path: "accessibility", actionName: actionName)
}
if let point = center(record.localFrame, in: snapshot.windowBounds) {
try Input.click(
at: point,
button: mouseButton(button),
button: button,
count: count,
modifiers: modifiers,
targetWindow: snapshot
@@ -763,7 +766,7 @@ final class Provider {
let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot)
try Input.click(
at: point,
button: mouseButton(button),
button: button,
count: count,
modifiers: modifiers,
targetWindow: snapshot
@@ -774,8 +777,8 @@ final class Provider {
)
}
private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? {
if mouseButton == "right" {
private func performClickAction(record: ElementRecord, mouseButton: MouseButtonSelection) throws -> String? {
if mouseButton == .right {
return performAction(record.element, "AXShowMenu") ? "AXShowMenu" : nil
}
for action in ["AXPress", "AXConfirm", "AXOpen"] {
@@ -1634,16 +1637,17 @@ private func screenshotScale(screenshot: ScreenshotPayload?, bounds: CGRect) ->
)
}
private enum MouseButton {
case left
case right
extension MouseButtonSelection {
// Why: macOS has no dedicated middle-button event family; it rides `otherMouse*`
// with the button number carried by `mouseButton:` on the event constructor.
var cgButton: CGMouseButton {
switch self {
case .left:
return .left
case .right:
return .right
case .middle:
return .center
}
}
@@ -1653,6 +1657,8 @@ private enum MouseButton {
return .leftMouseDown
case .right:
return .rightMouseDown
case .middle:
return .otherMouseDown
}
}
@@ -1662,20 +1668,18 @@ private enum MouseButton {
return .leftMouseUp
case .right:
return .rightMouseUp
case .middle:
return .otherMouseUp
}
}
}
private func mouseButton(_ raw: String?) throws -> MouseButton {
switch raw ?? "left" {
case "left":
return .left
case "right":
return .right
case "middle":
throw ProviderError.coded("invalid_argument", "middle-click is not yet supported")
case let value:
throw ProviderError.coded("invalid_argument", "unsupported mouse button '\(value)'")
private func mouseButton(_ raw: String?) throws -> MouseButtonSelection {
switch ActionArgumentValidation.mouseButton(raw) {
case let .success(button):
return button
case let .failure(error):
throw ProviderError.coded("invalid_argument", error.message)
}
}
@@ -2492,7 +2496,7 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? {
private enum Input {
static func click(
at point: CGPoint,
button: MouseButton,
button: MouseButtonSelection,
count: Int,
modifiers: [KeyModifier],
targetWindow: Snapshot
@@ -6,6 +6,18 @@ public struct ActionArgumentValidationError: Error, Equatable {
}
}
public enum MouseButtonSelection: String, Equatable, Sendable, CaseIterable {
case left
case right
case middle
/// Why: AXPress/AXShowMenu only model primary and secondary intent, so a middle
/// click has no accessibility equivalent and must reach the app as real events.
public var hasAccessibilityAction: Bool {
self != .middle
}
}
public enum ActionArgumentValidation {
public static func positiveInteger(
_ value: Double?,
@@ -31,6 +43,16 @@ public enum ActionArgumentValidation {
return .success(value)
}
public static func mouseButton(
_ value: String?
) -> Result<MouseButtonSelection, ActionArgumentValidationError> {
guard let value else { return .success(.left) }
guard let button = MouseButtonSelection(rawValue: value) else {
return .failure(ActionArgumentValidationError("unsupported mouse button '\(value)'"))
}
return .success(button)
}
public static func scrollDirection(_ value: String) -> Result<String, ActionArgumentValidationError> {
switch value {
case "up", "down", "left", "right":
@@ -54,6 +54,30 @@ final class ActionArgumentValidationTests: XCTestCase {
)
}
func testMouseButtonDefaultsToLeftAndAcceptsEveryButton() {
XCTAssertEqual(try ActionArgumentValidation.mouseButton(nil).get(), .left)
XCTAssertEqual(try ActionArgumentValidation.mouseButton("left").get(), .left)
XCTAssertEqual(try ActionArgumentValidation.mouseButton("right").get(), .right)
XCTAssertEqual(try ActionArgumentValidation.mouseButton("middle").get(), .middle)
}
func testMouseButtonRejectsUnknownButtons() {
XCTAssertEqual(
failureMessage(ActionArgumentValidation.mouseButton("primary")),
"unsupported mouse button 'primary'"
)
XCTAssertEqual(
failureMessage(ActionArgumentValidation.mouseButton("")),
"unsupported mouse button ''"
)
}
func testOnlyMiddleButtonLacksAnAccessibilityAction() {
XCTAssertTrue(MouseButtonSelection.left.hasAccessibilityAction)
XCTAssertTrue(MouseButtonSelection.right.hasAccessibilityAction)
XCTAssertFalse(MouseButtonSelection.middle.hasAccessibilityAction)
}
func testScrollDirectionRejectsUnknownDirections() {
XCTAssertEqual(try ActionArgumentValidation.scrollDirection("down").get(), "down")
XCTAssertEqual(
+2
View File
@@ -70,6 +70,8 @@ ORCA computer get-app-state --app <app> --restore-window --json
ORCA computer click --app <app> --element-index <index> --json
ORCA computer click --app <app> --x 100 --y 100 --json
ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json
ORCA computer click --app <app> --element-index <index> --mouse-button right --json
ORCA computer click --app <app> --element-index <index> --mouse-button middle --json
ORCA computer perform-secondary-action --app <app> --element-index <index> --action <name> --json
ORCA computer set-value --app <app> --element-index <index> --value "text" --json
ORCA computer type-text --app <app> --text "text" --json
+15 -3
View File
@@ -470,8 +470,19 @@ SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them
`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.
For an explicitly requested one-VM-per-workspace checkout, the create script must read
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, and `ORCA_REPO_BRANCH`, create
that exact primary checkout at `projectRoot`, and emit the same SSH result with:
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, `ORCA_REPO_REF_HEAD`, and
`ORCA_REPO_BRANCH`. Use `ORCA_REPO_REF` to fetch the selected source, but create
`ORCA_REPO_BRANCH` at the exact `ORCA_REPO_REF_HEAD` commit; resolving the symbolic ref again can race
with an upstream update. `ORCA_REPO_URL` and `ORCA_REPO_REF` are a matched fetch pair, including when
the desktop source uses multiple remotes. Return that primary checkout at `projectRoot` and emit the
same SSH result with:
```bash
[ -n "${ORCA_REPO_REF_HEAD:-}" ] || { echo "missing pinned source commit" >&2; exit 1; }
git fetch origin "$ORCA_REPO_REF"
git cat-file -e "${ORCA_REPO_REF_HEAD}^{commit}"
git checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD"
```
```json
{
@@ -645,7 +656,8 @@ worked script in §7g). `pairingCode` is **not** used in SSH mode.
**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add
`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create
the requested branch from `ORCA_REPO_REF` at the returned `projectRoot`, and emit schema version 2 plus
the requested `ORCA_REPO_BRANCH` at the pinned `ORCA_REPO_REF_HEAD` commit (use `ORCA_REPO_REF` only
to fetch that commit) at the returned `projectRoot`, and emit schema version 2 with
`checkoutMode: "provisioned-root"`. All recipes without this field retain the schema-v1 behavior above.
Lifecycle hooks (all run locally):
File diff suppressed because one or more lines are too long
+13
View File
@@ -57,4 +57,17 @@ describe('CLI error recovery', () => {
expect(output).toContain('Fix the command flags or RPC params')
})
it('does not replace mutation recovery with generic runtime startup advice', () => {
const error = new RuntimeClientError(
'runtime_unavailable',
'Re-issue the same command with --retry-request mutation_1.',
{ orchestrationRequestId: 'mutation_1' }
)
const output = formatCliError(error)
expect(output).toContain('--retry-request mutation_1')
expect(output).not.toContain('orca open')
})
})
+11
View File
@@ -78,6 +78,9 @@ export function printResult<TResult>(
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
if (hasOrchestrationRequestId(error.data)) {
return message
}
return `${message}\nOrca is not running. Run 'orca open' first.`
}
// Why: error-specific recovery must win over the generic computer fallback.
@@ -105,6 +108,14 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
return message
}
function hasOrchestrationRequestId(data: unknown): boolean {
return (
data !== null &&
typeof data === 'object' &&
typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string'
)
}
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
if (json) {
if (error instanceof RuntimeRpcFailureError) {
@@ -80,6 +80,32 @@ describe('orca computer action CLI routing', () => {
})
})
it('forwards a middle click to the runtime instead of silently downgrading it', async () => {
queueFixtures(callMock, okFixture('req_click', sampleSnapshot()))
await main(
[
'computer',
'click',
'--session',
'manual',
'--app',
'Finder',
'--element-index',
'3',
'--mouse-button',
'middle',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenCalledWith(
'computer.click',
expect.objectContaining({ mouseButton: 'middle' })
)
})
it('prints session and window context in action follow-up commands', async () => {
queueFixtures(callMock, okFixture('req_click', sampleSnapshot()))
@@ -195,4 +195,47 @@ describe('orchestration gate commands carry caller identity', () => {
expect(stderr).toContain('Pass --from <terminal-handle>')
expect(callMock).not.toHaveBeenCalledWith('orchestration.gateCreate', expect.anything())
})
it('reports idempotent recovery when a mutation connection drops', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
callMock
.mockResolvedValueOnce(okFixture('req_show', { terminal: { handle: 'term_coord' } }))
.mockRejectedValueOnce(
new RuntimeClientError(
'runtime_unavailable',
'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.',
{
orchestrationRequestId: 'mutation_1',
failedStage: 'dispatch_input',
residualResources: [
{ kind: 'worktree', id: 'repo::child' },
{ kind: 'terminal', id: 'term_worker' }
]
}
)
)
await main(
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?', '--json'],
'/tmp/repo'
)
expect(process.exitCode).toBe(1)
const output = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as {
error: { message: string; data: Record<string, unknown> }
}
expect(output.error.message).toContain('--retry-request mutation_1')
expect(output.error.message).toContain('may already have taken effect')
expect(output.error.message).toContain('Failed stage: dispatch_input')
expect(output.error.message).toMatch(/Residual resources:.*repo::child.*term_worker/)
expect(output.error.message).not.toMatch(/restart Orca/i)
expect(output.error.data).toMatchObject({
orchestrationRequestId: 'mutation_1',
failedStage: 'dispatch_input',
residualResources: expect.arrayContaining([
expect.objectContaining({ kind: 'worktree', id: 'repo::child' }),
expect.objectContaining({ kind: 'terminal', id: 'term_worker' })
])
})
})
})
+6 -6
View File
@@ -32,6 +32,7 @@ import {
type LegacyCompatibilityResult,
type OrchestrationMessageSummary as MessageSummary
} from '../../shared/orchestration-check-output'
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
@@ -397,14 +398,13 @@ function callMutation<TResult>(
options?: { timeoutMs?: number; orchestrationCapability?: string }
) {
const requestId = getOptionalStringFlag(flags, 'retry-request')
if (!requestId) {
return options
const result = requestId
? client.call<TResult>(method, params, { ...options, orchestrationRequestId: requestId })
: options
? client.call<TResult>(method, params, options)
: client.call<TResult>(method, params)
}
return client.call<TResult>(method, params, {
...options,
orchestrationRequestId: requestId
return result.catch((error) => {
throw orchestrationMutationRecoveryError(error)
})
}
+74
View File
@@ -61,3 +61,77 @@ describe('terminal close CLI', () => {
expect(help).toContain('durable persistence')
})
})
describe('terminal send CLI', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('marks combined text and Enter as an agent prompt candidate', async () => {
const call = vi.fn().mockResolvedValue({
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 7 } }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal send']({
flags: new Map<string, string | true>([
['terminal', 'term-1'],
['text', 'review'],
['enter', true]
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(call).toHaveBeenCalledWith('terminal.send', {
terminal: 'term-1',
text: 'review',
enter: true,
interrupt: false,
agentPrompt: true,
client: { id: 'orca-cli', type: 'desktop' }
})
})
it('keeps text-only and bare Enter sends as direct terminal input', async () => {
const call = vi.fn().mockResolvedValue({
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 1 } }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal send']({
flags: new Map<string, string | true>([
['terminal', 'term-1'],
['text', 'x']
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
await TERMINAL_HANDLERS['terminal send']({
flags: new Map<string, string | true>([
['terminal', 'term-1'],
['enter', true]
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(call).toHaveBeenNthCalledWith(1, 'terminal.send', {
terminal: 'term-1',
text: 'x',
enter: false,
interrupt: false,
client: { id: 'orca-cli', type: 'desktop' }
})
expect(call).toHaveBeenNthCalledWith(2, 'terminal.send', {
terminal: 'term-1',
text: undefined,
enter: true,
interrupt: false,
client: { id: 'orca-cli', type: 'desktop' }
})
})
})
+7 -3
View File
@@ -84,11 +84,15 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatTerminalRead)
},
'terminal send': async ({ flags, client, cwd, json }) => {
const text = getOptionalStringFlag(flags, 'text')
const enter = flags.get('enter') === true
const interrupt = flags.get('interrupt') === true
const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', {
terminal: await getTerminalHandle(flags, cwd, client),
text: getOptionalStringFlag(flags, 'text'),
enter: flags.get('enter') === true,
interrupt: flags.get('interrupt') === true,
text,
enter,
interrupt,
...(text && enter && !interrupt ? { agentPrompt: true } : {}),
client: { id: 'orca-cli', type: 'desktop' }
})
printResult(result, json, formatTerminalSend)
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes positional automation ids to edit, remove, run, and show', async () => {
queueFixtures(
callMock,
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }),
okFixture('req_remove', { removed: true, id: 'auto-1' }),
okFixture('req_run', {
run: {
id: 'run-1',
automationId: 'auto-1',
title: 'Paused run 1',
status: 'pending',
trigger: 'manual',
scheduledFor: 1,
workspaceId: null,
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
outputSnapshot: null,
usage: null,
error: null,
startedAt: null,
dispatchedAt: null,
createdAt: 1
}
}),
okFixture('req_show', { automation: { id: 'auto-1', name: 'Paused' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['automations', 'edit', 'auto-1', '--disabled', '--json'], '/tmp/repo')
await main(['automations', 'remove', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', {
id: 'auto-1',
updates: {
name: undefined,
prompt: undefined,
agentId: undefined,
repo: undefined,
workspace: undefined,
workspaceMode: undefined,
baseBranch: undefined,
timezone: undefined,
enabled: false,
missedRunGraceMinutes: undefined
}
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', {
id: 'auto-1'
})
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', {
id: 'auto-1'
})
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', {
id: 'auto-1'
})
})
it('rejects ambiguous positional and flag automation ids before dispatch', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['automations', 'show', 'auto-1', '--id', 'auto-2', '--json'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: 'Pass --id either positionally or as a flag, not both.'
}
})
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('rejects invalid automation --day values before calling the runtime', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Weekly review',
'--trigger',
'weekly',
'--day',
'7',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--day must be an integer from 0 to 6'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
name: 'day on daily preset',
args: ['--trigger', 'daily', '--day', '2'],
message: '--day can only be used with the weekly automation preset'
},
{
name: 'time on custom cron',
args: ['--trigger', '0 9 * * *', '--time', '10:30'],
message: '--time can only be used with preset automation triggers'
},
{
name: 'time on hourly preset',
args: ['--trigger', 'hourly', '--time', '10:30'],
message: '--time cannot be used with the hourly automation preset'
}
])('rejects automation schedule modifier mismatch: $name', async ({ args, message }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
...args,
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
name: 'create',
args: [
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--time',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
]
},
{
name: 'edit',
args: ['automations', 'edit', 'auto-1', '--trigger', 'daily', '--time', '--json']
}
])('rejects bare automation --time on $name', async ({ args }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(args, '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--time must use HH:MM format'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation edit schedule modifiers without a schedule flag', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['automations', 'edit', 'auto-1', '--day', '7', '--json'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--day requires --trigger or --schedule'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes automation session reuse flags through create and edit', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_create', {
automation: { id: 'auto-1', name: 'Daily review' }
}),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'Daily review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--workspace',
'current',
'--reuse-session',
'--json'
],
'/tmp/repo/feature/src'
)
await main(['automations', 'edit', 'auto-1', '--fresh-session', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(
2,
'automation.create',
expect.objectContaining({
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
reuseSession: true
})
)
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.update', {
id: 'auto-1',
updates: expect.objectContaining({ reuseSession: false })
})
})
it('rejects conflicting automation session reuse flags', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['automations', 'edit', 'auto-1', '--reuse-session', '--fresh-session', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --reuse-session or --fresh-session, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
@@ -0,0 +1,164 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes automation source context JSON through create', async () => {
const sourceContext = {
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
projectHostSetupId: 'setup-gpu',
repoId: 'repo-gpu',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' },
accountLabel: 'gpu-bot'
}
queueFixtures(
callMock,
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'GPU task review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'GPU task review',
'--trigger',
'daily',
'--prompt',
'Review open work',
'--provider',
'codex',
'--repo',
'id:repo-gpu',
'--source-context',
JSON.stringify(sourceContext),
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(
1,
'automation.create',
expect.objectContaining({
repo: 'id:repo-gpu',
sourceContext
})
)
})
it('clears automation source context on edit with null', async () => {
queueFixtures(
callMock,
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'GPU task review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(
1,
'automation.update',
expect.objectContaining({
id: 'auto-1',
updates: expect.objectContaining({
sourceContext: null
})
})
)
})
it('rejects invalid automation source context JSON before calling the runtime', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'GPU task review',
'--trigger',
'daily',
'--prompt',
'Review open work',
'--provider',
'codex',
'--repo',
'id:repo-gpu',
'--source-context',
'{nope',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--source-context must be valid JSON'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
+444
View File
@@ -0,0 +1,444 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('creates an automation for the enclosing worktree by default', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_automation_create', {
automation: {
id: 'auto-1',
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'existing',
workspaceId: 'repo-1::/tmp/repo/feature',
baseBranch: null,
timezone: 'America/Toronto',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1,
enabled: true,
nextRunAt: 2,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 1,
updatedAt: 1
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
repo: undefined,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
baseBranch: undefined,
reuseSession: undefined,
timezone: undefined,
enabled: undefined,
missedRunGraceMinutes: undefined,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: expect.any(Number)
})
})
it('resolves project and host flags for automation create', async () => {
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-local',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-local',
path: '/tmp/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-gpu',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
}),
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'GPU review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'GPU review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--project',
'github:stablyai/orca',
'--host',
'runtime:gpu',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
expect(callMock).toHaveBeenNthCalledWith(
2,
'automation.create',
expect.objectContaining({
repo: 'id:repo-gpu',
runContext: {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
projectHostSetupId: 'setup-gpu',
repoId: 'repo-gpu',
path: '/srv/orca'
},
workspace: undefined,
workspaceMode: 'new_per_run'
})
)
})
it('resolves project-host-setup flags for automation edit with explicit run context', async () => {
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-gpu',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
}),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'GPU review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['automations', 'edit', 'auto-1', '--project-host-setup', 'setup-gpu', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
expect(callMock).toHaveBeenNthCalledWith(
2,
'automation.update',
expect.objectContaining({
id: 'auto-1',
updates: expect.objectContaining({
repo: 'id:repo-gpu',
runContext: {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
projectHostSetupId: 'setup-gpu',
repoId: 'repo-gpu',
path: '/srv/orca'
}
})
})
)
})
it('rejects automation create with both repo and workspace targets', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--repo',
'id:repo-1',
'--workspace',
'id:repo-1::/tmp/repo/feature',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --repo or --workspace, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation edit with both repo and workspace targets', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'edit',
'auto-1',
'--repo',
'id:repo-1',
'--workspace',
'id:repo-1::/tmp/repo/feature',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --repo or --workspace, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
flag: 'enabled',
value: 'false',
message: '--enabled does not take a value'
},
{
flag: 'disabled',
value: 'false',
message: '--disabled does not take a value'
}
])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--repo',
'id:repo-1',
`--${flag}`,
value,
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('resolves explicit automation create workspace active from cwd', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'Daily review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--workspace',
'active',
'--json'
],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
repo: undefined,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
baseBranch: undefined,
timezone: undefined,
enabled: undefined,
missedRunGraceMinutes: undefined,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: expect.any(Number)
})
})
it('resolves explicit automation edit workspace current from cwd', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'Daily review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['automations', 'edit', 'auto-1', '--workspace', 'current', '--enabled', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', {
id: 'auto-1',
updates: {
name: undefined,
prompt: undefined,
agentId: undefined,
repo: undefined,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: undefined,
baseBranch: undefined,
reuseSession: undefined,
timezone: undefined,
enabled: true,
missedRunGraceMinutes: undefined
}
})
})
})
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
queueFixtures(
callMock,
okFixture('req_tab_current', {
tab: {
browserPageId: 'page-1',
index: 0,
url: 'https://example.com',
title: 'Example',
active: true,
worktreeId: 'repo-1::/srv/orca/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', {
worktree: undefined
})
})
it('passes emulator gesture points through to the runtime', async () => {
const points = [
{ type: 'begin', x: 0.5, y: 0.98, edge: 3 },
{ type: 'move', x: 0.5, y: 0.4, edge: 3 },
{ type: 'end', x: 0.5, y: 0.2, edge: 3 }
]
queueFixtures(callMock, okFixture('req_emulator_gesture', { ok: true }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['emulator', 'gesture', JSON.stringify(points), '--worktree', 'id:wt-1', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('emulator.gesture', {
points,
device: undefined,
emulator: undefined,
worktree: 'id:wt-1'
})
})
it('rejects emulator gesture points outside normalized coordinates', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'emulator',
'gesture',
JSON.stringify([
{ type: 'begin', x: 1.2, y: 0.8 },
{ type: 'end', x: 0.5, y: 0.2 }
]),
'--worktree',
'id:wt-1',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: '--points[0].x must be between 0 and 1'
}
})
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects emulator gesture points with invalid edge markers', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'emulator',
'gesture',
JSON.stringify([
{ type: 'begin', x: 0.5, y: 0.98, edge: 8 },
{ type: 'end', x: 0.5, y: 0.2, edge: 8 }
]),
'--worktree',
'id:wt-1',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: 'gesture point 0 edge must be an integer between 0 and 4'
}
})
process.exitCode = priorExitCode
})
})
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()])
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['environment', 'list', '--json'], '/tmp/repo')
expect(listEnvironmentsMock).toHaveBeenCalledWith('/tmp/orca-user-data')
expect(callMock).not.toHaveBeenCalled()
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
})
it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'],
'/tmp/repo'
)
expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
name: 'desk',
pairingCode: 'orca://pair#abc'
})
expect(callMock).not.toHaveBeenCalled()
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
})
})
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('collects and formats memory diagnostics', async () => {
queueFixtures(
callMock,
okFixture('req_memory', {
app: {
cpu: 1.25,
memory: 1024 * 1024,
main: { cpu: 0.5, memory: 512 * 1024 },
renderer: { cpu: 0.5, memory: 384 * 1024 },
other: { cpu: 0.25, memory: 128 * 1024 },
history: [1024 * 1024]
},
worktrees: [
{
worktreeId: 'repo::/tmp/repo/feature',
worktreeName: 'feature',
repoId: 'repo',
repoName: 'Orca',
cpu: 2.5,
memory: 1024 * 1024,
sessions: [
{
sessionId: 'pty-1',
paneKey: null,
pid: 123,
cpu: 2.5,
memory: 1024 * 1024
}
],
history: [1024 * 1024]
}
],
host: {
totalMemory: 8 * 1024 * 1024,
freeMemory: 2 * 1024 * 1024,
availableMemory: 2 * 1024 * 1024,
availableMemorySource: 'free-memory',
usedMemory: 6 * 1024 * 1024,
memoryUsagePercent: 75,
cpuCoreCount: 8,
loadAverage1m: 1.25
},
processMemoryMetric: 'rss',
totalCpu: 3.75,
totalMemory: 2 * 1024 * 1024,
collectedAt: 1000
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['diagnostics', 'memory'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('diagnostics.memory')
const output = logSpy.mock.calls.flat().join('\n')
expect(output).toContain('totalMemory: 2.0 MB')
expect(output).toContain('processMemoryMetric: summed RSS; shared or aliased pages may repeat')
expect(output).toContain('hostAvailable: 2.0 MB (free-memory)')
expect(output).toContain('app: 1.0 MB')
expect(output).toContain('- feature 1.0 MB 2.5% 1 session')
})
})
+248
View File
@@ -0,0 +1,248 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { okFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('formats group orchestration sends in text mode', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
callMock.mockResolvedValueOnce({
id: 'req_send',
ok: true,
result: {
messages: [{ id: 'msg_1' }, { id: 'msg_2' }],
recipients: 2
},
_meta: {
runtimeId: 'runtime-1'
}
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['orchestration', 'send', '--to', '@all', '--subject', 'hello'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.send', {
from: 'term_sender',
to: '@all',
subject: 'hello',
body: undefined,
type: undefined,
priority: undefined,
threadId: undefined,
payload: undefined,
devMode: false
})
expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients')
})
it('rejects no-flag orchestration reset before calling the runtime', async () => {
await main(['orchestration', 'reset'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
})
it.each([
{
args: ['orchestration', 'reset', '--all'],
params: { all: true, tasks: undefined, messages: undefined },
reset: 'all'
},
{
args: ['orchestration', 'reset', '--tasks'],
params: { all: undefined, tasks: true, messages: undefined },
reset: 'tasks'
},
{
args: ['orchestration', 'reset', '--messages'],
params: { all: undefined, tasks: undefined, messages: true },
reset: 'messages'
}
])('passes explicit reset flags through for $args', async ({ args, params, reset }) => {
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(args, '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.reset', params)
})
it.each([
['orchestration', 'reset', '--tasks', '--messages'],
['orchestration', 'reset', '--all', '--tasks']
])('rejects conflicting reset scopes for $args', async (...args) => {
await main(args, '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
})
it('rejects unknown task-update status with an enum-aware error', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['orchestration', 'task-update', '--id', 'task_x', '--status', 'complete'],
'/tmp/repo'
)
const output = [...errSpy.mock.calls, ...logSpy.mock.calls]
.flat()
.map((v) => (typeof v === 'string' ? v : JSON.stringify(v)))
.join('\n')
expect(output).toContain("invalid status 'complete'")
expect(output).toContain('pending, ready, dispatched, completed, failed, blocked')
expect(callMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
// Reset exitCode so subsequent tests don't inherit the failure.
process.exitCode = priorExitCode
errSpy.mockRestore()
})
it('passes the caller terminal handle through orchestration task-create', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
callMock.mockResolvedValueOnce({
id: 'req_task_create',
ok: true,
result: {
task: { id: 'task_1', status: 'ready' }
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'orchestration',
'task-create',
'--spec',
'spawn child workspace',
'--task-title',
'Child workspace',
'--display-name',
'Spawn child workspace'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('orchestration.taskCreate', {
spec: 'spawn child workspace',
taskTitle: 'Child workspace',
displayName: 'Spawn child workspace',
deps: undefined,
parent: undefined,
callerTerminalHandle: 'term_creator'
})
})
it('passes dev mode to injected orchestration dispatches', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev'
callMock.mockResolvedValueOnce({
id: 'req_dispatch',
ok: true,
result: {
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' }
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_worker', '--inject'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('orchestration.dispatch', {
task: 'task_1',
to: 'term_worker',
from: 'term_sender',
inject: true,
devMode: true
})
})
it('passes dev mode from an explicit dev CLI marker with a custom profile path', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
process.env.ORCA_USER_DATA_PATH = '/tmp/federation-acceptance-profile'
process.env.ORCA_DEV_CLI_INVOCATION = '1'
callMock.mockResolvedValueOnce({
id: 'req_dispatch',
ok: true,
result: {
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' }
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_worker', '--inject'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'orchestration.dispatch',
expect.objectContaining({ devMode: true })
)
})
})
+480
View File
@@ -0,0 +1,480 @@
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { okFixture, queueFixtures } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('resolves repo.add paths against the invoking cli cwd', async () => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: path.resolve('/tmp/repo/apps/web'),
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['repo', 'add', '--path', './apps/web', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: path.resolve('/tmp/repo/apps/web')
})
})
it('lists projects through the project-first runtime API', async () => {
queueFixtures(
callMock,
okFixture('req_project_list', {
projects: [
{
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#7c3aed',
providerIdentity: {
provider: 'github',
owner: 'stablyai',
repo: 'orca'
},
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1
}
]
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['project', 'list', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('project.list')
})
it('filters project host setups locally after fetching setup compatibility state', async () => {
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-local',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-local',
path: '/tmp/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-remote',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-remote',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['project', 'setups', '--project', 'github:stablyai/orca', '--host', 'runtime:gpu'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('projectHostSetup.list')
expect(logSpy.mock.calls[0]?.[0]).toContain('setup-remote')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-local')
})
it('sets up an existing project folder with a path resolved against the local cli cwd', async () => {
queueFixtures(
callMock,
okFixture('req_project_setup', {
result: {
project: {
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#7c3aed',
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-local',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-1',
path: path.resolve('/tmp/orca'),
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
},
repo: {
id: 'repo-1',
path: path.resolve('/tmp/orca'),
displayName: 'Orca',
badgeColor: '#7c3aed',
addedAt: 1
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'project',
'setup-existing-folder',
'--project',
'github:stablyai/orca',
'--host',
'local',
'--path',
'..',
'--kind',
'git',
'--display-name',
'Orca',
'--json'
],
'/tmp/orca/worktrees/feature'
)
expect(callMock).toHaveBeenCalledWith('projectHostSetup.setupExistingFolder', {
projectId: 'github:stablyai/orca',
hostId: 'local',
path: path.resolve('/tmp/orca/worktrees'),
kind: 'git',
displayName: 'Orca'
})
})
it('rejects remote project setup relative paths instead of resolving against client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'project',
'setup-existing-folder',
'--project',
'github:stablyai/orca',
'--host',
'runtime:gpu',
'--path',
'./orca',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote project setup requires --path to be an absolute path on the remote server.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['repo', 'add', '--path', './apps/web', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote repo add requires --path to be an absolute path on the remote server.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('sends remote repo.add absolute paths unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: '/srv/orca/web',
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['repo', 'add', '--path', '/srv/orca/web', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: '/srv/orca/web'
})
})
it.each(['C:\\repo', 'C:/repo', '\\\\server\\share\\repo', '//server/share/repo'])(
'sends remote repo.add server absolute path %s unchanged',
async (serverPath) => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: serverPath,
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['repo', 'add', '--path', serverPath, '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: serverPath
})
}
)
it('updates project host setup metadata through the project-first runtime API', async () => {
queueFixtures(
callMock,
okFixture('req_project_setup_update', {
result: {
project: {
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#7c3aed',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: '',
path: '/srv/orca',
displayName: 'GPU VM',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 2
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'project',
'setup-update',
'--setup',
'setup-gpu',
'--display-name',
'GPU VM',
'--path',
'/srv/orca',
'--worktree-base-path',
'../worktrees',
'--state',
'ready',
'--method',
'imported-existing-folder',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('projectHostSetup.update', {
setupId: 'setup-gpu',
updates: {
displayName: 'GPU VM',
path: path.resolve('/tmp/repo', '/srv/orca'),
worktreeBasePath: '../worktrees',
gitUsername: undefined,
kind: undefined,
setupState: 'ready',
setupMethod: 'imported-existing-folder'
}
})
})
it('creates independent project host setup metadata through the project-first runtime API', async () => {
queueFixtures(
callMock,
okFixture('req_project_setup_create', {
result: {
project: {
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#7c3aed',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: '',
path: '',
displayName: 'GPU VM',
setupState: 'setting-up',
setupMethod: 'provisioned',
createdAt: 1,
updatedAt: 2
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'project',
'setup-create',
'--project',
'github:stablyai/orca',
'--host',
'runtime:gpu',
'--setup-id',
'setup-gpu',
'--display-name',
'GPU VM',
'--state',
'setting-up',
'--method',
'provisioned',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('projectHostSetup.create', {
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
setupId: 'setup-gpu',
path: undefined,
kind: undefined,
displayName: 'GPU VM',
worktreeBasePath: undefined,
gitUsername: undefined,
setupState: 'setting-up',
setupMethod: 'provisioned'
})
})
it('deletes project host setup metadata through the project-first runtime API', async () => {
queueFixtures(
callMock,
okFixture('req_project_setup_delete', {
result: {
project: {
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#7c3aed',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: '',
path: '/srv/orca',
displayName: 'GPU VM',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 2
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', {
setupId: 'setup-gpu'
})
})
})
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('starts a foreground headless server through `serve`', async () => {
serveOrcaAppMock.mockResolvedValue(0)
process.env.ORCA_ENVIRONMENT = 'stale-env'
await main(
['serve', '--json', '--port', '6768', '--pairing-address', '100.64.1.20', '--no-pairing'],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: true,
port: '6768',
pairingAddress: '100.64.1.20',
noPairing: true,
mobilePairing: false,
recipeJson: false,
projectRoot: null
})
})
it('starts a foreground headless server with mobile pairing enabled', async () => {
serveOrcaAppMock.mockResolvedValue(0)
await main(
['serve', '--pairing-address', '100.64.1.20', '--mobile-pairing', '--json'],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: true,
port: null,
pairingAddress: '100.64.1.20',
noPairing: false,
mobilePairing: true,
recipeJson: false,
projectRoot: null
})
})
it('starts a recipe JSON headless server for VM recipes', async () => {
serveOrcaAppMock.mockResolvedValue(0)
await main(
[
'serve',
'--pairing-address',
'wss://sandbox.example.com',
'--project-root',
'/workspace/repo',
'--recipe-json'
],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: false,
port: null,
pairingAddress: 'wss://sandbox.example.com',
noPairing: false,
mobilePairing: false,
recipeJson: true,
projectRoot: '/workspace/repo'
})
})
it('rejects recipe JSON output without a project root', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--recipe-json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Recipe JSON output requires --project-root.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects recipe JSON output with mobile pairing', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['serve', '--recipe-json', '--project-root', '/workspace/repo', '--mobile-pairing'],
'/tmp/repo'
)
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Recipe JSON output requires runtime pairing; remove --mobile-pairing.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects contradictory serve pairing flags', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--mobile-pairing', '--no-pairing', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --mobile-pairing or --no-pairing, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects invalid serve ports before launching the app', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--port', 'not-a-port', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Invalid --port value: not-a-port'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects value-less serve ports before launching the app', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--port', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing value for --port.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
+663
View File
@@ -0,0 +1,663 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes explicit focus through terminal.create', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'RUNNER'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'RUNNER',
'--focus',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: undefined,
title: 'RUNNER',
focus: true,
presentation: 'focused'
})
})
it('prints terminal.read fallback screen lines in json mode', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_read', {
terminal: {
handle: 'term_worker',
status: 'running',
tail: ['Claude Code', 'Checking files', 'Waiting for input'],
truncated: false,
limited: true,
oldestCursor: '0',
nextCursor: '3000',
latestCursor: '3000',
returnedLineCount: 3
}
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['terminal', 'read', '--terminal', 'term_worker', '--limit', '120', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.read', {
terminal: 'term_worker',
limit: 120
})
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
expect(printed.result.terminal.tail).toEqual([
'Claude Code',
'Checking files',
'Waiting for input'
])
})
it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex',
'--command',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex',
'--command',
'codex',
'--focus',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: true,
presentation: 'focused',
rendererBacked: true,
activate: true
})
})
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex exec'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex exec',
'--command',
'codex exec summarize',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
title: 'Codex exec',
focus: false
})
})
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex exec'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex exec',
'--command',
'codex -m gpt-5 --sandbox workspace-write exec summarize',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
title: 'Codex exec',
focus: false
})
})
it('does not force the visible terminal path for Codex review commands after long options', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex review'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex review',
'--command',
'codex --model=gpt-5 --sandbox=workspace-write review',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
title: 'Codex review',
focus: false
})
})
it('does not force the visible terminal path for Codex help commands', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex help'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex help',
'--command',
'codex --help',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
title: 'Codex help',
focus: false
})
})
it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex prompt'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex prompt',
'--command',
'codex -m gpt-5 "fix the flaky test"',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Claude'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Claude',
'--command',
'claude',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude',
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps Claude print commands on the background terminal path', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Claude print'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Claude print',
'--command',
'claude -p "summarize"',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
title: 'Claude print',
focus: false
})
})
it('uses the resolved enclosing worktree for terminal consumers', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
okFixture('req_term', { terminals: [], totalCount: 0, truncated: false })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', {
worktree: 'id:repo::/tmp/repo/feature',
limit: undefined,
includeVisualLayouts: false
})
})
it('requests visual layouts only for the human-readable terminal list', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
okFixture('req_term', { terminals: [], totalCount: 0, truncated: false })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['terminal', 'list', '--worktree', 'active'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(
2,
'terminal.list',
expect.objectContaining({ includeVisualLayouts: true })
)
})
it('allows agent JSON clients to request visual layouts explicitly', async () => {
const visualLayouts = [
{
worktreeId: 'repo::/tmp/repo/feature',
worktreePath: '/tmp/repo/feature',
root: { type: 'group', groupId: null, activeTabId: null, tabs: [] }
}
]
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
okFixture('req_term', {
terminals: [],
visualLayouts,
totalCount: 0,
truncated: false
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['terminal', 'list', '--worktree', 'active', '--include-visual-layouts', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(
2,
'terminal.list',
expect.objectContaining({ includeVisualLayouts: true })
)
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
result: { visualLayouts }
})
})
it('rejects implicit remote terminal create instead of resolving from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['terminal', 'create', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/client/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote terminal create requires --worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/srv/orca/feature',
title: 'Server terminal'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'id:repo-1::/srv/orca/feature',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
title: undefined,
focus: false
})
})
it('exits nonzero when terminal wait returns an unsatisfied blocked result', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
callMock.mockResolvedValueOnce({
id: 'req_terminal_wait',
ok: true,
result: {
wait: {
handle: 'term_worker',
condition: 'tui-idle',
satisfied: false,
status: 'running',
exitCode: null,
blockedReason: 'codex-cwd-prompt'
}
},
_meta: {
runtimeId: 'runtime-1'
}
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['terminal', 'wait', '--terminal', 'term_worker', '--for', 'tui-idle'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'terminal.wait',
{
terminal: 'term_worker',
for: 'tui-idle',
timeoutMs: undefined
},
{
timeoutMs: 300000
}
)
expect(logSpy.mock.calls.flat().join('\n')).toContain('blockedReason: codex-cwd-prompt')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('does not force remote Codex terminal creates through a local renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/srv/orca/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'id:repo-1::/srv/orca/feature',
'--command',
'codex',
'--title',
'Codex',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
title: 'Codex',
focus: false
})
})
})
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, vi, type Mock } from 'vitest'
export type RuntimeClientModuleMocks = {
callMock: Mock
runtimeClientConstructorMock: Mock
serveOrcaAppMock: Mock
getDefaultUserDataPathMock: Mock
}
export type WorktreeAwarenessMocks = {
callMock: Mock
serveOrcaAppMock: Mock
getDefaultUserDataPathMock: Mock
addEnvironmentFromPairingCodeMock: Mock
listEnvironmentsMock: Mock
spawnMock: Mock
}
export async function createRuntimeClientModuleMock(mocks: RuntimeClientModuleMocks) {
// Why: re-export the REAL error classes rather than redefining them. format.ts
// narrows with `instanceof` against ./runtime/types, so a look-alike class
// here would make every CLI error fall through to the generic `runtime_error`
// shape — mirroring the barrel keeps the mock faithful to production.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js')
class RuntimeClient {
readonly isRemote: boolean
call = mocks.callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
mocks.runtimeClientConstructorMock(remotePairingCode, environmentSelector)
const effectivePairingCode =
remotePairingCode === undefined
? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
: remotePairingCode
const effectiveEnvironment =
environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector
if (effectivePairingCode && effectiveEnvironment) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --pairing-code or --environment, not both.'
)
}
this.isRemote = Boolean(effectivePairingCode || effectiveEnvironment)
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError,
serveOrcaApp: mocks.serveOrcaAppMock,
getDefaultUserDataPath: mocks.getDefaultUserDataPathMock
}
}
export async function createChildProcessModuleMock(spawnMock: Mock) {
const { EventEmitter } = await import('node:events')
return {
spawn: spawnMock.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
stdin: {
write: vi.fn(),
end: vi.fn()
},
kill: vi.fn()
})
process.nextTick(() => {
child.emit('exit', 0, null)
child.emit('close', 0, null)
})
return child
})
}
}
/** Installs the env-var save/restore + mock-reset hooks shared by the CLI worktree-awareness suites. */
export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): void {
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
const originalDevCliInvocation = process.env.ORCA_DEV_CLI_INVOCATION
const originalPairingCode = process.env.ORCA_PAIRING_CODE
const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING
const originalEnvironment = process.env.ORCA_ENVIRONMENT
const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID
const originalWorktreeId = process.env.ORCA_WORKTREE_ID
beforeEach(() => {
mocks.callMock.mockReset()
delete process.env.ORCA_TERMINAL_HANDLE
delete process.env.ORCA_USER_DATA_PATH
delete process.env.ORCA_DEV_CLI_INVOCATION
delete process.env.ORCA_WORKSPACE_ID
delete process.env.ORCA_WORKTREE_ID
// Isolate the pane key so claude-teams tests that set it don't leak a
// senderPaneKey into later orchestration.send assertions.
delete process.env.ORCA_PANE_KEY
mocks.serveOrcaAppMock.mockReset()
mocks.getDefaultUserDataPathMock.mockClear()
mocks.addEnvironmentFromPairingCodeMock.mockReset()
mocks.listEnvironmentsMock.mockReset()
mocks.spawnMock.mockClear()
mocks.addEnvironmentFromPairingCodeMock.mockReturnValue({
id: 'env-1',
name: 'desk',
createdAt: 100,
updatedAt: 100,
lastUsedAt: null,
runtimeId: null,
endpoints: [
{
id: 'ws-env-1',
kind: 'websocket',
label: 'WebSocket',
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: 'pk'
}
],
preferredEndpointId: 'ws-env-1'
})
mocks.listEnvironmentsMock.mockReturnValue([])
})
afterEach(() => {
vi.restoreAllMocks()
if (originalTerminalHandle === undefined) {
delete process.env.ORCA_TERMINAL_HANDLE
} else {
process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle
}
if (originalUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = originalUserDataPath
}
if (originalDevCliInvocation === undefined) {
delete process.env.ORCA_DEV_CLI_INVOCATION
} else {
process.env.ORCA_DEV_CLI_INVOCATION = originalDevCliInvocation
}
if (originalPairingCode === undefined) {
delete process.env.ORCA_PAIRING_CODE
} else {
process.env.ORCA_PAIRING_CODE = originalPairingCode
}
if (originalRemotePairing === undefined) {
delete process.env.ORCA_REMOTE_PAIRING
} else {
process.env.ORCA_REMOTE_PAIRING = originalRemotePairing
}
if (originalEnvironment === undefined) {
delete process.env.ORCA_ENVIRONMENT
} else {
process.env.ORCA_ENVIRONMENT = originalEnvironment
}
if (originalWorkspaceId === undefined) {
delete process.env.ORCA_WORKSPACE_ID
} else {
process.env.ORCA_WORKSPACE_ID = originalWorkspaceId
}
if (originalWorktreeId === undefined) {
delete process.env.ORCA_WORKTREE_ID
} else {
process.env.ORCA_WORKTREE_ID = originalWorktreeId
}
})
}
+340
View File
@@ -0,0 +1,340 @@
import path from 'node:path'
import { chmodSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('runs vm recipe doctor locally without contacting the app runtime', async () => {
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-'))
try {
mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true })
const startScript = path.join(repoPath, 'scripts', 'orca-vm', 'start.sh')
const cleanupScript = path.join(repoPath, 'scripts', 'orca-vm', 'cleanup.sh')
writeFileSync(startScript, '#!/bin/sh\n')
writeFileSync(cleanupScript, '#!/bin/sh\n')
chmodSync(startScript, 0o755)
chmodSync(cleanupScript, 0o755)
writeFileSync(
path.join(repoPath, 'orca.yaml'),
[
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
' create: ./scripts/orca-vm/start.sh',
' destroy: ./scripts/orca-vm/cleanup.sh'
].join('\n')
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['vm', 'recipe', 'doctor', 'cloud-sandbox', '--repo-path', repoPath, '--json'])
const output = JSON.parse(String(logSpy.mock.calls[0][0])) as {
ok: boolean
checks: { id: string; status: string }[]
}
if (!output.ok) {
throw new Error(JSON.stringify(output))
}
expect(output.ok).toBe(true)
expect(output.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'orca_yaml.parse', status: 'pass' }),
expect.objectContaining({ id: 'recipe.exists', status: 'pass' }),
expect.objectContaining({ id: 'recipe.create', status: 'pass' }),
expect.objectContaining({ id: 'recipe.destroy', status: 'pass' })
])
)
expect(callMock).not.toHaveBeenCalled()
} finally {
rmSync(repoPath, { recursive: true, force: true })
}
})
it('warns when vm recipe doctor finds no cleanup hook', async () => {
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-'))
try {
mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true })
writeFileSync(path.join(repoPath, 'scripts', 'orca-vm', 'start.sh'), '#!/bin/sh\n')
writeFileSync(
path.join(repoPath, 'orca.yaml'),
[
'environmentRecipes:',
' - id: manual-sandbox',
' name: Manual Sandbox',
' create: ./scripts/orca-vm/start.sh'
].join('\n')
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['vm', 'recipe', 'doctor', 'manual-sandbox', '--repo-path', repoPath, '--json'])
const output = JSON.parse(String(logSpy.mock.calls[0][0])) as {
ok: boolean
checks: { id: string; status: string; remediation?: string }[]
}
if (!output.ok) {
throw new Error(JSON.stringify(output))
}
expect(output.ok).toBe(true)
expect(output.checks).toContainEqual(
expect.objectContaining({
id: 'recipe.destroy',
status: 'warn',
remediation: 'Add destroy or explicitly set destroy: none.'
})
)
} finally {
rmSync(repoPath, { recursive: true, force: true })
}
})
it('runs vm recipe doctor provision mode and invokes cleanup', async () => {
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-provision-'))
const pairingCode = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: 'ws://sandbox.example.com:6767',
deviceToken: 'token',
publicKeyB64: 'public-key'
})
try {
mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true })
writeFileSync(
path.join(repoPath, 'scripts', 'orca-vm', 'start.js'),
[
'console.log(JSON.stringify({',
' schemaVersion: 1,',
` pairingCode: ${JSON.stringify(pairingCode)},`,
" projectRoot: '/workspace/repo'",
'}))'
].join('\n')
)
writeFileSync(
path.join(repoPath, 'scripts', 'orca-vm', 'cleanup.js'),
[
"const fs = require('fs')",
"const input = fs.readFileSync(0, 'utf8')",
'const payload = JSON.parse(input)',
"fs.writeFileSync('cleanup-ran.json', JSON.stringify(payload))"
].join('\n')
)
writeFileSync(
path.join(repoPath, 'orca.yaml'),
[
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
` create: ${JSON.stringify(`${process.execPath} ./scripts/orca-vm/start.js`)}`,
` destroy: ${JSON.stringify(`${process.execPath} ./scripts/orca-vm/cleanup.js`)}`
].join('\n')
)
const { EventEmitter } = await import('node:events')
const startChild = Object.assign(new EventEmitter(), {
stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
stdin: { write: vi.fn(), end: vi.fn() },
kill: vi.fn()
})
const cleanupChild = Object.assign(new EventEmitter(), {
stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
stdin: { write: vi.fn(), end: vi.fn() },
kill: vi.fn()
})
spawnMock
.mockImplementationOnce(() => {
process.nextTick(() => {
startChild.stdout.emit(
'data',
JSON.stringify({
schemaVersion: 1,
pairingCode,
projectRoot: '/workspace/repo'
})
)
startChild.emit('exit', 0, null)
startChild.emit('close', 0, null)
})
return startChild
})
.mockImplementationOnce(() => {
process.nextTick(() => {
cleanupChild.emit('exit', 0, null)
cleanupChild.emit('close', 0, null)
})
return cleanupChild
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main([
'vm',
'recipe',
'doctor',
'cloud-sandbox',
'--repo-path',
repoPath,
'--provision',
'--json'
])
const output = JSON.parse(String(logSpy.mock.calls[0][0])) as {
ok: boolean
checks: { id: string; status: string }[]
provisionTranscript?: {
provision: { exitCode: number | null; stdout: string; stderr: string }
destroy?: { exitCode: number | null; stdout: string; stderr: string }
}
}
if (!output.ok) {
throw new Error(JSON.stringify(output))
}
expect(output.ok).toBe(true)
expect(output.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'recipe.provision', status: 'pass' }),
expect.objectContaining({ id: 'recipe.result.endpoint.public_ws', status: 'warn' }),
expect.objectContaining({ id: 'recipe.result.project_root', status: 'pass' }),
expect.objectContaining({ id: 'recipe.destroy.run', status: 'pass' })
])
)
// The transcript carries both stages so the agent can self-diagnose.
expect(output.provisionTranscript?.provision.exitCode).toBe(0)
expect(output.provisionTranscript?.destroy?.exitCode).toBe(0)
const cleanupPayload = JSON.parse(
String(vi.mocked(cleanupChild.stdin.end).mock.calls[0]?.[0])
) as { recipeId: string; recipeResult: { projectRoot: string } }
expect(cleanupPayload).toMatchObject({
recipeId: 'cloud-sandbox',
recipeResult: { projectRoot: '/workspace/repo' }
})
} finally {
rmSync(repoPath, { recursive: true, force: true })
}
})
it('returns the full create transcript when provision fails so the agent can self-diagnose', async () => {
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-provision-fail-'))
try {
mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true })
writeFileSync(path.join(repoPath, 'scripts', 'orca-vm', 'start.js'), 'process.exit(0)')
writeFileSync(
path.join(repoPath, 'orca.yaml'),
[
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
` create: ${JSON.stringify(`${process.execPath} ./scripts/orca-vm/start.js`)}`,
' destroy: none'
].join('\n')
)
const { EventEmitter } = await import('node:events')
const startChild = Object.assign(new EventEmitter(), {
stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }),
stdin: { write: vi.fn(), end: vi.fn() },
kill: vi.fn()
})
// create emits a non-JSON line to stdout + a real diagnostic to stderr, then exits 0
spawnMock.mockImplementationOnce(() => {
process.nextTick(() => {
startChild.stdout.emit('data', 'Provisioning sandbox...\n')
startChild.stderr.emit('data', 'vercel: error: missing scope\n')
startChild.emit('exit', 0, null)
startChild.emit('close', 0, null)
})
return startChild
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main([
'vm',
'recipe',
'doctor',
'cloud-sandbox',
'--repo-path',
repoPath,
'--provision',
'--json'
])
const output = JSON.parse(String(logSpy.mock.calls[0][0])) as {
ok: boolean
checks: { id: string; status: string }[]
provisionTranscript?: {
provision: {
exitCode: number | null
stdout: string
stderr: string
parseError?: string
}
}
}
expect(output.ok).toBe(false)
expect(output.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'recipe.provision', status: 'fail' })
])
)
// The agent gets the full create output, not a 500-char tail.
expect(output.provisionTranscript?.provision.stdout).toContain('Provisioning sandbox...')
expect(output.provisionTranscript?.provision.stderr).toContain('missing scope')
expect(output.provisionTranscript?.provision.parseError).toBeTruthy()
process.exitCode = priorExitCode
} finally {
rmSync(repoPath, { recursive: true, force: true })
}
})
})
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('opts into setup and activation when worktree.create runs hooks', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--run-hooks', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: true,
activate: true,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('starts an agent worktree in the background unless activation is explicit', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/agent-task', 'agent-task', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'agent-task',
'--agent',
'codex',
'--prompt',
'hi',
'--setup',
'run',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'agent-task',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
setupDecision: 'run',
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {},
startupAgent: 'codex',
startupPrompt: 'hi'
})
})
it('infers the repo and honors explicit activation on worktree.create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/agent-task', 'agent-task', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--name',
'agent-task',
'--agent',
'codex',
'--prompt',
'hi',
'--activate',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'agent-task',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: true,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {},
startupAgent: 'codex',
startupPrompt: 'hi'
})
})
it('rejects prompt without agent on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--prompt', 'hi', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--prompt requires --agent'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects unknown agents on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--agent', 'wat', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: 'Unknown TUI agent "wat"'
}
})
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects agent, prompt, and setup flags without values on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--agent'],
'/tmp/repo'
)
expect(callMock.mock.calls.some(([method]) => method === 'worktree.create')).toBe(false)
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing value for --agent'
)
callMock.mockClear()
logSpy.mockClear()
errSpy.mockClear()
process.exitCode = priorExitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--agent',
'codex',
'--prompt'
],
'/tmp/repo'
)
expect(callMock.mock.calls.some(([method]) => method === 'worktree.create')).toBe(false)
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing value for --prompt'
)
callMock.mockClear()
logSpy.mockClear()
errSpy.mockClear()
process.exitCode = priorExitCode
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--setup'],
'/tmp/repo'
)
expect(callMock.mock.calls.some(([method]) => method === 'worktree.create')).toBe(false)
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing value for --setup'
)
process.exitCode = priorExitCode
})
it('rejects contradictory setup flags on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--run-hooks',
'--setup',
'skip',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --run-hooks or --setup run'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
@@ -0,0 +1,246 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes Linear issue metadata through worktree.create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create_linear', {
worktree: {
...buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1'),
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'feature',
'--linear-issue',
'https://linear.app/stably/issue/STA-335/test-issue',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably',
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('normalizes bare Linear identifiers through worktree.create', async () => {
queueFixtures(
callMock,
okFixture('req_create_linear_id', {
worktree: {
...buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1'),
linkedLinearIssue: 'STA-335'
},
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'feature',
'--linear-issue',
'sta-335',
'--no-parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
// Why: a bare identifier carries no org, and the two scoping fields
// describe the issue being replaced. Inheriting a stale org key would
// pin the lookup to a workspace this issue does not live in.
linkedLinearIssueOrganizationUrlKey: null,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
noParent: true,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('rejects null Linear issue values on worktree.create before RPC', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'feature',
'--linear-issue',
'null',
'--no-parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Omit --linear-issue on create'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects invalid Linear issue values on worktree.create before RPC', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'feature',
'--linear-issue',
'not-a-linear-link',
'--no-parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Pass a Linear issue identifier like STA-335'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects missing Linear issue values on worktree.create before RPC', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'feature',
'--linear-issue',
'--no-parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing value for --linear-issue'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
@@ -0,0 +1,573 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { RuntimeRpcFailureError } from './runtime-client'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes an explicit parent through worktree.create without cwd inference', async () => {
queueFixtures(
callMock,
okFixture('req_create', {
worktree: {
...buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
parentWorktreeId: 'repo-1::/tmp/repo/parent',
lineage: {
worktreeId: 'repo-1::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
},
lineage: {
worktreeId: 'repo-1::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
},
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'id:repo-1::/tmp/repo/parent',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: 'id:repo-1::/tmp/repo/parent',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('routes traditional parent-worktree selectors through parentWorktree', async () => {
queueFixtures(
callMock,
okFixture('req_create', {
worktree: {
...buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
parentWorktreeId: 'repo-1::/tmp/repo/parent'
},
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'branch:feature/parent',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: 'branch:feature/parent',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('routes workspace-key parent-worktree selectors through parentWorkspace', async () => {
const cases = [
{ selector: 'folder:folder-1', parentWorkspace: 'folder:folder-1' },
{
selector: 'worktree:repo-1::/tmp/repo/parent',
parentWorkspace: 'worktree:repo-1::/tmp/repo/parent'
},
{ selector: 'id:folder:folder-1', parentWorkspace: 'folder:folder-1' },
{
selector: 'id:worktree:repo-1::/tmp/repo/parent',
parentWorkspace: 'worktree:repo-1::/tmp/repo/parent'
}
]
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
for (const testCase of cases) {
callMock.mockReset()
queueFixtures(
callMock,
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
workspaceLineage: {
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
childInstanceId: 'child-instance',
parentWorkspaceKey: testCase.parentWorkspace,
parentInstanceId: null,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
},
warnings: []
})
)
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
testCase.selector,
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
parentWorkspace: testCase.parentWorkspace,
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
}
})
it('passes folder workspace environment lineage through worktree.create', async () => {
process.env.ORCA_WORKSPACE_ID = 'folder:folder-1'
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
workspaceLineage: {
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
childInstanceId: 'child-instance',
parentWorkspaceKey: 'folder:folder-1',
parentInstanceId: null,
origin: 'cli',
capture: { source: 'env-workspace', confidence: 'inferred' },
createdAt: 1
},
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
envParentWorkspace: 'folder:folder-1',
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('resolves current for explicit parent-worktree on create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/parent', 'feature/parent', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'current',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: 'id:repo-1::/tmp/repo/parent',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('routes active/current folder workspace parent selectors through parentWorkspace on create', async () => {
const folderWorkspace = {
...buildWorktree('/tmp/folder', '', '', 'folder-workspace:group-1'),
id: 'folder:folder-1',
repoId: 'folder-workspace:group-1',
displayName: 'Folder'
}
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
for (const parentSelector of ['current', 'active']) {
callMock.mockReset()
queueFixtures(
callMock,
worktreeListFixture([folderWorkspace]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
workspaceLineage: {
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
childInstanceId: 'child-instance',
parentWorkspaceKey: 'folder:folder-1',
parentInstanceId: null,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
},
warnings: []
})
)
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
parentSelector,
'--json'
],
'/tmp/folder/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
parentWorkspace: 'folder:folder-1',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
}
})
it('rejects contradictory parent flags on worktree.create before resolving selectors', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'current',
'--no-parent',
'--json'
],
'/tmp/not-managed'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either one parent selector or --no-parent.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects removed parent-workspace on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
const outputModes = [[], ['--json']]
for (const outputArgs of outputModes) {
logSpy.mockClear()
errSpy.mockClear()
process.exitCode = priorExitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-workspace',
'folder:folder-1',
...outputArgs
],
'/tmp/repo'
)
const output = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
expect(output).toContain('Unknown flag --parent-workspace for command: worktree create')
expect(callMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
}
process.exitCode = priorExitCode
})
it('rejects bare parent-worktree on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing required --parent-worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('reports runtime parent selector failures without hidden flag guidance', async () => {
callMock.mockRejectedValueOnce(
new RuntimeRpcFailureError({
id: 'req_create',
ok: false,
error: {
code: 'LINEAGE_PARENT_NOT_FOUND',
message: 'Parent selector was not found.',
data: {
nextSteps: [
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<worktreeId>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
'Retry with --no-parent to create without lineage.'
]
}
},
_meta: { runtimeId: 'runtime-1' }
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'folder:missing',
'--json'
],
'/tmp/repo'
)
const output = String(logSpy.mock.calls[0][0])
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
parentWorkspace: 'folder:missing',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
expect(output).toContain('"ok": false')
expect(output).toContain('Parent selector was not found.')
expect(output).toContain('--parent-worktree selector')
expect(output).not.toContain('--parent-workspace')
expect(errSpy).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes no-parent through worktree.create and skips cwd inference', async () => {
queueFixtures(
callMock,
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent', '--json'],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
noParent: true,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
})
@@ -0,0 +1,297 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes explicit activation through worktree.create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--activate', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: true,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('resolves project and host flags to the matching repo for worktree.create', async () => {
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-local',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-local',
path: '/tmp/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-gpu',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
}),
okFixture('req_create', {
worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--project',
'github:stablyai/orca',
'--host',
'runtime:gpu',
'--name',
'feature',
'--no-parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-gpu',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
noParent: true,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('resolves project-host-setup directly for worktree.create', async () => {
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-gpu',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
}),
okFixture('req_create', {
worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--project-host-setup',
'setup-gpu',
'--name',
'feature',
'--no-parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(
2,
'worktree.create',
expect.objectContaining({ repo: 'id:repo-gpu' })
)
})
it('rejects mixing repo and project target flags on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-local',
'--project',
'github:stablyai/orca',
'--name',
'feature',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --repo or project target flags, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes caller terminal handle through worktree.create with cwd fallback', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_parent'
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledTimes(2)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: 'term_parent',
cliProvenanceRequest: { callerTerminalHandle: 'term_parent' }
})
})
it('marks every worktree.create as CLI-created even from an external shell', async () => {
// Why: the sidebar badge/filter must catch hand-typed creates too, so the
// provenance request is sent with no terminal handle rather than omitted.
delete process.env.ORCA_TERMINAL_HANDLE
queueFixtures(
callMock,
okFixture('req_create_external', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({ cliProvenanceRequest: {} })
)
})
})
@@ -0,0 +1,277 @@
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { buildCurrentWorktreeSelector, main, normalizeWorktreeSelector } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('builds the current worktree selector from cwd', () => {
expect(buildCurrentWorktreeSelector('/tmp/repo/feature')).toBe(
`path:${path.resolve('/tmp/repo/feature')}`
)
})
it('normalizes active/current worktree selectors to cwd', () => {
const resolved = path.resolve('/tmp/repo/feature')
expect(normalizeWorktreeSelector('active', '/tmp/repo/feature')).toBe(`path:${resolved}`)
expect(normalizeWorktreeSelector('current', '/tmp/repo/feature')).toBe(`path:${resolved}`)
expect(normalizeWorktreeSelector('branch:feature/foo', '/tmp/repo/feature')).toBe(
'branch:feature/foo'
)
})
it('shows the enclosing worktree for `worktree current`', async () => {
queueFixtures(
callMock,
worktreeListFixture([
buildWorktree('/tmp/repo', 'main'),
buildWorktree('/tmp/repo/feature', 'feature/foo'),
buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'duplicate-repo')
]),
okFixture('req_1', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'id:repo::/tmp/repo/feature'
})
expect(logSpy).toHaveBeenCalledTimes(1)
})
it('resolves the invocation cwd from ORCA_CLI_CWD when no cwd is passed', async () => {
// Why: the SSH relay bridge runs the CLI on the Orca host with the remote
// shell's cwd carried in ORCA_CLI_CWD (#7716); cwd-based selectors must
// resolve against it, not the host process cwd.
process.env.ORCA_CLI_CWD = '/tmp/repo/feature/src'
try {
queueFixtures(
callMock,
worktreeListFixture([
buildWorktree('/tmp/repo', 'main'),
buildWorktree('/tmp/repo/feature', 'feature/foo')
]),
okFixture('req_1', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'current', '--json'])
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'id:repo::/tmp/repo/feature'
})
} finally {
delete process.env.ORCA_CLI_CWD
}
})
it.skipIf(process.platform === 'win32')(
'prepares and starts Claude Agent Teams in the current Orca terminal',
async () => {
process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
queueFixtures(
callMock,
okFixture('req_agent_teams_prepare', {
launch: {
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1',
TMUX_PANE: '%1',
PATH: '/tmp/orca-shim:/usr/bin'
}
}
})
)
await main(['claude-teams'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('agentTeams.prepareLaunch', {
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
env: expect.objectContaining({
ORCA_PANE_KEY: 'tab-1:11111111-1111-4111-8111-111111111111'
})
})
expect(spawnMock).toHaveBeenCalledWith('claude', ['--teammate-mode', 'auto'], {
stdio: 'inherit',
env: expect.objectContaining({
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX_PANE: '%1'
})
})
}
)
it.skipIf(process.platform === 'win32')(
'passes Claude Agent Teams arguments through to Claude Code',
async () => {
process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
queueFixtures(
callMock,
okFixture('req_agent_teams_prepare', {
launch: {
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1',
TMUX_PANE: '%1',
PATH: '/tmp/orca-shim:/usr/bin'
}
}
})
)
await main(
['claude-teams', '--resume', 'session-1', '--model', 'sonnet', 'review this'],
'/tmp/repo'
)
expect(spawnMock).toHaveBeenCalledWith(
'claude',
['--teammate-mode', 'auto', '--resume', 'session-1', '--model', 'sonnet', 'review this'],
{
stdio: 'inherit',
env: expect.objectContaining({
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX_PANE: '%1'
})
}
)
}
)
it.skipIf(process.platform === 'win32')(
'does not duplicate an explicit Claude teammate mode',
async () => {
process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
queueFixtures(
callMock,
okFixture('req_agent_teams_prepare', {
launch: {
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1',
TMUX_PANE: '%1',
PATH: '/tmp/orca-shim:/usr/bin'
}
}
})
)
await main(['claude-teams', '--teammate-mode', 'in-process'], '/tmp/repo')
expect(spawnMock).toHaveBeenCalledWith(
'claude',
['--teammate-mode', 'in-process'],
expect.objectContaining({ stdio: 'inherit' })
)
}
)
it('rejects remote `worktree current` without listing worktrees from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'current', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'current is a local cwd shortcut and cannot be resolved against a remote runtime.'
)
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'id:<repo-id>::<path>'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
okFixture('req_show', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'id:repo::/tmp/repo/feature'
})
})
})
+394
View File
@@ -0,0 +1,394 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('uses cwd when active is passed to worktree.set', async () => {
queueFixtures(
callMock,
worktreeListFixture([
buildWorktree('/tmp/repo', 'main', 'aaa'),
buildWorktree('/tmp/repo/feature', 'feature/foo')
]),
okFixture('req_1', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature',
comment: 'hello'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'set', '--worktree', 'active', '--comment', 'hello', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', {
worktree: 'id:repo::/tmp/repo/feature',
displayName: undefined,
linkedIssue: undefined,
comment: 'hello',
parentWorktree: undefined,
noParent: false
})
})
it('passes parent lineage through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_set_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: 'repo::/tmp/repo/parent',
childWorktreeIds: [],
lineage: {
worktreeId: 'repo::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'id:repo::/tmp/repo/parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
parentWorktree: 'id:repo::/tmp/repo/parent',
noParent: false
})
})
it('resolves current for explicit parent-worktree on set', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/parent', 'feature/parent')]),
okFixture('req_set_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: 'repo::/tmp/repo/parent',
childWorktreeIds: [],
lineage: null
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'current',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
parentWorktree: 'id:repo::/tmp/repo/parent',
noParent: false
})
})
it('rejects contradictory parent flags on worktree.set before resolving selectors', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'current',
'--no-parent',
'--json'
],
'/tmp/not-managed'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --parent-worktree or --no-parent, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects bare parent-worktree on worktree.set', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--parent-worktree', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing required --parent-worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes parent removal through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_clear_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--no-parent', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
workspaceStatus: undefined,
parentWorktree: undefined,
noParent: true
})
})
it('passes Linear URL metadata through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_set_linear', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--linear-issue',
'https://linear.app/stably/issue/STA-335/test-issue',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably',
comment: undefined,
workspaceStatus: undefined,
parentWorktree: undefined,
noParent: false
})
})
it('clears all Linear metadata through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_clear_linear', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--linear-issue',
'null',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
comment: undefined,
workspaceStatus: undefined,
parentWorktree: undefined,
noParent: false
})
})
it('rejects invalid Linear issue values on worktree.set before RPC', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--linear-issue',
'not-a-linear-link',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Pass a Linear issue identifier like STA-335'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes workspace status through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_set_status', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
workspaceStatus: 'in-review'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--workspace-status',
'in-review',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
workspaceStatus: 'in-review',
parentWorktree: undefined,
noParent: false
})
})
})
+11 -4554
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import { RuntimeClientError } from './runtime-client'
export function orchestrationMutationRecoveryError(error: unknown): unknown {
if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) {
return error
}
const data = objectRecord(error.data)
const requestId = data?.orchestrationRequestId
if (typeof requestId !== 'string' || requestId.length === 0) {
return error
}
const message = [
stripUnsafeRetryAdvice(error.message, requestId),
'The orchestration mutation may already have taken effect; do not assume it failed.',
`Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`,
typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined,
Array.isArray(data?.residualResources)
? `Residual resources: ${JSON.stringify(data.residualResources)}.`
: undefined
].filter((line): line is string => line !== undefined)
return new RuntimeClientError(error.code, message.join('\n'), error.data)
}
function isUnknownMutationOutcomeCode(code: string): boolean {
return [
'runtime_unavailable',
'remote_runtime_unavailable',
'runtime_timeout',
'invalid_runtime_response'
].includes(code)
}
function objectRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object'
? (value as Record<string, unknown>)
: undefined
}
function stripUnsafeRetryAdvice(message: string, requestId: string): string {
return message
.replace(' Restart Orca and try again.', '')
.replace(' Retry the command.', '')
.replace(` Orchestration mutation request ID: ${requestId}.`, '')
}
+31 -1
View File
@@ -4,7 +4,7 @@ import { join } from 'node:path'
import { createServer, type Socket } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../shared/protocol-version'
import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client'
import { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
import { launchOrcaApp } from './runtime/launch'
vi.mock('./runtime/launch', () => ({
@@ -415,6 +415,36 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
})
})
it('preserves a dropped read-only orchestration failure exactly', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
let request: Record<string, unknown> | undefined
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
request = JSON.parse(String(data).trim()) as Record<string, unknown>
socket.end()
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 100)
const failure = await client
.call('orchestration.workerShow', { dispatch: 'ctx_1' })
.catch((error: unknown) => error)
expect(failure).toBeInstanceOf(RuntimeClientError)
expect((failure as RuntimeClientError).message).toBe(
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
)
expect((failure as RuntimeClientError).data).toBeUndefined()
expect(request).toMatchObject({ method: 'orchestration.workerShow' })
expect(request).not.toHaveProperty('orchestrationRequestId')
})
it('allows a per-call timeout override for long runtime requests', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
+3 -1
View File
@@ -132,7 +132,9 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => {
// pre-fix behavior would hang the full duration and trip vitest's own limit.
const start = Date.now()
await expect(sendRequest(metadata, 'status.get', undefined, 60000)).rejects.toMatchObject({
code: 'runtime_unavailable'
code: 'runtime_unavailable',
message:
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
})
expect(Date.now() - start).toBeLessThan(5000)
})
+6 -2
View File
@@ -20,7 +20,9 @@ import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
vi.mock('./launch', () => ({
@@ -64,7 +66,9 @@ describe('CLI remote WebSocket transport', () => {
expect.objectContaining({
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
)
+14 -12
View File
@@ -6,7 +6,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'run-create'],
summary: 'Create and bind a lightweight orchestration Run',
usage: 'orca orchestration run-create --objective <text> [--from <handle>] [--json]',
usage:
'orca orchestration run-create --objective <text> [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'objective', 'from', 'retry-request'],
notes: [
'A Run is a namespace and home inbox. It never schedules or places workers.',
@@ -17,7 +18,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'run-use'],
summary: 'Bind this coordinator terminal to an existing Run',
usage:
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--json]',
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'],
notes: [
'--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.'
@@ -45,7 +46,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'send'],
summary: 'Send an inter-agent message',
usage:
'orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--run <run_id>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--task-id <id>] [--dispatch-id <id>] [--outcome <succeeded|failed>] [--files-modified <csv>] [--report-path <path>] [--phase <text>] [--json]',
'orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--run <run_id>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--task-id <id>] [--dispatch-id <id>] [--outcome <succeeded|failed>] [--files-modified <csv>] [--report-path <path>] [--phase <text>] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'to',
@@ -80,7 +81,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'check'],
summary: 'Check messages for a terminal',
usage:
'orca orchestration check [--terminal <handle>] [--run <run_id>] [--ack <delivery_id>] [--unread | --peek | --all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--json]\n' +
'orca orchestration check [--terminal <handle>] [--run <run_id>] [--ack <delivery_id>] [--unread | --peek | --all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--retry-request <id>] [--json]\n' +
" default: return the bound Run's oldest unacknowledged FIFO batch.\n" +
' --ack: acknowledge the prior whole batch before checking/waiting.\n' +
' --peek: return only unread messages without marking them read.\n' +
@@ -114,7 +115,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'reply'],
summary: 'Reply to a message',
usage:
'orca orchestration reply --id <msg_id> --body <text> [--run <run_id>] [--from <handle>] [--json]',
'orca orchestration reply --id <msg_id> --body <text> [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request']
},
{
@@ -127,7 +128,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'task-create'],
summary: 'Create an orchestration task',
usage:
'orca orchestration task-create --spec <text> [--task-title <text>] [--display-name <text>] [--deps <json_array>] [--parent <task_id>] [--run <run_id>] [--from <handle>] [--json]',
'orca orchestration task-create --spec <text> [--task-title <text>] [--display-name <text>] [--deps <json_array>] [--parent <task_id>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'spec',
@@ -152,7 +153,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'task-update'],
summary: 'Update a task status',
usage:
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--run <run_id>] [--from <handle>] [--json]',
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'],
notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.']
},
@@ -161,7 +162,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'dispatch'],
summary: 'Dispatch a task to a terminal',
usage:
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--run <run_id>] [--inject] [--dry-run] [--return-preamble] [--json]',
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--run <run_id>] [--inject] [--dry-run] [--return-preamble] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'task',
@@ -185,7 +186,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'ask'],
summary: 'Ask the coordinator a question and block until answered',
usage:
'orca orchestration ask (--question <text> | --resume <message_id>) [--to <run:id>] [--run <run_id>] [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]',
'orca orchestration ask (--question <text> | --resume <message_id>) [--to <run:id>] [--run <run_id>] [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'to',
@@ -236,14 +237,14 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'gate-create'],
summary: 'Create a decision gate blocking a task',
usage:
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--from <handle>] [--json]',
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request']
},
{
path: ['orchestration', 'gate-resolve'],
summary: 'Resolve a pending decision gate',
usage:
'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--from <handle>] [--json]',
'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request']
},
{
@@ -257,7 +258,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'reset'],
summary: 'Reset one explicit orchestration state scope',
usage: 'orca orchestration reset (--all | --tasks | --messages) [--json]',
usage:
'orca orchestration reset (--all | --tasks | --messages) [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages', 'retry-request']
}
]
@@ -48,11 +48,13 @@ export async function readHooksJsonRemote(
export async function writeHooksJsonRemote(
sftp: SFTPWrapper,
remotePath: string,
config: HooksConfig
config: HooksConfig,
// Why: mirrors the local writer — a JSONC config supplies text edited in place.
options?: { serialized?: string }
): Promise<void> {
const dir = dirnamePosix(remotePath)
await mkdirpRemote(sftp, dir)
const serialized = `${JSON.stringify(config, null, 2)}\n`
const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n`
// Why: skip the write when on-disk content is identical so repeated
// install() calls do not bump the file's mtime / inode unnecessarily.
try {
+4 -2
View File
@@ -316,7 +316,9 @@ function writeScriptWithAclRetry(scriptPath: string, content: string): void {
export function writeHooksJson(
configPath: string,
config: HooksConfig,
options?: { preserveMode?: boolean }
// Why: `serialized` lets a JSONC config (Devin) supply text edited in place, so the
// atomic write + rolling backup below stay shared instead of being reimplemented.
options?: { preserveMode?: boolean; serialized?: string }
): void {
const writePath = resolveHooksJsonWritePath(configPath)
const dir = dirname(writePath)
@@ -325,7 +327,7 @@ export function writeHooksJson(
// Why: temp+rename leaves the original untouched on a crash/disk-full mid-write.
// Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond.
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
const serialized = `${JSON.stringify(config, null, 2)}\n`
const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n`
const existingMode =
options?.preserveMode === true && existsSync(writePath) ? statSync(writePath).mode : undefined
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { parse as parseJsonc } from 'jsonc-parser'
import type { SFTPWrapper } from 'ssh2'
vi.mock('electron', () => ({
@@ -415,7 +416,11 @@ describe('remote hook service installers', () => {
expect(grokConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*')
expect(grokConfig.hooks.StopFailure?.[0]?.matcher).toBeUndefined()
const devinConfig = JSON.parse(devin.fs.files.get('/home/dev/.config/devin/config.json')!) as {
const devinText = devin.fs.files.get('/home/dev/.config/devin/config.json')!
// Why: Devin config.json is JSONC — parse it as such, and assert the user's comment
// survived. Asserting with JSON.parse would only pass if the install had stripped it.
expect(devinText).toContain('// Existing Devin config comment')
const devinConfig = parseJsonc(devinText) as {
permissions: { mode: string }
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
}
@@ -0,0 +1,344 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { _internals } from './server'
import { buildBody } from './server.test-fixtures'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
beforeEach(() => {
_internals.resetCachesForTests()
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Amp hook normalization', () => {
it('maps agent lifecycle events to working and done states', () => {
const start = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
message: 'wire Amp hooks'
}),
'production'
)
expect(start?.payload).toMatchObject({
state: 'working',
prompt: 'wire Amp hooks',
agentType: 'amp'
})
const done = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
message: 'wire Amp hooks',
status: 'done'
}),
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'wire Amp hooks',
agentType: 'amp'
})
})
it('surfaces Amp tool call and result context while preserving the prompt', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
message: 'run tests'
}),
'production'
)
const toolCall = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.call',
tool: 'shell_command',
input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' }
}),
'production'
)
expect(toolCall?.payload).toMatchObject({
state: 'working',
prompt: 'run tests',
agentType: 'amp',
toolName: 'shell_command',
toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts'
})
const result = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
tool: 'shell_command',
input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' },
status: 'done',
output: 'tests passed'
}),
'production'
)
expect(result?.payload).toMatchObject({
state: 'working',
prompt: 'run tests',
agentType: 'amp',
toolName: 'shell_command',
toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts',
lastAssistantMessage: 'tests passed'
})
})
it('does not let Amp tool result messages overwrite the cached prompt', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
message: 'run tests'
}),
'production'
)
const result = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
tool: 'shell_command',
input: { command: 'pnpm test' },
message: 'tests passed'
}),
'production'
)
expect(result?.payload).toMatchObject({
state: 'working',
prompt: 'run tests',
agentType: 'amp',
lastAssistantMessage: 'tests passed'
})
const done = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
status: 'done'
}),
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'run tests',
agentType: 'amp'
})
})
it('keeps Amp prompt and tool caches isolated by thread id within one pane', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
threadId: 'thread-a',
message: 'first task'
}),
'production'
)
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
threadId: 'thread-b',
message: 'second task'
}),
'production'
)
const threadAResult = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
threadId: 'thread-a',
tool: 'shell_command',
input: { command: 'pnpm test:a' },
output: 'first done'
}),
'production'
)
expect(threadAResult?.payload).toMatchObject({
state: 'working',
prompt: 'first task',
agentType: 'amp',
toolName: 'shell_command',
toolInput: 'pnpm test:a',
lastAssistantMessage: 'first done'
})
const threadBDone = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
threadId: 'thread-b',
status: 'done'
}),
'production'
)
expect(threadBDone?.payload).toMatchObject({
state: 'done',
prompt: 'second task',
agentType: 'amp'
})
})
it('drops stale Amp tool events that arrive after the thread ended', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
threadId: 'thread-a',
message: 'run tests'
}),
'production'
)
const done = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
threadId: 'thread-a',
status: 'done'
}),
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'run tests',
agentType: 'amp'
})
const staleToolResult = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
threadId: 'thread-a',
tool: 'shell_command',
input: { command: 'pnpm test' },
message: 'tests passed'
}),
'production'
)
expect(staleToolResult).toBeNull()
})
it('does not mark Amp tool result messages as explicit prompts', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
threadId: 'thread-a',
message: 'run tests'
}),
'production'
)
const result = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
threadId: 'thread-a',
tool: 'shell_command',
input: { command: 'pnpm test' },
message: 'tests passed'
}),
'production'
)
expect(result?.payload).toMatchObject({
state: 'working',
prompt: 'run tests',
agentType: 'amp',
lastAssistantMessage: 'tests passed'
})
expect(result?.hasExplicitPrompt).toBeUndefined()
})
it('marks cancelled Amp turns as interrupted done states', () => {
const cancelled = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
message: 'stop this run',
status: 'cancelled'
}),
'production'
)
expect(cancelled?.payload).toMatchObject({
state: 'done',
prompt: 'stop this run',
agentType: 'amp',
interrupted: true
})
})
it('treats session.start as cache reset without creating a visible row', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
message: 'old prompt'
}),
'production'
)
const sessionStart = _internals.normalizeHookPayload(
'amp',
buildBody({ hook_event_name: 'session.start', threadId: 'thread-1' }),
'production'
)
expect(sessionStart).toBeNull()
const nextTool = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.call',
tool: 'Read',
input: { file_path: '/tmp/file.ts' }
}),
'production'
)
expect(nextTool?.payload).toMatchObject({
state: 'working',
prompt: '',
agentType: 'amp',
toolName: 'Read',
toolInput: '/tmp/file.ts'
})
})
})
@@ -0,0 +1,449 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { _internals } from './server'
import { buildBody } from './server.test-fixtures'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
beforeEach(() => {
_internals.resetCachesForTests()
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Claude hook normalization', () => {
it('PostToolUse for Edit surfaces toolName + file_path preview', () => {
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: { file_path: '/src/config.ts', old_string: 'a', new_string: 'b' },
tool_response: {}
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('Edit')
expect(result?.payload.toolInput).toBe('/src/config.ts')
})
it('PostToolUse for Bash surfaces the command string', () => {
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'pnpm test --run' },
tool_response: { content: [{ type: 'text', text: 'tests passed' }] }
}),
'production'
)
expect(result?.payload.toolName).toBe('Bash')
expect(result?.payload.toolInput).toBe('pnpm test --run')
expect(result?.payload.lastAssistantMessage).toBe('tests passed')
})
it('PostToolUse for Grep surfaces the search pattern', () => {
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'foo.*bar', path: '/src' }
}),
'production'
)
expect(result?.payload.toolName).toBe('Grep')
expect(result?.payload.toolInput).toBe('foo.*bar')
})
it('PostToolUse for an unknown tool surfaces the name without input', () => {
// Why: a per-tool allowlist picks the preview field; unrecognized tools render name-only to avoid guessing a misleading field (e.g. an opaque ID).
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'BespokeTool',
tool_input: { irrelevantFlag: true, summary: 'doing the thing' }
}),
'production'
)
expect(result?.payload.toolName).toBe('BespokeTool')
expect(result?.payload.toolInput).toBeUndefined()
})
it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => {
// Why: TaskUpdate's tool_input has no meaningful preview (rendering "3" is confusing), so the allowlist leaves toolInput undefined.
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'TaskUpdate',
tool_input: { task_id: '3', status: 'in_progress' }
}),
'production'
)
expect(result?.payload.toolName).toBe('TaskUpdate')
expect(result?.payload.toolInput).toBeUndefined()
})
it('PostToolUseFailure clears stale tool fields until the next PreToolUse', () => {
_internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Edit',
tool_input: { file_path: '/src/config.ts' }
}),
'production'
)
const failed = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUseFailure',
tool_name: 'Edit',
tool_input: { file_path: '/src/config.ts' },
error: 'file is read-only'
}),
'production'
)
expect(failed?.payload).toMatchObject({
state: 'working',
lastAssistantMessage: 'file is read-only'
})
expect(failed?.payload.toolName).toBeUndefined()
expect(failed?.payload.toolInput).toBeUndefined()
const retry = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: { file_path: '/src/config.ts' }
}),
'production'
)
expect(retry?.payload).toMatchObject({
state: 'working',
toolName: 'Read',
toolInput: '/src/config.ts'
})
})
it('PreToolUse normalizes to working + tool fields', () => {
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: { file_path: '/src/index.ts' }
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('Read')
expect(result?.payload.toolInput).toBe('/src/index.ts')
})
it('PermissionRequest normalizes to waiting + tool fields', () => {
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'rm -rf build' }
}),
'production'
)
expect(result?.payload.state).toBe('waiting')
expect(result?.payload.toolName).toBe('Bash')
expect(result?.payload.toolInput).toBe('rm -rf build')
})
it('PermissionRequest for a fresh tool without input preview clears cached tool input', () => {
_internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
}),
'production'
)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PermissionRequest',
tool_name: 'BespokeTool',
tool_input: { request_id: 'approval-1' }
}),
'production'
)
expect(result?.payload.state).toBe('waiting')
expect(result?.payload.toolName).toBe('BespokeTool')
expect(result?.payload.toolInput).toBeUndefined()
})
it('PermissionRequest for the same tool without input preview clears cached tool input', () => {
_internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'BespokeTool',
tool_input: 'old preview'
}),
'production'
)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PermissionRequest',
tool_name: 'BespokeTool',
tool_input: { request_id: 'approval-1' }
}),
'production'
)
expect(result?.payload.state).toBe('waiting')
expect(result?.payload.toolName).toBe('BespokeTool')
expect(result?.payload.toolInput).toBeUndefined()
})
it('PermissionRequest without a tool name does not inherit stale tool details when input is explicit', () => {
_internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
}),
'production'
)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PermissionRequest',
tool_input: { request_id: 'approval-1' }
}),
'production'
)
expect(result?.payload.state).toBe('waiting')
expect(result?.payload.toolName).toBeUndefined()
expect(result?.payload.toolInput).toBeUndefined()
})
it('UserPromptSubmit clears the cached tool state from the prior turn', () => {
_internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: { file_path: '/src/stale.ts' }
}),
'production'
)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'UserPromptSubmit',
prompt: 'Do the next thing'
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.prompt).toBe('Do the next thing')
expect(result?.payload.toolName).toBeUndefined()
expect(result?.payload.toolInput).toBeUndefined()
})
it('Stop carries last_assistant_message directly when present', () => {
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'Stop',
last_assistant_message: 'what is up my dude'
}),
'production'
)
expect(result?.payload.state).toBe('done')
expect(result?.payload.lastAssistantMessage).toBe('what is up my dude')
})
it('StopFailure maps to done without copying provider error text', () => {
_internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'UserPromptSubmit',
prompt: 'say hi'
}),
'production'
)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'StopFailure',
error: 'invalid_request',
error_details: 'model is not supported',
last_assistant_message: 'API Error: model is not supported'
}),
'production'
)
expect(result?.payload.state).toBe('done')
expect(result?.payload.prompt).toBe('say hi')
expect(result?.payload.lastAssistantMessage).toBeUndefined()
})
describe('Stop transcript scan', () => {
let tmpDir: string
let transcriptPath: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-hook-test-'))
transcriptPath = join(tmpDir, 'transcript.jsonl')
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
it('surfaces the most recent assistant text entry', () => {
const lines = [
{ role: 'user', content: 'hi' },
{ role: 'assistant', message: { role: 'assistant', content: 'earlier reply' } },
{ role: 'user', content: 'do it' },
{
role: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'final reply' }] }
}
]
writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }),
'production'
)
expect(result?.payload.lastAssistantMessage).toBe('final reply')
})
it('skips tool_use-only assistant entries to find the previous text reply', () => {
const lines = [
{ role: 'assistant', message: { role: 'assistant', content: 'the answer is 42' } },
{
role: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }]
}
}
]
writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }),
'production'
)
expect(result?.payload.lastAssistantMessage).toBe('the answer is 42')
})
it('finds an assistant reply that sits past the first chunk boundary', () => {
// Why: large tool_result entries push the final reply past the first 64 KB chunk; the scan must keep reading backward to find it.
const filler = 'x'.repeat(70_000)
const lines = [
{ role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } },
// 70 KB of tool_result content straddling the first chunk boundary.
{
role: 'user',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 't1', content: filler }]
}
},
{
role: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }]
}
}
]
writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }),
'production'
)
expect(result?.payload.lastAssistantMessage).toBe('deeply buried reply')
})
it('returns undefined when the transcript has no assistant text at all', () => {
const lines = [
{ role: 'user', content: 'hi' },
{
role: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }]
}
}
]
writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`)
const result = _internals.normalizeHookPayload(
'claude',
buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }),
'production'
)
expect(result?.payload.lastAssistantMessage).toBeUndefined()
})
})
it('merges tool fields across consecutive events in the same turn', () => {
_internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'ls -la' }
}),
'production'
)
// Stop event has no tool fields, so the merged snapshot must keep the earlier PreToolUse values.
const stop = _internals.normalizeHookPayload(
'claude',
buildBody({ hook_event_name: 'Stop' }),
'production'
)
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.toolName).toBe('Bash')
expect(stop?.payload.toolInput).toBe('ls -la')
})
})
@@ -0,0 +1,677 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { buildBody, PANE } from './server.test-fixtures'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
beforeEach(() => {
_internals.resetCachesForTests()
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('AgentHookServer listener replay', () => {
it('keeps Claude permission visible when another subagent reports tool activity', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await expect(
postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }
})
).resolves.toMatchObject({ status: 204 })
await expect(
postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: { file_path: '/tmp/other-subagent.txt' }
})
).resolves.toMatchObject({ status: 204 })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'rm -rf /tmp/orca-subagent-repro'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible when matching tool activity has no execution id', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'rm -rf /tmp/orca-subagent-repro'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible when approved tool execution has no identity', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' },
tool_use_id: 'toolu-approved-1'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'rm -rf /tmp/orca-subagent-repro'
})
])
} finally {
server.stop()
}
})
it('lets Claude permission clear when approved PostToolUse matches the preceding tool use id', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' },
tool_use_id: 'toolu-approved-by-claude'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' },
tool_use_id: 'toolu-approved-by-claude'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'rm -rf /tmp/orca-2824-permission-target'
})
])
} finally {
server.stop()
}
})
it('lets Claude permission clear by tool use id when tool input is not previewable', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' },
tool_use_id: 'toolu-approved-opaque'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' },
tool_use_id: 'toolu-approved-opaque'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude',
toolName: 'BespokeTool'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible for unpreviewable tool input with another tool use id', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' },
tool_use_id: 'toolu-permission-owner-opaque'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-b' },
tool_use_id: 'toolu-other-opaque'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'BespokeTool'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible when another tool use completes after permission', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-permission-owner'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-other-subagent'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible when an explicit agent type reports another tool use id', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
agent_type: 'main',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-permission-owner-type'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
agent_type: 'main',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
agent_type: 'main',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-other-type'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
})
])
} finally {
server.stop()
}
})
it('lets Claude permission clear when same explicit agent type starts the approved tool', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
agent_type: 'main',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
agent_type: 'main',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' },
tool_use_id: 'toolu-approved-1'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'rm -rf /tmp/orca-subagent-repro'
})
])
} finally {
server.stop()
}
})
it('lets Claude subagent permission clear when the same agent starts the approved tool', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
agent_id: 'agent-subagent-a',
agent_type: 'Review',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
agent_id: 'agent-subagent-a',
agent_type: 'Review',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-approved-subagent'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
})
])
} finally {
server.stop()
}
})
it('lets same Claude subagent clear an unknown approved tool without an input preview', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
agent_id: 'agent-custom-tool',
agent_type: 'Review',
tool_name: 'BespokeTool',
tool_input: { request_id: 'pending-1' }
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
agent_id: 'agent-custom-tool',
agent_type: 'Review',
tool_name: 'BespokeTool',
tool_input: { request_id: 'pending-1' },
tool_use_id: 'toolu-custom-approved'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude',
toolName: 'BespokeTool',
toolInput: undefined
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible when another same-type subagent reports the same tool execution', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
agent_id: 'agent-subagent-a',
agent_type: 'Review',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
agent_id: 'agent-subagent-b',
agent_type: 'Review',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-other-subagent'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible when unknown tool previews collide', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'BespokeTool',
tool_input: { request_id: 'pending-1' }
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'BespokeTool',
tool_input: { request_id: 'other-subagent' }
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'BespokeTool',
toolInput: undefined
})
])
} finally {
server.stop()
}
})
it('lets Claude permission clear when a new explicit prompt starts', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }
})
await postClaudeHook({
hook_event_name: 'UserPromptSubmit',
prompt: 'start a new task'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude',
prompt: 'start a new task',
toolName: undefined,
toolInput: undefined
})
])
} finally {
server.stop()
}
})
it('does not replay cleared pane state to a newly attached listener', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({
hook_event_name: 'UserPromptSubmit',
prompt: 'clear me'
})
)
})
server.clearPaneState(PANE)
const listener = vi.fn()
server.setListener(listener)
expect(listener).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
})

Some files were not shown because too many files have changed in this diff Show More