mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into faster-kanban-view-open
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Product source remains owned by its normal reviewers; localization inputs need focused review.
|
||||
/src/renderer/src/i18n/locales/ @brennanb2025
|
||||
/config/scripts/*localization*.mjs @brennanb2025
|
||||
/config/scripts/*locale*.mjs @brennanb2025
|
||||
/i18next.config.ts @brennanb2025
|
||||
@@ -6,6 +6,10 @@ inputs:
|
||||
description: Native runtime to prepare after the script-free install (none, node, or electron).
|
||||
required: false
|
||||
default: none
|
||||
node-version:
|
||||
description: Node.js version override; defaults to the version declared in package.json.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -17,11 +21,19 @@ runs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: inputs.node-version == ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup requested Node.js
|
||||
if: inputs.node-version != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate native runtime
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/computer-use-modifier-safety.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
@@ -84,6 +85,7 @@ jobs:
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/computer-use-modifier-safety.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
src/main/computer/computer-provider-lifecycle.test.ts
|
||||
|
||||
@@ -184,7 +184,9 @@ jobs:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Why 35: the terminal parking + retention specs below add two more
|
||||
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
|
||||
timeout-minutes: 35
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -227,6 +229,13 @@ jobs:
|
||||
- name: Run Docker SSH watcher isolation E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
|
||||
# Why always(): these specs gate the C1 terminal parking/retention budget
|
||||
# over a real SSH host and run in no other lane, so a watcher-isolation
|
||||
# failure above must not silently skip them.
|
||||
- name: Run Docker SSH terminal parking + retention E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -62,6 +62,11 @@ jobs:
|
||||
- name: Verify localization catalog
|
||||
run: pnpm run verify:localization-catalog
|
||||
|
||||
# Why: extraction writes sorted evidence to an isolated temporary path,
|
||||
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
|
||||
- name: Verify localization extraction
|
||||
run: pnpm run verify:localization-extraction
|
||||
|
||||
- name: Verify localization coverage
|
||||
run: pnpm run verify:localization-coverage
|
||||
|
||||
@@ -179,12 +184,14 @@ jobs:
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shard_total: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -195,6 +202,7 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
@@ -208,7 +216,7 @@ jobs:
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
package:
|
||||
name: package
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Terminal IME E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/terminal-ime-e2e.yml'
|
||||
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
|
||||
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
|
||||
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
|
||||
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
|
||||
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
|
||||
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
|
||||
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
- 'tests/e2e/terminal-ime-*.ts'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux-x11:
|
||||
name: Linux X11 terminal IME
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native build and IME tools
|
||||
run: >-
|
||||
sudo apt-get update &&
|
||||
sudo apt-get install -y
|
||||
build-essential
|
||||
dbus-x11
|
||||
dconf-gsettings-backend
|
||||
ibus
|
||||
ibus-hangul
|
||||
libglib2.0-bin
|
||||
python3
|
||||
xdotool
|
||||
xfwm4
|
||||
xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for E2E
|
||||
run: pnpm exec electron-vite build --mode e2e
|
||||
|
||||
- name: Run deterministic terminal IME boundary tests
|
||||
run: >-
|
||||
xvfb-run --auto-servernum
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
|
||||
pnpm run test:e2e --
|
||||
tests/e2e/terminal-ime-exact-byte.spec.ts
|
||||
--workers=1
|
||||
|
||||
- name: Run native IBus Hangul exact-byte tests
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
- name: Upload terminal IME evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-ime-evidence
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -44,9 +44,15 @@ package-lock.json
|
||||
*~
|
||||
|
||||
# OS
|
||||
# Keep these in step with isOsMetadataSkillEntryName (skill-package-identity.ts): both disk
|
||||
# walkers skip a plain file with one of these names, the git-tree producer does not, and
|
||||
# ignoring them here is what keeps `git add -A` from committing a stray one.
|
||||
*.stackdump
|
||||
.DS_Store
|
||||
._*
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Lint/cache
|
||||
.oxlintcache
|
||||
|
||||
@@ -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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type OutputChunk = Rollup.OutputChunk
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
|
||||
@@ -83,12 +83,17 @@ module.exports = {
|
||||
'!skill-guides{,/**/*}',
|
||||
'!skill-stubs{,/**/*}',
|
||||
'!tests{,/**/*}',
|
||||
// Why: examples/ is plugin authoring documentation with no runtime consumer —
|
||||
// bundled plugins ship via extraResources from resources/plugins/launch/. It also
|
||||
// carries hostile-panel, the adversarial fixture the containment tests point at,
|
||||
// which must never reach a user's install.
|
||||
'!examples{,/**/*}',
|
||||
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
|
||||
// it is gitignored, but exclude it defensively so a stray local capture at
|
||||
// package time never bloats app.asar.
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
|
||||
'!out/**/*.test.js',
|
||||
// Why: Vite's manifest is only used to project the paired web client.
|
||||
'!out/renderer/.vite{,/**/*}',
|
||||
@@ -136,18 +141,23 @@ module.exports = {
|
||||
'out/main/agent-hooks/**',
|
||||
'out/main/antigravity/**',
|
||||
'out/main/claude/**',
|
||||
'out/main/claude-accounts/keychain.js',
|
||||
'out/main/codex/**',
|
||||
'out/main/codex-cli/command.js',
|
||||
'out/main/copilot/**',
|
||||
'out/main/cursor/**',
|
||||
'out/main/droid/**',
|
||||
'out/main/gemini/**',
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/ipc/local-agent-install-dir-detection.js',
|
||||
'out/main/ipc/tui-agent-detection-commands.js',
|
||||
'out/main/win32-utils.js',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/main-thread-hang-watchdog-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
|
||||
@@ -52,11 +52,27 @@ Sync catalog keys after adding or removing `translate(...)` calls:
|
||||
pnpm run sync:localization-catalog
|
||||
```
|
||||
|
||||
The sync command adds missing `en.json` entries from each call's string fallback,
|
||||
copies untranslated English placeholders into other locale catalogs to keep
|
||||
parity, removes locale entries whose English key was deleted, and repairs
|
||||
placeholder mismatches. Run the machine-translation bootstrap commands only when
|
||||
refreshing real translations, not for ordinary UI copy changes.
|
||||
The sync command adds missing `en.json` entries from each call's string fallback.
|
||||
It never edits target catalogs: missing values remain absent and use the existing
|
||||
runtime English fallback. Existing placeholder mismatches fail validation until
|
||||
a localization PR fixes or retires the target entry.
|
||||
|
||||
Run maintained source extraction without committing a second English catalog:
|
||||
|
||||
```sh
|
||||
pnpm run verify:localization-extraction
|
||||
```
|
||||
|
||||
Extraction fails when a statically extracted key is absent from `en.json` or an
|
||||
inline default has incompatible placeholders. Existing unreferenced English keys
|
||||
and wording-only fallback drift are reported as migration debt; the permanent
|
||||
bilingual translation source will reconcile them without a large disposition
|
||||
database. Reviewed and stale translation state is likewise deferred to that
|
||||
source rather than inferred permanently from Git history.
|
||||
|
||||
The legacy free-endpoint bootstrap and whole-catalog repair scripts intentionally
|
||||
have no package-script entry points. Ordinary product and localization work must
|
||||
not invoke tools that can overwrite an entire target catalog.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
|
||||
@@ -97,8 +113,8 @@ Recommended migration order:
|
||||
The final gate should combine three checks:
|
||||
|
||||
1. Scanner coverage: no unclassified localizable candidates remain.
|
||||
2. Catalog coverage: every supported locale has the same keys as English, with
|
||||
matching interpolation variables.
|
||||
2. Catalog correctness: existing translations have matching interpolation
|
||||
variables; missing target entries are reported rather than rejected.
|
||||
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
|
||||
obvious English leftovers or layout clipping in core screens.
|
||||
|
||||
|
||||
@@ -251,7 +251,6 @@ inline src/renderer/src/components/tab-bar/TabBar.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/task-page-cache-selectors.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
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1286
-866
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
@@ -72,6 +73,7 @@ describe('computer-use e2e workflow', () => {
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
'src/main/computer/computer-provider-lifecycle.test.ts',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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 < 0 || end < 0) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
}
|
||||
|
||||
describe('computer-use modifier safety', () => {
|
||||
it('uses mouse-event flags instead of held modifier keys on macOS', () => {
|
||||
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
|
||||
const clickInput = sourceBetween(macOS, 'static func click(', 'static func scroll(')
|
||||
const mouseInput = sourceBetween(
|
||||
macOS,
|
||||
'private static func mouse(',
|
||||
'private static func keyEvent('
|
||||
)
|
||||
|
||||
expect(mouseInput).toContain('event.flags = flags')
|
||||
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
|
||||
expect(clickInput).not.toContain('down: true')
|
||||
})
|
||||
|
||||
it('submits each modified Windows click in a closed, timed SendInput batch', () => {
|
||||
const windows = source('native/computer-use-windows/runtime.ps1')
|
||||
const modifiedClick = sourceBetween(
|
||||
windows,
|
||||
'public static void SendModifiedClick',
|
||||
'private static INPUT KeyboardInput'
|
||||
)
|
||||
const mouseClick = sourceBetween(
|
||||
windows,
|
||||
'function Send-OrcaMouseClick',
|
||||
'function Send-OrcaDrag'
|
||||
)
|
||||
|
||||
expect(modifiedClick).toContain('SendInput((uint)values.Length, values')
|
||||
expect(modifiedClick).toContain('SendInput((uint)releaseValues.Length, releaseValues')
|
||||
expect(modifiedClick).toContain('if (sent != (uint)values.Length)')
|
||||
expect(modifiedClick).toContain('releases.Add(MouseInput(mouseInput, mouseUp))')
|
||||
expect(modifiedClick).not.toContain('int count')
|
||||
expect(mouseClick).toMatch(
|
||||
/for \(\$i = 0; \$i -lt \$clickCount; \$i\+\+\) \{\s+\[OrcaDesktopWin32\]::SendModifiedClick\(/
|
||||
)
|
||||
expect(mouseClick).toContain('if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }')
|
||||
expect(windows).not.toContain('keybd_event')
|
||||
})
|
||||
|
||||
it('keeps Linux modifier release in the xdotool sequence and a fallback', () => {
|
||||
const linux = source('native/computer-use-linux/runtime.py')
|
||||
const modifiedClick = sourceBetween(linux, 'def modified_click_at(', 'def scroll_at(')
|
||||
|
||||
expect(modifiedClick).toContain('command.extend(["keyup", modifier])')
|
||||
expect(modifiedClick).toContain('is_wayland')
|
||||
expect(modifiedClick).toContain('modified clicks require xdotool on an X11 session')
|
||||
expect(modifiedClick).toContain('finally:')
|
||||
expect(modifiedClick).toContain('check=False')
|
||||
expect(modifiedClick).toContain('timeout=5')
|
||||
expect(modifiedClick).toContain('timeout=2')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
|
||||
@@ -8,6 +9,7 @@ const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// installable stub projection is checked separately below.
|
||||
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
|
||||
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
|
||||
|
||||
describe('computer-use skill guidance', () => {
|
||||
it('keeps web-app targeting on the computer-use surface', () => {
|
||||
@@ -44,6 +46,15 @@ describe('computer-use skill guidance', () => {
|
||||
expect(skill).toContain('`result.snapshot.treeText`')
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
|
||||
it('requires atomic modifier-click actions in the source and bundled guide', () => {
|
||||
expect(bundledGuide).toBeDefined()
|
||||
|
||||
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
|
||||
expect(skill).toContain('click --modifiers <chord>')
|
||||
expect(skill).toContain('Never synthesize separate modifier-down and modifier-up commands')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use install stub', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
||||
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
|
||||
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
|
||||
const {
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
@@ -38,15 +39,39 @@ describe('electron-builder config', () => {
|
||||
'!skill-stubs{,/**/*}',
|
||||
'!resources/skills/**',
|
||||
'!tests{,/**/*}',
|
||||
'!examples{,/**/*}',
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
|
||||
'!out/**/*.test.js',
|
||||
'!resources/plugins/launch/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
|
||||
// anything without an explicit `!` entry — examples/ landed without one and shipped
|
||||
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
|
||||
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
|
||||
it('keeps plugin authoring examples out of app.asar', () => {
|
||||
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
||||
// copyFiles() prepends this itself once the pattern list is all-negation.
|
||||
matcher.prependPattern('**/*')
|
||||
const isPacked = matcher.createFilter()
|
||||
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
|
||||
|
||||
for (const authoringOnly of [
|
||||
'examples/plugins/hostile-panel/panel.html',
|
||||
'examples/plugins/hostile-panel/orca-plugin.json',
|
||||
'examples/plugins/hello-orca/main.mjs',
|
||||
'examples/plugins/hello-orca/orca-plugin.json'
|
||||
]) {
|
||||
expect(packs(authoringOnly)).toBe(false)
|
||||
}
|
||||
// The negation stays anchored at the app root, so nested `examples` segments still ship.
|
||||
expect(packs('out/main/examples/index.js')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps runtime resources available through extraResources', () => {
|
||||
const bundledPluginResources = expect.objectContaining({
|
||||
from: 'resources/plugins/launch',
|
||||
@@ -129,7 +154,16 @@ describe('electron-builder config', () => {
|
||||
|
||||
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
|
||||
expect.arrayContaining([
|
||||
'out/package.json',
|
||||
'out/cli/**',
|
||||
'out/shared/**',
|
||||
'out/main/claude-accounts/keychain.js',
|
||||
'out/main/codex-cli/command.js',
|
||||
'out/main/ipc/local-agent-install-dir-detection.js',
|
||||
'out/main/ipc/tui-agent-detection-commands.js',
|
||||
'out/main/win32-utils.js'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -141,6 +175,14 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
|
||||
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
|
||||
it('unpacks the forked main-thread hang-watchdog entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the multi-size icon source for Linux packages', () => {
|
||||
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
|
||||
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
|
||||
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
|
||||
|
||||
describe('Electron Vite output contract', () => {
|
||||
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
|
||||
@@ -39,4 +40,12 @@ describe('Electron Vite output contract', () => {
|
||||
it('rejects prototype properties as build targets', () => {
|
||||
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
|
||||
})
|
||||
|
||||
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
|
||||
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
|
||||
expect(devRunner).toContain("'Electron Helper.app',")
|
||||
expect(devRunner).toContain(
|
||||
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,6 +124,18 @@ function gitTreeSha(entries) {
|
||||
return hashDirectory(root).toString('hex')
|
||||
}
|
||||
|
||||
// Why: kept in step with isOsMetadataSkillEntryName in src/main/skills/skill-package-identity.ts.
|
||||
// The scanner ignores these because the OS writes them into a live install; the generator
|
||||
// ignores them so a stray one in a working tree cannot be committed into the manifest as
|
||||
// content no user could ever match. Skipped rather than rejected: the file is not the
|
||||
// developer's doing, so failing the build over it would be hostile.
|
||||
const OS_METADATA_FILE_NAMES = new Set(['.ds_store', 'thumbs.db', 'ehthumbs.db', 'desktop.ini'])
|
||||
|
||||
function isOsMetadataSkillEntryName(name) {
|
||||
const folded = name.toLocaleLowerCase('en-US')
|
||||
return OS_METADATA_FILE_NAMES.has(folded) || folded.startsWith('._')
|
||||
}
|
||||
|
||||
async function collectPackageFiles(packageRoot) {
|
||||
const files = []
|
||||
const caseFoldedPaths = new Map()
|
||||
@@ -135,6 +147,14 @@ async function collectPackageFiles(packageRoot) {
|
||||
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(directory, entry.name)
|
||||
const fileStat = await lstat(absolutePath)
|
||||
// Only a plain file is OS-authored, so the type decides and not the name alone: a
|
||||
// directory or link wearing the name would otherwise drop its subtree out of the
|
||||
// manifest and skip the guards below. Decided before the case-fold map so two
|
||||
// spellings of one sidecar cannot collide.
|
||||
if (isOsMetadataSkillEntryName(entry.name) && fileStat.isFile()) {
|
||||
continue
|
||||
}
|
||||
const relativePath = path.relative(packageRoot, absolutePath)
|
||||
assertSafeRelativePath(relativePath)
|
||||
const manifestPath = relativePath.split(path.sep).join('/')
|
||||
@@ -144,7 +164,6 @@ async function collectPackageFiles(packageRoot) {
|
||||
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
|
||||
}
|
||||
caseFoldedPaths.set(foldedPath, manifestPath)
|
||||
const fileStat = await lstat(absolutePath)
|
||||
if (fileStat.isSymbolicLink()) {
|
||||
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { observeSkillPackage } from '../../src/main/skills/skill-package-identity'
|
||||
import {
|
||||
appendReleaseRow,
|
||||
assertReleasedHistoryPreserved,
|
||||
@@ -436,6 +437,90 @@ describe('skill bundle manifest generator', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores OS-authored sidecars a working tree may carry', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
|
||||
await mkdir(path.join(packageRoot, 'references'))
|
||||
await writeFile(path.join(packageRoot, 'references', 'guide.md'), 'nested\n')
|
||||
const pristine = await collectPackageFiles(packageRoot)
|
||||
expect(pristine.map((file) => file.path)).toEqual(['SKILL.md', 'references/guide.md'])
|
||||
// Finder writes .DS_Store into any browsed folder, and it is gitignored — so without
|
||||
// this the committed artifacts read as stale and lint fails for that developer, while
|
||||
// the scanner would have no snapshot a real install could match.
|
||||
await writeFile(path.join(packageRoot, '.DS_Store'), Buffer.from([0, 1, 2, 3]))
|
||||
await writeFile(path.join(packageRoot, '._SKILL.md'), Buffer.from([0, 5]))
|
||||
await writeFile(path.join(packageRoot, 'Thumbs.db'), Buffer.from([9]))
|
||||
// Nested folders get browsed too, and a sidecar there shifts the same index-aligned list.
|
||||
await writeFile(path.join(packageRoot, 'references', '.DS_Store'), Buffer.from([7]))
|
||||
|
||||
expect(await collectPackageFiles(packageRoot)).toEqual(pristine)
|
||||
})
|
||||
|
||||
it('still records an unexpected file that is not OS metadata', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
|
||||
await writeFile(path.join(packageRoot, 'payload.sh'), 'echo hi\n')
|
||||
|
||||
expect((await collectPackageFiles(packageRoot)).map((file) => file.path)).toEqual([
|
||||
'SKILL.md',
|
||||
'payload.sh'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps guarding a directory or link that only wears an OS metadata name', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
|
||||
// Only plain files are OS-authored, so a subtree behind one of these names is real
|
||||
// content that must stay in the manifest instead of shipping unrecorded.
|
||||
await mkdir(path.join(packageRoot, '.DS_Store'))
|
||||
await writeFile(path.join(packageRoot, '.DS_Store', 'payload.sh'), 'echo hi\n')
|
||||
|
||||
expect((await collectPackageFiles(packageRoot)).map((file) => file.path)).toEqual([
|
||||
'.DS_Store/payload.sh',
|
||||
'SKILL.md'
|
||||
])
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
await rm(path.join(packageRoot, '.DS_Store'), { recursive: true })
|
||||
await symlink('SKILL.md', path.join(packageRoot, '._SKILL.md'))
|
||||
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
|
||||
'Symlink is not allowed in a shipped skill'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the predicate is hand-copied from the scanner, and an asymmetric skip is worse than
|
||||
// no skip — one side would bake in content the other can never observe, leaving every
|
||||
// install permanently unrecognized. Compared through both walkers so ordering and the
|
||||
// case-fold map are covered too, not just the name test.
|
||||
it('skips exactly the names the scanner skips', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
for (const name of [
|
||||
'SKILL.md',
|
||||
'.DS_Store',
|
||||
'.ds_store',
|
||||
'.DS_STORE',
|
||||
'Thumbs.db',
|
||||
'THUMBS.DB',
|
||||
'ehthumbs.db',
|
||||
'desktop.ini',
|
||||
'Desktop.INI',
|
||||
'._SKILL.md',
|
||||
'._',
|
||||
// Near misses that both sides must keep.
|
||||
'.dsstore',
|
||||
'ds_store.md',
|
||||
'_SKILL.md',
|
||||
'.DS_Store.md'
|
||||
]) {
|
||||
await writeFile(path.join(packageRoot, name), `${name}\n`)
|
||||
}
|
||||
|
||||
const generated = (await collectPackageFiles(packageRoot)).map((file) => file.path)
|
||||
expect(generated).toEqual((await observeSkillPackage(packageRoot)).files.map((f) => f.path))
|
||||
expect(generated).toEqual(['.DS_Store.md', '.dsstore', 'SKILL.md', '_SKILL.md', 'ds_store.md'])
|
||||
})
|
||||
|
||||
it('computes the same Git tree identity as Git', async () => {
|
||||
const packageRoot = path.resolve('skills', 'orca-cli')
|
||||
const files = await collectPackageFiles(packageRoot)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
|
||||
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
|
||||
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
|
||||
|
||||
export function mergeLocaleKeyOverrides(base) {
|
||||
const merged = { ...base }
|
||||
@@ -10,5 +11,8 @@ export function mergeLocaleKeyOverrides(base) {
|
||||
// KO split overrides can share keys with zh/ja repairs; merge per locale.
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -2231,9 +2231,6 @@
|
||||
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
|
||||
"ko": "마이크"
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.7ca17b62c8": {
|
||||
"ko": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다."
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
|
||||
"ko": "자동화"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const MACOS_TCC_KEY_OVERRIDES = {
|
||||
'auto.hooks.useMacosTccPromptNotice.description': {
|
||||
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
|
||||
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
|
||||
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
|
||||
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
|
||||
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
|
||||
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
|
||||
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
|
||||
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
|
||||
ko: '전체 디스크 접근 권한',
|
||||
zh: '完全磁盘访问权限'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const LOCALIZED_PROSE_TERM_KEYS = new Set([
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
|
||||
])
|
||||
|
||||
const LOCALIZABLE_PROSE_TERMS = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'agent',
|
||||
'agents',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'terminal',
|
||||
'terminals'
|
||||
])
|
||||
|
||||
export function isLocalizedProseTermContext(term, key) {
|
||||
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
|
||||
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
@@ -382,6 +383,9 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (isScreenCursorContext(brand, enValue, key)) {
|
||||
continue
|
||||
}
|
||||
if (isLocalizedProseTermContext(brand, key)) {
|
||||
continue
|
||||
}
|
||||
if (includesPreservedLatinTerm(result, brand)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('localization package scripts', () => {
|
||||
const scripts = JSON.parse(readFileSync('package.json', 'utf8')).scripts
|
||||
|
||||
it('keeps safe catalog and extraction verification available', () => {
|
||||
expect(scripts['verify:localization-catalog']).toBeDefined()
|
||||
expect(scripts['sync:localization-catalog']).toBeDefined()
|
||||
expect(scripts['verify:localization-extraction']).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not expose whole-catalog translation and repair commands', () => {
|
||||
expect(scripts['bootstrap:locale-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:zh-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:ko-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:ja-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:es-catalog']).toBeUndefined()
|
||||
expect(scripts['repair:locale-catalog']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const LOCALES = ['es', 'ja', 'ko', 'zh']
|
||||
const KEYS = [
|
||||
'auto.hooks.useMacosTccPromptNotice.title',
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.hooks.useMacosTccPromptNotice.openSettings',
|
||||
'auto.hooks.useMacosTccPromptNotice.dismiss',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
|
||||
'auto.components.settings.DeveloperPermissionsPane.c566bca278'
|
||||
]
|
||||
|
||||
function readCatalog(locale) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getValue(catalog, key) {
|
||||
return key.split('.').reduce((value, part) => value[part], catalog)
|
||||
}
|
||||
|
||||
describe('macOS TCC prompt localization', () => {
|
||||
it('survives the canonical catalog repair policy', () => {
|
||||
const english = readCatalog('en')
|
||||
for (const locale of LOCALES) {
|
||||
const catalog = readCatalog(locale)
|
||||
for (const key of KEYS) {
|
||||
const enValue = getValue(english, key)
|
||||
const localeValue = getValue(catalog, key)
|
||||
expect(repairTranslatedValue({ key, enValue, localeValue, locale })).toBe(localeValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the macOS Full Disk Access labels in Korean and Chinese', () => {
|
||||
expect(
|
||||
getValue(readCatalog('ko'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
|
||||
).toBe('전체 디스크 접근 권한')
|
||||
expect(
|
||||
getValue(readCatalog('zh'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
|
||||
).toBe('完全磁盘访问权限')
|
||||
})
|
||||
})
|
||||
@@ -51,30 +51,39 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('teaches the hard cutover without reviving a legacy executor', () => {
|
||||
it('teaches attested adoption without reviving the retired scheduler', () => {
|
||||
const skill = readSkill()
|
||||
const migration = getSection(skill, 'Contract Migration')
|
||||
|
||||
expect(migration).toContain('hard cutover')
|
||||
expect(migration).toContain('effectsApplied')
|
||||
expect(migration).toContain('skills get orchestration --full')
|
||||
expect(migration).toContain('Do not retry the rejected command unchanged')
|
||||
expect(migration).toContain('no longer supervised')
|
||||
expect(migration).toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('Read-only inspection never consumes legacy mail')
|
||||
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
|
||||
expect(migration).toContain('does not cancel the prior assignment')
|
||||
expect(migration).toContain('invalidate its worktree')
|
||||
expect(migration).toContain('discard filesystem changes')
|
||||
expect(migration).toContain('leave it as the only editor in that worktree')
|
||||
expect(migration).toContain('observe it manually, read-only')
|
||||
expect(migration).toContain('until it reaches a stable handoff point')
|
||||
expect(migration).toContain('visible activity is a reason to keep observing')
|
||||
expect(migration).toContain(
|
||||
'Never launch a replacement editor in the same worktree while the legacy worker may still write there.'
|
||||
'adopts a live pre-update orchestration assignment into an ordinary Run'
|
||||
)
|
||||
expect(migration).toContain('if remaining work needs new lifecycle supervision')
|
||||
expect(migration).not.toContain('restart the work using Run -> Task -> `worker-start`')
|
||||
expect(migration).toContain(
|
||||
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
|
||||
)
|
||||
expect(migration).toContain('never restarts or replaces the worker')
|
||||
expect(migration).toContain('The retired scheduler is not revived')
|
||||
expect(migration).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(migration).toContain('[LEGACY READ-ONLY]')
|
||||
expect(migration).toContain(
|
||||
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
|
||||
)
|
||||
expect(migration).not.toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
|
||||
expect(migration).toContain('Recovered orchestration work from a contract update')
|
||||
expect(migration).toContain('run-show --id <adopted_run_id>')
|
||||
expect(migration).toContain('task-list --run <adopted_run_id>')
|
||||
expect(migration).toContain('Legacy inspection remains available without consuming mail')
|
||||
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
|
||||
expect(migration).toContain('Takeover fences only the old coordinator')
|
||||
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
|
||||
expect(migration).toContain(
|
||||
'keep the original worker as the only editor until it reaches a stable handoff point'
|
||||
)
|
||||
expect(migration).toContain('a conflict-free placement for any remaining work')
|
||||
})
|
||||
|
||||
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
|
||||
|
||||
@@ -37,13 +37,19 @@ describe('PR workflow parallelism', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across sixteen runners', () => {
|
||||
it('shards the general test suite across Node 24 and Node 26', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = workflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
@@ -95,9 +101,15 @@ describe('PR workflow parallelism', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
|
||||
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
||||
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
||||
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
|
||||
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
|
||||
})
|
||||
|
||||
it('restores Electron downloads before preparing the package runtime', () => {
|
||||
|
||||
@@ -128,10 +128,8 @@ function prepareMacDevElectronApp() {
|
||||
|
||||
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
|
||||
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
|
||||
// v6: bundle the notification-status helper (real permission readout) and
|
||||
// ad-hoc re-sign after plist edits so Notification Center accepts the
|
||||
// bundle; bumping forces stale cached copies to be recreated.
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
|
||||
// v7: give the terminal daemon helper an Orca-specific TCC identity.
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
|
||||
const hash = createHash('sha1')
|
||||
.update(
|
||||
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
|
||||
@@ -155,6 +153,7 @@ function prepareMacDevElectronApp() {
|
||||
// Electron drops clicks for notification ids it didn't create, so the
|
||||
// click is lost, not misdirected.
|
||||
const bundleId = 'com.stablyai.orca.dev'
|
||||
const helperBundleId = `${bundleId}.helper`
|
||||
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
|
||||
const expectedMarker = JSON.stringify(
|
||||
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
|
||||
@@ -205,9 +204,18 @@ function prepareMacDevElectronApp() {
|
||||
restoreElectronFrameworkSymlinks(appPath)
|
||||
|
||||
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
|
||||
const helperPlistPath = path.join(
|
||||
appPath,
|
||||
'Contents',
|
||||
'Frameworks',
|
||||
'Electron Helper.app',
|
||||
'Contents',
|
||||
'Info.plist'
|
||||
)
|
||||
setPlistValue(plistPath, 'CFBundleName', title)
|
||||
setPlistValue(plistPath, 'CFBundleDisplayName', title)
|
||||
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
|
||||
setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)
|
||||
|
||||
// Why: the notification-status helper reads the app's real macOS
|
||||
// notification authorization (UNUserNotificationCenter has no Electron
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
// Why both specs in one runner: they share the docker SSH rig and the same
|
||||
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
|
||||
// restore, the retention spec proves the C1 budget bounds what they retain.
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-terminal-parking.spec.ts',
|
||||
'tests/e2e/terminal-retention-budget.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,306 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '../..')
|
||||
const scriptPath = import.meta.filename
|
||||
const insideSessionFlag = '--inside-session'
|
||||
const processStopTimeoutMs = 5_000
|
||||
const processKillTimeoutMs = 1_000
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function waitForExit(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
|
||||
})
|
||||
}
|
||||
|
||||
function processGroupMembers(processGroupId) {
|
||||
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
return []
|
||||
}
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function stopOwnedProcessGroup(processGroupId) {
|
||||
let members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
console.error(
|
||||
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
|
||||
)
|
||||
try {
|
||||
process.kill(-processGroupId, 'SIGTERM')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + processStopTimeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-processGroupId, 'SIGKILL')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const killDeadline = Date.now() + processKillTimeoutMs
|
||||
do {
|
||||
members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
await delay(100)
|
||||
} while (Date.now() < killDeadline)
|
||||
return members
|
||||
}
|
||||
|
||||
function commandOutput(command, args) {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8' })
|
||||
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
|
||||
}
|
||||
|
||||
function configureHangulEngine() {
|
||||
for (const [key, value] of [
|
||||
['initial-input-mode', 'hangul'],
|
||||
['hangul-keyboard', '2']
|
||||
]) {
|
||||
const result = spawnSync(
|
||||
'gsettings',
|
||||
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHangulEngine(ibusProcess) {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
if (ibusProcess.exitCode !== null) {
|
||||
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
|
||||
}
|
||||
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
|
||||
if (result.status === 0) {
|
||||
return
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
throw new Error('Timed out while selecting the IBus Hangul engine')
|
||||
}
|
||||
|
||||
async function runInsideSession(evidenceDir) {
|
||||
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
|
||||
const ibusLogFd = openSync(ibusLogPath, 'w')
|
||||
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
|
||||
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
|
||||
const evidence = {
|
||||
display: process.env.DISPLAY ?? null,
|
||||
ibusDaemonPid: null,
|
||||
ibusGroupBeforeCleanup: [],
|
||||
ibusGroupAfterCleanup: [],
|
||||
playwrightPid: null,
|
||||
windowManagerPid: null,
|
||||
windowManagerGroupAfterCleanup: []
|
||||
}
|
||||
let ibusProcess
|
||||
let windowManagerProcess
|
||||
let testExitCode = 1
|
||||
|
||||
try {
|
||||
configureHangulEngine()
|
||||
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
|
||||
})
|
||||
if (!windowManagerProcess.pid) {
|
||||
throw new Error('xfwm4 did not return a PID')
|
||||
}
|
||||
evidence.windowManagerPid = windowManagerProcess.pid
|
||||
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
|
||||
|
||||
ibusProcess = spawn(
|
||||
'ibus-daemon',
|
||||
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
|
||||
{
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: ['ignore', ibusLogFd, ibusLogFd]
|
||||
}
|
||||
)
|
||||
if (!ibusProcess.pid) {
|
||||
throw new Error('ibus-daemon did not return a PID')
|
||||
}
|
||||
evidence.ibusDaemonPid = ibusProcess.pid
|
||||
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
|
||||
await waitForHangulEngine(ibusProcess)
|
||||
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
|
||||
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
|
||||
console.error(
|
||||
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
|
||||
'get',
|
||||
'org.freedesktop.ibus.engine.hangul',
|
||||
'initial-input-mode'
|
||||
])}`
|
||||
)
|
||||
console.error(
|
||||
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
|
||||
'get',
|
||||
'org.freedesktop.ibus.engine.hangul',
|
||||
'hangul-keyboard'
|
||||
])}`
|
||||
)
|
||||
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
|
||||
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
|
||||
|
||||
const testProcess = spawn(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
[
|
||||
'run',
|
||||
'test:e2e:headful',
|
||||
'--workers=1',
|
||||
'--',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1',
|
||||
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
if (!testProcess.pid) {
|
||||
throw new Error('Playwright did not return a PID')
|
||||
}
|
||||
evidence.playwrightPid = testProcess.pid
|
||||
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
|
||||
testExitCode = await waitForExit(testProcess)
|
||||
} finally {
|
||||
if (ibusProcess?.pid) {
|
||||
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
|
||||
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
|
||||
}
|
||||
if (windowManagerProcess?.pid) {
|
||||
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
|
||||
windowManagerProcess.pid
|
||||
)
|
||||
}
|
||||
closeSync(ibusLogFd)
|
||||
closeSync(windowManagerLogFd)
|
||||
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
|
||||
copyFileSync(
|
||||
ibusLogPath,
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
|
||||
)
|
||||
copyFileSync(
|
||||
windowManagerLogPath,
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
|
||||
`${JSON.stringify(evidence, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (evidence.ibusGroupAfterCleanup.length > 0) {
|
||||
throw new Error(
|
||||
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
|
||||
)
|
||||
}
|
||||
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
|
||||
throw new Error(
|
||||
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
|
||||
)
|
||||
}
|
||||
return testExitCode
|
||||
}
|
||||
|
||||
async function runOuter() {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
|
||||
}
|
||||
|
||||
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
|
||||
const runtimeDir = path.join(evidenceDir, 'runtime')
|
||||
mkdirSync(runtimeDir, { mode: 0o700 })
|
||||
mkdirSync(path.join(evidenceDir, 'config'))
|
||||
mkdirSync(path.join(evidenceDir, 'cache'))
|
||||
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
|
||||
|
||||
const sessionProcess = spawn(
|
||||
'xvfb-run',
|
||||
[
|
||||
'--auto-servernum',
|
||||
'dbus-run-session',
|
||||
'--',
|
||||
process.execPath,
|
||||
scriptPath,
|
||||
insideSessionFlag,
|
||||
evidenceDir
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
GTK_IM_MODULE: 'ibus',
|
||||
IBUS_ENABLE_SYNC_MODE: '1',
|
||||
LANG: process.env.LANG || 'C.UTF-8',
|
||||
QT_IM_MODULE: 'ibus',
|
||||
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
|
||||
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
|
||||
XDG_RUNTIME_DIR: runtimeDir,
|
||||
XMODIFIERS: '@im=ibus'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
if (!sessionProcess.pid) {
|
||||
throw new Error('xvfb-run did not return a PID')
|
||||
}
|
||||
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
|
||||
const exitCode = await waitForExit(sessionProcess)
|
||||
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
|
||||
if (remaining.length > 0) {
|
||||
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
const insideSession = process.argv[2] === insideSessionFlag
|
||||
try {
|
||||
if (insideSession && !process.argv[3]) {
|
||||
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
|
||||
}
|
||||
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
|
||||
} catch (error) {
|
||||
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('terminal IME e2e workflow', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
it('runs for xterm patch and terminal IME regression changes', () => {
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-ime-*',
|
||||
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
|
||||
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
|
||||
'tests/e2e/terminal-ime-*.ts'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('installs native IBus Hangul and X11 input tools', () => {
|
||||
const runs = workflow.jobs['linux-x11'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const installRun = runs.find((run) => run.includes('apt-get install'))
|
||||
|
||||
expect(installRun).toBeDefined()
|
||||
expect(installRun).toContain('ibus-hangul')
|
||||
expect(installRun).toContain('xdotool')
|
||||
expect(installRun).toContain('xfwm4')
|
||||
expect(installRun).toContain('xvfb')
|
||||
expect(installRun).toContain('dbus-x11')
|
||||
expect(installRun).toContain('dconf-gsettings-backend')
|
||||
expect(installRun).toContain('libglib2.0-bin')
|
||||
})
|
||||
|
||||
it('runs deterministic boundaries before the real IBus suite', () => {
|
||||
const runs = workflow.jobs['linux-x11'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const deterministicIndex = runs.findIndex((run) =>
|
||||
run.includes('terminal-ime-exact-byte.spec.ts')
|
||||
)
|
||||
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
|
||||
|
||||
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
|
||||
})
|
||||
|
||||
it('keeps IBus lifecycle scoped to owned processes', () => {
|
||||
const runner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(runner).toContain(
|
||||
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
|
||||
)
|
||||
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
|
||||
expect(runner).toContain("['initial-input-mode', 'hangul']")
|
||||
expect(runner).toContain("['hangul-keyboard', '2']")
|
||||
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
|
||||
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
|
||||
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
|
||||
expect(runner).toMatch(
|
||||
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
|
||||
)
|
||||
expect(runner).not.toContain("'--replace'")
|
||||
expect(runner).not.toContain('killall')
|
||||
expect(runner).not.toContain('pkill')
|
||||
})
|
||||
|
||||
it('bounds blocking native input commands', () => {
|
||||
const nativeSpec = readFileSync(
|
||||
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -227,44 +227,6 @@ function setCatalogEntry(catalog, key, value) {
|
||||
cursor[parts.at(-1)] = value
|
||||
}
|
||||
|
||||
function deleteCatalogEntry(catalog, key) {
|
||||
const parts = key.split('.')
|
||||
const stack = []
|
||||
let cursor = catalog
|
||||
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (
|
||||
typeof cursor?.[part] !== 'object' ||
|
||||
cursor[part] === null ||
|
||||
Array.isArray(cursor[part])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
stack.push([cursor, part])
|
||||
cursor = cursor[part]
|
||||
}
|
||||
|
||||
const leafKey = parts.at(-1)
|
||||
if (!Object.hasOwn(cursor, leafKey)) {
|
||||
return false
|
||||
}
|
||||
|
||||
delete cursor[leafKey]
|
||||
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
||||
const [parent, part] = stack[index]
|
||||
const child = parent[part]
|
||||
if (
|
||||
typeof child === 'object' &&
|
||||
child !== null &&
|
||||
!Array.isArray(child) &&
|
||||
Object.keys(child).length === 0
|
||||
) {
|
||||
delete parent[part]
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectLocaleParityIssues(enCatalog, localeCatalog) {
|
||||
const enEntries = flattenCatalogEntries(enCatalog)
|
||||
const localeEntries = flattenCatalogEntries(localeCatalog)
|
||||
@@ -286,30 +248,6 @@ function collectLocaleParityIssues(enCatalog, localeCatalog) {
|
||||
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
|
||||
}
|
||||
|
||||
function repairLocaleParity(enCatalog, localeCatalog) {
|
||||
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
let changed = 0
|
||||
|
||||
for (const key of missingInLocale) {
|
||||
setCatalogEntry(localeCatalog, key, enEntries.get(key))
|
||||
changed += 1
|
||||
}
|
||||
|
||||
for (const key of extraInLocale) {
|
||||
if (deleteCatalogEntry(localeCatalog, key)) {
|
||||
changed += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of interpolationMismatches) {
|
||||
setCatalogEntry(localeCatalog, key, enEntries.get(key))
|
||||
changed += 1
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function referencesMissingFallbacks(missing) {
|
||||
return missing.filter((reference) => typeof reference.fallback !== 'string')
|
||||
}
|
||||
@@ -344,23 +282,18 @@ function applyMissingEnglishEntries(catalog, missing) {
|
||||
return changed
|
||||
}
|
||||
|
||||
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
||||
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
|
||||
if (
|
||||
missingInLocale.length > 0 ||
|
||||
extraInLocale.length > 0 ||
|
||||
interpolationMismatches.length > 0
|
||||
) {
|
||||
console.error(`Locale catalog parity failed for ${localeName}.json.`)
|
||||
if (missingInLocale.length > 0) {
|
||||
console.error('')
|
||||
console.error(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
|
||||
if (missingInLocale.length > 20) {
|
||||
console.error(`...and ${missingInLocale.length - 20} more missing keys`)
|
||||
}
|
||||
}
|
||||
// Why: feature PRs own English declarations; absent target leaves deliberately
|
||||
// use i18next's existing English fallback until a localization PR supplies them.
|
||||
console.log(
|
||||
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
|
||||
)
|
||||
|
||||
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
|
||||
console.error(`Locale catalog validation failed for ${localeName}.json.`)
|
||||
if (extraInLocale.length > 0) {
|
||||
console.error('')
|
||||
console.error(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
|
||||
@@ -380,7 +313,7 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
||||
return 1
|
||||
}
|
||||
|
||||
console.log(`Verified locale parity for ${localeName}.json (${localeEntries.size} keys).`)
|
||||
console.log(`Verified ${localeEntries.size} existing ${localeName}.json entries.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -527,19 +460,10 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
|
||||
const localeName = fileName.replace(/\.json$/, '')
|
||||
const localeCatalogPath = path.join(localesDir, fileName)
|
||||
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
|
||||
if (options.fix) {
|
||||
const repaired = repairLocaleParity(catalog, localeCatalog)
|
||||
if (repaired > 0) {
|
||||
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
|
||||
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
|
||||
}
|
||||
}
|
||||
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
|
||||
const exitCode = verifyLocaleCatalog(catalog, localeName, localeCatalog)
|
||||
if (exitCode !== 0) {
|
||||
if (!options.fix) {
|
||||
console.error('')
|
||||
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
|
||||
}
|
||||
console.error('')
|
||||
console.error('Fix or retire the existing target entry in a localization PR.')
|
||||
return exitCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
|
||||
}
|
||||
|
||||
describe('verify-localization-catalog', () => {
|
||||
it('bootstraps missing catalog entries from string fallbacks', async () => {
|
||||
it('bootstraps English entries without fabricating target translations', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
|
||||
@@ -45,12 +45,10 @@ describe('verify-localization-catalog', () => {
|
||||
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
})
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
})
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({})
|
||||
})
|
||||
|
||||
it('repairs stale locale keys and interpolation mismatches', async () => {
|
||||
it('never overwrites mismatched translations or removes target-only entries', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
|
||||
@@ -63,13 +61,29 @@ describe('verify-localization-catalog', () => {
|
||||
}
|
||||
})
|
||||
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
|
||||
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
auto: {
|
||||
example: { greeting: 'Hola' },
|
||||
stale: { removed: 'Viejo' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts sparse target catalogs when existing placeholders match', async () => {
|
||||
const { root } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
|
||||
enCatalog: {
|
||||
auto: { example: { greeting: 'Hello {{name}}', untranslated: 'English only' } }
|
||||
},
|
||||
esCatalog: { auto: { example: { greeting: 'Hola {{name}}' } } }
|
||||
})
|
||||
|
||||
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
|
||||
})
|
||||
|
||||
it('does not invent values for keys without string fallbacks', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
|
||||
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
||||
|
||||
function flattenCatalog(value, prefix = '', entries = new Map()) {
|
||||
if (typeof value === 'string') {
|
||||
entries.set(prefix, value)
|
||||
return entries
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return entries
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
|
||||
}
|
||||
|
||||
export function compareExtraction(extractedCatalog, englishCatalog) {
|
||||
const extracted = flattenCatalog(extractedCatalog)
|
||||
const english = flattenCatalog(englishCatalog)
|
||||
const dynamicDefaults = [...extracted.entries()]
|
||||
.filter(([, value]) => value.length === 0)
|
||||
.map(([key]) => key)
|
||||
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
|
||||
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
|
||||
const fallbackDrift = []
|
||||
const placeholderMismatches = []
|
||||
|
||||
for (const [key, extractedValue] of extracted) {
|
||||
const englishValue = english.get(key)
|
||||
if (
|
||||
extractedValue.length === 0 ||
|
||||
englishValue === undefined ||
|
||||
englishValue === extractedValue
|
||||
) {
|
||||
continue
|
||||
}
|
||||
fallbackDrift.push(key)
|
||||
if (placeholders(extractedValue) !== placeholders(englishValue)) {
|
||||
placeholderMismatches.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
extracted,
|
||||
dynamicDefaults,
|
||||
missingFromEnglish,
|
||||
orphans,
|
||||
fallbackDrift,
|
||||
placeholderMismatches
|
||||
}
|
||||
}
|
||||
|
||||
function printKeys(label, keys) {
|
||||
if (keys.length === 0) {
|
||||
return
|
||||
}
|
||||
console.error(`${label}:`)
|
||||
for (const key of keys.slice(0, 20)) {
|
||||
console.error(` ${key}`)
|
||||
}
|
||||
if (keys.length > 20) {
|
||||
console.error(` ...and ${keys.length - 20} more`)
|
||||
}
|
||||
}
|
||||
|
||||
async function extractToTemporaryCatalog(root, tempDir) {
|
||||
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
|
||||
const outputPattern = path.join(tempDir, '{{language}}.json')
|
||||
// Why: extraction output is evidence for this check, not another committed
|
||||
// catalog that feature authors must keep synchronized.
|
||||
await execFileAsync(process.execPath, [cliPath, 'extract', '--sync-primary', '--quiet'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
||||
}
|
||||
})
|
||||
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
|
||||
|
||||
try {
|
||||
const [extractedCatalog, englishCatalog] = await Promise.all([
|
||||
extractToTemporaryCatalog(root, tempDir),
|
||||
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse)
|
||||
])
|
||||
const result = compareExtraction(extractedCatalog, englishCatalog)
|
||||
|
||||
console.log(
|
||||
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced, and ${result.fallbackDrift.length} inline defaults differ.`
|
||||
)
|
||||
|
||||
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
|
||||
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
|
||||
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { compareExtraction } from './verify-localization-extraction.mjs'
|
||||
|
||||
describe('verify-localization-extraction', () => {
|
||||
it('reports legacy drift without requiring a committed disposition database', () => {
|
||||
const result = compareExtraction(
|
||||
{ menu: { open: 'Open now {{name}}' } },
|
||||
{ menu: { open: 'Open {{name}}', legacy: 'Legacy copy' } }
|
||||
)
|
||||
|
||||
expect(result.orphans).toEqual(['menu.legacy'])
|
||||
expect(result.fallbackDrift).toEqual(['menu.open'])
|
||||
expect(result.placeholderMismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('reports dynamic defaults without treating empty extractor values as catalog copy', () => {
|
||||
const result = compareExtraction(
|
||||
{ menu: { dynamic: '' } },
|
||||
{ menu: { dynamic: '{{count}} items' } }
|
||||
)
|
||||
|
||||
expect(result.dynamicDefaults).toEqual(['menu.dynamic'])
|
||||
expect(result.fallbackDrift).toEqual([])
|
||||
expect(result.placeholderMismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects undeclared keys even when their defaults are dynamic', () => {
|
||||
const result = compareExtraction({ menu: { dynamic: '' } }, {})
|
||||
|
||||
expect(result.missingFromEnglish).toEqual(['menu.dynamic'])
|
||||
})
|
||||
|
||||
it('rejects missing English declarations and incompatible placeholders', () => {
|
||||
const result = compareExtraction(
|
||||
{
|
||||
menu: {
|
||||
missing: 'Missing',
|
||||
open: 'Open {{name}}'
|
||||
}
|
||||
},
|
||||
{ menu: { open: 'Open {{path}}' } }
|
||||
)
|
||||
|
||||
expect(result.missingFromEnglish).toEqual(['menu.missing'])
|
||||
expect(result.placeholderMismatches).toEqual(['menu.open'])
|
||||
})
|
||||
})
|
||||
@@ -9,12 +9,17 @@
|
||||
"../src/main/agent-hooks/hooks-json-read.ts",
|
||||
"../src/main/agent-hooks/installer-utils.ts",
|
||||
"../src/main/agent-hooks/installer-utils-remote.ts",
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/ipc/local-agent-install-dir-detection.ts",
|
||||
"../src/main/ipc/tui-agent-detection-commands.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
"../src/main/claude/hook-service.ts",
|
||||
"../src/main/claude/statusline-script.ts",
|
||||
"../src/main/claude-accounts/keychain.ts",
|
||||
"../src/main/codex/codex-app-server-capability-cache.ts",
|
||||
"../src/main/codex/codex-app-server-capability-signal.ts",
|
||||
"../src/main/codex/codex-app-server-client.ts",
|
||||
@@ -68,6 +73,7 @@
|
||||
"../src/main/kimi/kimi-hook-config-toml.ts",
|
||||
"../src/main/openclaude/hook-service.ts",
|
||||
"../src/main/rolling-file-backup.ts",
|
||||
"../src/main/startup/hydrate-shell-path.ts",
|
||||
"../src/main/runtime/runtime-metadata.ts",
|
||||
"../src/main/win32-utils.ts"
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"../electron.vite.config.*",
|
||||
"../build-plugins/**/*",
|
||||
"../src/main/**/*",
|
||||
"../src/renderer/src/lib/skill-freshness-display-status.ts",
|
||||
"../src/preload/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/relay/**/*",
|
||||
|
||||
@@ -15,6 +15,8 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 11m">
|
||||
<title>downloads: 11m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 12m">
|
||||
<title>downloads: 12m</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">11m</text>
|
||||
<text x="90" y="14">11m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">12m</text>
|
||||
<text x="90" y="14">12m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
File diff suppressed because it is too large
Load Diff
@@ -729,6 +729,69 @@ deliberately. The post-upgrade binary and version record are retained in
|
||||
artifacts and remove them according to your retention policy after the rollback
|
||||
is resolved.
|
||||
|
||||
## Installing Agent Skills Without A Desktop
|
||||
|
||||
Orca's agent skills (CLI usage, orchestration, computer use, etc.) are normally
|
||||
installed from Orca Settings, which pre-fills an `npx skills add ... --global`
|
||||
command in a terminal for you to run. A headless host has no Settings UI, so
|
||||
use `orca skills install` instead:
|
||||
|
||||
```bash
|
||||
orca skills install # list installable skills
|
||||
orca skills install --skill orca-cli --skill orchestration # install globally (default)
|
||||
orca skills install --skill orca-cli --local # install into the current project only
|
||||
orca skills install --all # install every bundled skill
|
||||
orca skills install --all --dry-run # print the npx command without running it
|
||||
```
|
||||
|
||||
This resolves the same `npx skills add <repo> --skill <name> ...` command
|
||||
Settings would show you (adding `--global` unless `--local` is passed), then
|
||||
runs it and forwards its output and exit code. It requires `node`/`npx` on the
|
||||
host; it does not need a running Orca runtime.
|
||||
|
||||
Unlike the command Settings shows, the spawned one adds `npx --yes` and `-y`.
|
||||
Without them the `skills` CLI opens an interactive agent picker and blocks
|
||||
forever on any allocated TTY — which includes a normal `ssh` session. Use
|
||||
`--dry-run` to see the exact command that will run.
|
||||
|
||||
Settings keeps that picker deliberately, because choosing which agents get a
|
||||
skill is a real decision. A headless run cannot answer it, so instead of dropping
|
||||
the choice Orca makes it explicitly: it passes an `--agent` list built from the
|
||||
coding agents it detects on the host, plus the shared `.agents/skills` directory
|
||||
it reads itself. Left to decide on its own with no agent detected, the `skills`
|
||||
CLI installs into all ~75 agents it knows and leaves a config directory for each.
|
||||
Override the targets yourself, or narrow to the shared directory alone:
|
||||
|
||||
```bash
|
||||
orca skills install --skill orca-cli --agent claude-code,codex
|
||||
orca skills install --skill orca-cli --agent universal
|
||||
```
|
||||
|
||||
If Orca detects no agent at all, `orca skills install` stops and asks for
|
||||
`--agent` rather than guessing.
|
||||
|
||||
To refresh already-installed skills, `orca skills update` mirrors the same
|
||||
selection flags (`--skill`, `--all`, `--local`, `--dry-run`) and resolves to
|
||||
`npx skills update <names...>` with a matching scope flag — `--global`, or
|
||||
`--project` when you pass `--local`:
|
||||
|
||||
```bash
|
||||
orca skills update --all # update every bundled skill globally
|
||||
orca skills update --skill orca-cli --dry-run # print the npx command without running it
|
||||
```
|
||||
|
||||
`orca skills update` only refreshes skills that are already installed — it exits
|
||||
0 without doing anything for a skill that is missing, so install it first. More
|
||||
generally, a 0 exit means the `skills` CLI ran without erroring, not that it
|
||||
wrote anything; read its output to confirm what changed.
|
||||
|
||||
`--json` covers the skill listing and `--dry-run`. A real run streams the
|
||||
`skills` CLI's own non-JSON output and rejects `--json`.
|
||||
|
||||
Both commands install onto the machine that runs them. In an Orca SSH workspace
|
||||
or the WSL bridge the `orca` shim forwards commands to the Orca host, so they
|
||||
refuse to run there and print the command to run on the machine you want.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `dlopen(): error loading libfuse.so.2`: install `libfuse2`.
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# STA-2694 — garbled terminal after switching away from an AI workspace
|
||||
|
||||
Handoff snapshot: 2026-07-27, branch
|
||||
`neil/sta-2694-fix-ui-rendering-artifacts-when-switching-away-from-ai`.
|
||||
|
||||
## The report
|
||||
|
||||
> When I use an AI tool—such as OpenCode—and switch away to the desktop or
|
||||
> another task, the page appears garbled or distorted upon returning; I have to
|
||||
> resize the window to restore the display.
|
||||
|
||||
Reporter hints: related to terminal parking / workspace switching; also happens
|
||||
with Claude Code and probably grok.
|
||||
|
||||
## Status
|
||||
|
||||
**One defect, root-caused and fixed, with a mechanism-level test that fails
|
||||
without the fix.** The garble window is *unbounded*, not the ~1s I originally
|
||||
claimed — see [the correction](#correction-the-window-is-unbounded).
|
||||
|
||||
A second hypothesis was implemented, then **refuted by measurement and
|
||||
reverted**. That refutation is the other half of the value here: it rules out a
|
||||
whole class of "just force a fuller repaint" fixes, and it is pinned by a test so
|
||||
it cannot be reintroduced.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Fix | `ed1eaf55f1` — release an abandoned synchronized-output frame on reveal |
|
||||
| Refuted + reverted | `0f7ec4458d`, reverted in `8d5eacecb4` |
|
||||
| Oracle + scope correction | `3ccffc17ec` |
|
||||
| Unit tests | 8 passing across 2 files |
|
||||
| e2e | 5 draw-command oracle + 10 headless convergence + 1 headful |
|
||||
| Teeth-verified | Yes — removing the fix fails the stranded-latch test |
|
||||
|
||||
## The defect
|
||||
|
||||
Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint in
|
||||
DEC 2026 synchronized output — `\x1b[?2026h … \x1b[?2026l` — many times a second.
|
||||
Hide a pane mid-bracket (a worktree switch, a cold park, or an OS-level occlusion
|
||||
lands there routinely) and xterm keeps
|
||||
`decPrivateModes.synchronizedOutput` latched `true`.
|
||||
|
||||
`RenderService.refreshRows` checks that latch **before** rendering
|
||||
(`RenderService.ts:162`), so while it holds, every repaint Orca owns is a no-op:
|
||||
the forced render-pause repaint, the plain `refresh()` fallback, and the shared
|
||||
glyph-atlas rebuild all render zero rows against a perfectly correct buffer.
|
||||
|
||||
The fix (`terminal-synchronized-output-release.ts`) clears the latch and flushes
|
||||
the handler's buffered row range at both reveal repaint entry points, before the
|
||||
repaint. Both reveal paths reach it:
|
||||
|
||||
- `schedulePaneRevealPresent` (plain refocus / desktop return)
|
||||
- `resetWebglTextureAtlas` (worktree switch, cold park, tab reveal — also reached
|
||||
from `resetAndRefreshAllTerminalWebglAtlases` via the light tab-resume path)
|
||||
|
||||
### Correction: the window is unbounded
|
||||
|
||||
`ed1eaf55f1`'s commit message says this "closes a bounded window rather than the
|
||||
whole STA-2694 report", because xterm arms a 1s watchdog that clears the latch.
|
||||
**That was wrong.** The watchdog is armed only inside
|
||||
`SynchronizedOutputHandler.bufferRows`, and `refreshRows` returns at its
|
||||
`_isPaused` check *first*:
|
||||
|
||||
```ts
|
||||
public refreshRows(start, end, sync = false, isRedrawOnly = false): void {
|
||||
if (this._isPaused) { this._needsFullRefresh = true; return } // ← occluded pane stops here
|
||||
if (this._coreService.decPrivateModes.synchronizedOutput) {
|
||||
this._syncOutputHandler.bufferRows(start, end) // ← only place the watchdog arms
|
||||
return
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
While a pane is occluded its `IntersectionObserver` sets `_isPaused`, so nothing
|
||||
ever reaches `bufferRows` and **no timer is ever pending**. A pane hidden
|
||||
mid-frame holds the latch with no watchdog behind it, indefinitely — which is the
|
||||
indefinite garble the report describes. `terminal-reveal-draw-command-probe.spec.ts`
|
||||
asserts exactly this: latch set, `_timeout === undefined`, and a repaint drawing
|
||||
0 instances.
|
||||
|
||||
This also explains the specific workaround: resizing the window makes the TUI
|
||||
repaint via SIGWINCH, and that repaint's closing `?2026l` clears the latch.
|
||||
|
||||
## The refuted hypothesis (do not reimplement)
|
||||
|
||||
The idea: xterm's renderers are diff-based, so a reveal `refresh()` skips cells
|
||||
whose model entry still matches the buffer, leaving an occluded canvas showing
|
||||
pre-hide pixels until a resize rebuilds the model. It is a *plausible* reading —
|
||||
`WebglRenderer._updateModel` really does early-continue per unchanged cell.
|
||||
|
||||
It is wrong, measured on a live pane:
|
||||
|
||||
| Refresh | `updateCell` calls | instances drawn |
|
||||
|---|---|---|
|
||||
| diff-skipped (model matches buffer) | 0 | 562 |
|
||||
| model cleared first | 561 | 562 |
|
||||
|
||||
`GlyphRenderer.render` copies vertices for **every** row up to `lineLengths[y]`
|
||||
into the active buffer and issues **one** full-viewport `drawElementsInstanced`.
|
||||
The diff only decides how much *vertex data* is rewritten, never how much is
|
||||
*drawn*. The DOM renderer likewise `replaceChildren()`s every row
|
||||
unconditionally. So clearing the model cannot change what reaches the screen.
|
||||
|
||||
Worse, it has a cost: `_clearModel(true)` zeroes every glyph vertex, and
|
||||
`RenderService.clear()` fires no repaint of its own — so any paint landing between
|
||||
the clear and the repopulating refresh draws an **empty** viewport (0 instances,
|
||||
also asserted in the spec). On a reveal that refresh is debounced through a RAF
|
||||
and can itself be swallowed, which would turn a merely-stale pane into a blank
|
||||
one. Reverted in `8d5eacecb4`.
|
||||
|
||||
The first test in the oracle spec pins the refutation so this cannot come back as
|
||||
a "fix".
|
||||
|
||||
## Why pixel oracles failed, and what replaced them
|
||||
|
||||
The field defect is "the buffer is correct but the compositor shows pre-hide
|
||||
pixels". Two pixel oracles were built and **both were proven blind** by injecting
|
||||
that exact defect (freeze `RenderService.refreshRows`, then write new content):
|
||||
|
||||
1. **Canvas-vs-buffer ink sampling.** `drawImage` on a
|
||||
non-`preserveDrawingBuffer` WebGL canvas hands back a *re-rendered* copy — it
|
||||
reported 0 missing cells against 5263 cells of text the canvas had never
|
||||
drawn.
|
||||
2. **Screenshot vs. a forced repaint.** Playwright's screenshot drives a fresh
|
||||
compositor frame, which *heals* the stale paint before capture; and the
|
||||
"repair" ran the same code the reveal already ran, so a shared defect cancels
|
||||
out.
|
||||
|
||||
An earlier resize-referenced oracle was also unsound: resizing shifts alt-screen
|
||||
rows, so its 4.4% pixel diff measured legitimate reflow.
|
||||
|
||||
**Pixels are the wrong layer** — anything that reads them can trigger the repaint
|
||||
that hides the bug. `tests/e2e/terminal-reveal-draw-command-probe.spec.ts`
|
||||
instead wraps `GlyphRenderer.updateCell` and `gl.drawElementsInstanced` on the
|
||||
live pane and counts draw commands. A draw command cannot be healed after the
|
||||
fact, so "did the reveal repaint?" is directly observable — and falsifiable,
|
||||
which is how the second hypothesis got refuted.
|
||||
|
||||
Use that spec for **paint** questions;
|
||||
`terminal-opencode-altscreen-reveal-artifacts.spec.ts` for **buffer, geometry and
|
||||
PTY-size convergence** (worktree switch, cold park, idle agent, headful desktop
|
||||
hide). The idle-agent case matters: every earlier test kept the TUI streaming
|
||||
across the reveal, so live frames repainted whatever the reveal got wrong and the
|
||||
defect healed itself before any assertion ran.
|
||||
|
||||
## Leads closed by measurement
|
||||
|
||||
Both were live suspects in the previous handoff; both are now ruled out.
|
||||
|
||||
- **Dimension staleness.** Every dimension-rebuilding path in `WebglRenderer` is
|
||||
behind an unchanged/zero-sized early exit that a hidden pane trips, and
|
||||
`performSafeFit` early-returns at unchanged geometry — so nothing on the reveal
|
||||
path re-runs `handleResize()`. Measured across a real `window.hide()` /
|
||||
`show()`: canvas backing store `1272×1104` matched the renderer's computed
|
||||
dimensions exactly, char metrics stayed `8.65×16`, `_isAttached` stayed true,
|
||||
the screen element stayed connected. No staleness.
|
||||
- **Lagging atlas page bindings.** Right after a reveal the bound texture version
|
||||
*does* lag the atlas page (e.g. 1297 vs 1366) — but bindings are lazy and
|
||||
`GlyphRenderer.render` rebinds any page whose version moved, so one repaint
|
||||
brings them level (1366/1366). Normal, not a defect. Judge bindings only
|
||||
*after* a draw.
|
||||
|
||||
## Residual risk
|
||||
|
||||
The oracle proves the repaint is *issued*; it does not photograph the user's
|
||||
screen. If a report survives this fix, the remaining suspects are below the draw
|
||||
call — GPU-process/compositor-level content loss — where the in-app sentinel on
|
||||
real hardware is the tool:
|
||||
|
||||
```js
|
||||
localStorage.setItem('orca:render-desync-sentinel', '1') // then reload
|
||||
```
|
||||
|
||||
Reproduce, then **⌘-click** (Ctrl-click off Mac) in the garbled pane. That starts
|
||||
a 10s burst at 250ms intervals; a trip needs the same cells missing across 2
|
||||
consecutive samples, ≥200 text cells, ≥8% missing. It writes `corrupt.png` +
|
||||
`corrupt.json` to `<userData>/terminal-render-desync-evidence/<captureId>/`, then
|
||||
runs atlas recovery and captures the healed frame. Armed at import when the flag
|
||||
is set (`terminal-freeze-breadcrumbs.ts:45`).
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
npx vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/
|
||||
npx tsc --noEmit -p config/tsconfig.tc.web.json
|
||||
npx playwright test tests/e2e/terminal-reveal-draw-command-probe.spec.ts \
|
||||
tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
|
||||
tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts \
|
||||
--config tests/playwright.config.ts --project=electron-headless --workers=1
|
||||
SKIP_BUILD=1 npx playwright test tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
|
||||
--config tests/playwright.config.ts --project=electron-headful --workers=1
|
||||
```
|
||||
|
||||
To confirm the fix still has teeth, delete the
|
||||
`releaseAbandonedSynchronizedOutput(pane.terminal)` line from
|
||||
`schedulePaneRevealPresent` and re-run the oracle: the stranded-latch test fails
|
||||
with "the reveal repaint did not release the stranded latch, so the pane stays
|
||||
frozen".
|
||||
|
||||
## Files
|
||||
|
||||
Production:
|
||||
|
||||
- `src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts` (the fix)
|
||||
- `src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts` (`schedulePaneRevealPresent`)
|
||||
- `src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts` (`resetWebglTextureAtlas`)
|
||||
|
||||
Tests:
|
||||
|
||||
- `terminal-synchronized-output-release.test.ts`, `reveal-repaint-synchronized-output.test.ts`
|
||||
- `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` (the oracle)
|
||||
- `tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts`,
|
||||
`tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs`
|
||||
@@ -0,0 +1,576 @@
|
||||
# Preserve active orchestration work across contract updates
|
||||
|
||||
Status: implementation validation
|
||||
|
||||
Date: 2026-07-28
|
||||
|
||||
## Decision
|
||||
|
||||
Do not block updates, restart active workers, or revive the retired scheduler.
|
||||
|
||||
On first launch after the contract update, atomically adopt the durable
|
||||
pre-Run graph into one ordinary lightweight Run. Preserve every Task,
|
||||
Dispatch, message, gate, terminal, PTY, process, worktree, and prompt
|
||||
identifier. Mark only the pre-update Dispatch attempts as legacy-contract
|
||||
attempts and accept the exact command shapes already pasted into those
|
||||
workers through a narrow adapter.
|
||||
|
||||
This is state migration plus a per-Dispatch protocol adapter. It is not a
|
||||
second scheduler:
|
||||
|
||||
- the migrated Run owns the preserved graph and coordinator inbox;
|
||||
- legacy attempts keep their original protocol until they settle;
|
||||
- every new attempt uses the current protocol, even on a preserved Task; and
|
||||
- no placement, polling, concurrency, or deadline loop is resurrected.
|
||||
|
||||
## User-visible outcome
|
||||
|
||||
- The update installs immediately.
|
||||
- Existing coordinator and worker terminals keep the same process, PTY,
|
||||
worktree, prompt, Task ID, Dispatch ID, and history.
|
||||
- Orca does not inject text, focus a tab, or start a replacement editor.
|
||||
- The legacy graph appears in an automatically adopted, normal Run instead of
|
||||
being stranded in `run_legacy_local`.
|
||||
- An original live worker with retained launch proof can keep using the
|
||||
`heartbeat`, `worker_done`, `escalation`, `ask`, and `check` commands in its
|
||||
existing prompt.
|
||||
- An original live coordinator can keep listing and settling preserved Tasks,
|
||||
dispatch remaining work with current commands, check direct legacy mail, and
|
||||
use the pre-cutover `reply` CLI guidance.
|
||||
- Unprovable, stale, ambiguous, or already-settled rows remain inspectable and
|
||||
unmistakably read-only. They never advertise Reply or Ack actions.
|
||||
- A pre-update WSL process whose older launcher did not propagate the hidden
|
||||
token remains lifecycle read-only. Its assignment, process, terminal, and
|
||||
filesystem work remain valid and inspectable.
|
||||
|
||||
The retired `orchestration run`, `coordinator-start`, and `coordinator-stop`
|
||||
scheduler commands remain retired. Their in-memory loop cannot be reconstructed
|
||||
truthfully. The coordinator agent and all durable work remain accessible; it
|
||||
explicitly dispatches any ready work that was never assigned before the update.
|
||||
|
||||
## Evidence and constraints
|
||||
|
||||
- Schema v7 preserved old `messages`, `tasks`, `dispatch_contexts`, and
|
||||
`decision_gates` by assigning them to `run_legacy_local`.
|
||||
- The exact prompt at `8b154d686` used old `send`, `ask`, and consuming
|
||||
`check` shapes. It omitted `worker_done --outcome`; a subject equal to
|
||||
`Failed` or beginning `Failed:` represented failure. Its escalation carried
|
||||
`taskId` but no `dispatchId`.
|
||||
- `reply` existed in the old CLI and message formatter guidance, not in that
|
||||
pinned worker prompt.
|
||||
- Restored `OrcaRuntime` PTY records currently set `launchToken: null`, and
|
||||
provider `listProcesses()` does not expose the launch token. Handles and
|
||||
pane IDs alone therefore cannot authorize compatibility.
|
||||
- Managed hooks source the owner-only endpoint file on every invocation, so an
|
||||
old process reaches the new hook server after restart. The hook cache retains
|
||||
the prior authenticated pane/launch-token association and distinguishes
|
||||
hydrated rows from events observed by the current runtime.
|
||||
- Older builds did not propagate `ORCA_AGENT_LAUNCH_TOKEN` through `WSLENV`, and
|
||||
a running process cannot acquire it retroactively.
|
||||
- The CLI transport mutation receipt is keyed by rotating runtime
|
||||
authentication. It cannot deduplicate a legacy mutation across restart.
|
||||
- Current invalid-capability lifecycle sends deliberately remain as auditable
|
||||
rejected messages. Compatibility must not silently change that behavior.
|
||||
- PR #11142 fixes background-tab materialization for newly dispatched workers.
|
||||
It does not recover a pre-fix worker whose PTY survived an update after its
|
||||
renderer tab binding was lost; workspace activation can otherwise mistake
|
||||
that worker for a sleeping provider session and launch `codex resume`.
|
||||
- The packaged Windows launcher keeps its Electron-as-Node child alive for the
|
||||
lifetime of a blocking CLI command. A seamless design cannot assume that a
|
||||
bounded RPC wait releases the installed executable.
|
||||
|
||||
## Non-negotiable invariants
|
||||
|
||||
1. Adoption never spawns, writes, signals, stops, focuses, or injects a
|
||||
terminal and never mutates the assigned filesystem. It may restore one
|
||||
persisted background tab/leaf binding for the exact already-live PTY.
|
||||
2. Every durable row and identifier survives adoption; only its owning Run and
|
||||
explicit protocol metadata change.
|
||||
3. Compatibility is write-once per Dispatch. Migration never classifies a
|
||||
current retry as legacy.
|
||||
4. A legacy mutation requires one uniquely resolved legacy attempt or
|
||||
coordinator principal plus current live proof of its original process tree.
|
||||
5. Caller-supplied handles, pane keys, Task IDs, Dispatch IDs, or remote
|
||||
attachments never create authority.
|
||||
6. Current Dispatch behavior remains unchanged, including auditable rejected
|
||||
lifecycle messages, capabilities, process incarnation, Run Delivery, and
|
||||
question semantics.
|
||||
7. Legacy direct mail and current Run Delivery are disjoint, even when their
|
||||
Dispatches share one migrated Task and Run.
|
||||
8. A B-era disconnect may replay accepted work; it may not lose mail or create
|
||||
a second question, reply, completion, or settlement. A cut after an A-era
|
||||
ask was already answered is explicitly ambiguous because A persisted no
|
||||
invocation identity; recovery replays its answer through legacy check before
|
||||
a same-text question is asked again.
|
||||
9. Different reply bodies for the same source and principal conflict instead
|
||||
of creating multiple replies.
|
||||
10. Folder workspaces, SSH, WSL, remote runtimes, macOS, Linux, and Windows
|
||||
follow the same proof rules. Missing proof degrades to inspection without
|
||||
invalidating the retained assignment or filesystem work.
|
||||
|
||||
## Storage and atomic adoption
|
||||
|
||||
Raise the schema version and add:
|
||||
|
||||
```text
|
||||
dispatch_contexts.contract_version INTEGER NOT NULL
|
||||
dispatch_contexts.launch_token_hash TEXT
|
||||
messages.delivery_contract TEXT NOT NULL
|
||||
CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only'))
|
||||
legacy_adoptions(
|
||||
source_run_id PRIMARY KEY,
|
||||
adopted_run_id UNIQUE NOT NULL,
|
||||
scheduler_state_lost INTEGER NOT NULL,
|
||||
adopted_at
|
||||
)
|
||||
legacy_compatibility_principals(
|
||||
id PRIMARY KEY,
|
||||
run_id NOT NULL,
|
||||
dispatch_id,
|
||||
role CHECK(role IN ('worker', 'coordinator')),
|
||||
host_scope NOT NULL,
|
||||
terminal_handle NOT NULL,
|
||||
pane_key NOT NULL,
|
||||
launch_token_hash NOT NULL,
|
||||
process_incarnation,
|
||||
status CHECK(status IN ('committed', 'settled', 'revoked')),
|
||||
UNIQUE(role, run_id, dispatch_id)
|
||||
)
|
||||
legacy_operation_receipts(
|
||||
principal_id,
|
||||
operation_key,
|
||||
method,
|
||||
payload_hash,
|
||||
effect_id,
|
||||
response_json,
|
||||
completed_at,
|
||||
PRIMARY KEY(principal_id, operation_key)
|
||||
)
|
||||
legacy_mail_receipts(
|
||||
principal_id,
|
||||
message_id,
|
||||
acknowledged_at,
|
||||
PRIMARY KEY(principal_id, message_id)
|
||||
)
|
||||
```
|
||||
|
||||
The schema migration runs in one `BEGIN IMMEDIATE` transaction:
|
||||
|
||||
1. create a persisted random Run ID and `legacy_adoptions` marker if one does
|
||||
not already exist;
|
||||
2. create an ordinary `legacy = 0` Run with a recovery objective;
|
||||
3. move all `run_legacy_local` Tasks, Dispatches, decision gates, messages,
|
||||
and question threads to it without changing their IDs, sequence, status, or
|
||||
content;
|
||||
4. mark pre-existing direct mail `legacy_direct` and hard-cutover rejection
|
||||
rows `audit_only`;
|
||||
5. mark a Dispatch legacy only when it predates the contract column and lacks
|
||||
a current dispatch capability; and
|
||||
6. fence any impossible outstanding legacy Delivery, move retained Delivery
|
||||
history to the adopted Run, assert every Task/Dispatch/gate/message/question/
|
||||
Delivery Run ID agrees, and leave `run_legacy_local` as an empty read-only
|
||||
audit tombstone.
|
||||
|
||||
Every new Dispatch and message writes the current contract explicitly.
|
||||
Repeated startup, a partially initialized fixture, and WAL recovery reuse the
|
||||
persisted adoption marker. Reset and fresh-database paths cannot synthesize
|
||||
legacy authority.
|
||||
|
||||
## Durable principals and live authority
|
||||
|
||||
After the hook server and PTY inventory are ready, a one-shot adoption service
|
||||
may commit compatibility principals. It does not mutate terminal processes or
|
||||
input.
|
||||
|
||||
Before renderer hydration, legacy Dispatch evidence also fences automatic
|
||||
provider resume for the exact sleeping pane. Once the relevant local daemon or
|
||||
SSH relay has authoritative inventory, Orca intersects the durable handle,
|
||||
pane, PTY ID, PTY incarnation, workspace, and host owner. A unique live match
|
||||
is adopted into its original tab/leaf as inactive background state while the
|
||||
coordinator tab remains active. Missing, stale, reused, cross-host, or
|
||||
ambiguous evidence stays fenced and inspectable. Ordinary provider resume is
|
||||
unblocked only when authoritative inventory proves that exact PTY exited.
|
||||
|
||||
At hook-server startup, copy the validated hydrated status rows into an
|
||||
immutable main-process-only startup snapshot before the server listens or
|
||||
binds the new listener. Later live events update a separate current-runtime
|
||||
observation map and cannot overwrite the pre-update commitment source. A
|
||||
current event is preferred but is not required before the worker's first
|
||||
post-restart lifecycle command when controller inventory proves the exact
|
||||
original PTY and process incarnation are live.
|
||||
|
||||
For each candidate it resolves the union of:
|
||||
|
||||
- durable Dispatch assignee and pane;
|
||||
- equivalent-pane aliases;
|
||||
- remote attachment and host ownership;
|
||||
- coordinator candidates from active legacy coordinator rows, Task creator
|
||||
handles, Dispatch coordinator/parent provenance, and direct-message
|
||||
sender/recipient history;
|
||||
- the restored terminal inventory and process incarnation; and
|
||||
- the hydrated, previously authenticated hook pane/launch-token association.
|
||||
|
||||
The candidate commits only when that union identifies exactly one role and
|
||||
row. The orchestration database stores a SHA-256 token commitment, never the
|
||||
raw token. The existing owner-only hook status cache is an input to the
|
||||
one-time commitment, but a hydrated status is not enough to mutate.
|
||||
|
||||
Every compatibility call then requires:
|
||||
|
||||
- a hidden launch token and pane inherited by the calling process;
|
||||
- the immutable pre-update hook commitment, plus either a matching
|
||||
current-runtime hook event or exact current controller proof of the original
|
||||
PTY and process incarnation;
|
||||
- a live PTY with the committed handle, host scope, and process incarnation;
|
||||
- the committed principal and exact active legacy Dispatch or adopted Run; and
|
||||
- an operation-specific unique match.
|
||||
|
||||
The current hook event or exact controller inventory proves that the process
|
||||
survived the restart; equality with the pre-update commitment proves it is the
|
||||
same launch. If the hook cache was absent, the provider cannot report the
|
||||
proof, the token changed, or any candidate is ambiguous, the row remains
|
||||
read-only.
|
||||
|
||||
For new Dispatches, persist the launch-token hash at dispatch time so later
|
||||
contract updates do not depend on hook-cache recovery. Never return or log raw
|
||||
tokens. Add explicit RPC-envelope, structured-log, error, and CLI JSON
|
||||
redaction tests.
|
||||
|
||||
Host scope is runtime-owned:
|
||||
|
||||
- local: stable local execution-host identity;
|
||||
- WSL: stable native execution-host identity plus distro;
|
||||
- SSH: saved-host authority plus remote pane/process identity; and
|
||||
- federated runtime: authenticated peer fingerprint plus remote attachment.
|
||||
|
||||
`pickRemoteCliEnv`, SSH relay forwarding, WSLENV, and the legacy in-process SSH
|
||||
fallback must carry the hidden evidence without exposing it in command text or
|
||||
logs. A local runtime never accepts a raw remote token as local authority.
|
||||
Pre-update WSL processes without the token cannot use the lifecycle adapter.
|
||||
|
||||
For SSH, the host passthrough and in-process fallback stamp the saved target,
|
||||
`SshRelaySession` connection incarnation, and attachment provenance from the
|
||||
authenticated runtime channel; caller environment cannot choose them. WSL
|
||||
stamps the runtime-owned native host and distro in both the full bridge and
|
||||
fallback bridge. The verifier intersects those stamps with the committed host
|
||||
scope before considering token or pane evidence.
|
||||
|
||||
## Mixed-contract Run and mail routing
|
||||
|
||||
The adopted Run is a normal current Run. Tasks belong to it, so a current retry
|
||||
of a preserved Task naturally routes to the same Run without a cross-Run Task
|
||||
reference.
|
||||
|
||||
Message delivery is selected by `messages.delivery_contract`, not by Run ID:
|
||||
|
||||
- old and adapter-created direct messages are `legacy_direct`;
|
||||
- current sends are `current_delivery`; and
|
||||
- migration rejections and non-actionable history are `audit_only`.
|
||||
|
||||
Current Run Delivery creation, filtering, waiting, and acknowledgment select
|
||||
only `current_delivery`. Legacy `check` selects only the exact principal's
|
||||
`legacy_direct` address. History and inbox may show all three with explicit
|
||||
labels but never attach actions to `audit_only`. Mixed legacy/current attempts
|
||||
under one Task are covered across send, ask, check, reply, and settlement.
|
||||
|
||||
The original coordinator is automatically associated with the adopted Run
|
||||
after its principal proves live. `run-use` may bind that same principal without
|
||||
converting legacy Dispatches. A different coordinator cannot silently take
|
||||
over while a legacy attempt is active; an explicit recovery takeover is a
|
||||
separate current-contract action and revokes legacy coordinator mutation
|
||||
authority before rebinding.
|
||||
|
||||
When that coordinator uses old direct `check`, the adapter also performs a
|
||||
non-consuming existence check for `current_delivery` mail. It never mixes the
|
||||
messages into legacy output; it prints the exact current Run check command and,
|
||||
after that command creates a Delivery, the exact acknowledgment form. This
|
||||
keeps an unchanged coordinator informed about current retries without
|
||||
weakening current Delivery semantics.
|
||||
|
||||
## Exact legacy adapter
|
||||
|
||||
The CLI keeps accepting the old flags. It sends a hidden compatibility
|
||||
envelope; the runtime, not the CLI, decides whether legacy behavior applies.
|
||||
|
||||
Version B generates a random hidden `compatibilityInvocationId` once per CLI
|
||||
process and reuses it for every transport reconnect by that invocation.
|
||||
`legacy_operation_receipts.operation_key` is this invocation ID for B calls.
|
||||
The method and canonical payload hash must also match.
|
||||
|
||||
Attested compatibility calls bypass the rotating-auth generic mutation ledger
|
||||
after the contract fence routes them to the adapter. Their only dedupe boundary
|
||||
is the compatibility-principal receipt transaction described here; current
|
||||
calls continue through the existing generic ledger unchanged.
|
||||
|
||||
Version A had no invocation ID. Cross-cutover reconstruction is therefore
|
||||
limited to effects with a durable semantic identity: final settlement per
|
||||
Dispatch, still-pending ask per normalized question/options/recipient, reply
|
||||
per source/principal/body, and read recovery per message. Heartbeat and
|
||||
escalation remain at-least-once across an A-to-B manual retry because
|
||||
collapsing identical commands could suppress a legitimate later event. Tests
|
||||
and guidance do not promise cross-cutover exactly-once behavior for those
|
||||
repeatable signals.
|
||||
|
||||
An A-era ask that was already answered before its output was lost is also
|
||||
ambiguous. B does not guess whether the same text means retry or a new
|
||||
question. Its answer is in the durable recovery cohort and the error tells the
|
||||
agent to run the exact legacy check command first. After that answer is printed
|
||||
and acknowledged, a new B invocation with identical text creates a new
|
||||
question. If multiple identical A threads exist, ask fails ambiguous and
|
||||
recovery check drains them by message sequence; no arbitrary thread is chosen.
|
||||
|
||||
### Lifecycle `send`
|
||||
|
||||
- `heartbeat` and `worker_done`: resolve Task + Dispatch from the payload, then
|
||||
intersect them with the verified worker principal.
|
||||
- `escalation`: resolve Task + verified worker principal to exactly one active
|
||||
legacy Dispatch because the old prompt supplied no Dispatch ID.
|
||||
- Preserve the old direct coordinator recipient after verifying it belongs to
|
||||
the adopted Run principal.
|
||||
- Infer missing `worker_done` outcome as `failed` only when the trimmed subject
|
||||
is `Failed` or begins `Failed:`; otherwise infer `succeeded`.
|
||||
- Normalize to the current internal settlement model.
|
||||
|
||||
Compatibility validation happens before message insertion. Current lifecycle
|
||||
handling is not refactored through this prevalidation: its existing
|
||||
invalid-capability audit message behavior remains locked by regression tests.
|
||||
|
||||
The DB commits the direct message, lifecycle effect, and compatibility receipt
|
||||
in one transaction. If a pre-update `worker_done` already settled the Dispatch,
|
||||
the adapter reconstructs the matching semantic receipt from the existing
|
||||
message and settlement. The persisted A settlement is authoritative: the
|
||||
pinned A reconciler recorded even a `Failed:` subject as completed, so B
|
||||
returns that original completed result rather than retroactively changing Task
|
||||
or dependency state. Only a not-yet-accepted B call applies the corrected
|
||||
failure inference. A conflicting retry fails.
|
||||
|
||||
### `check`
|
||||
|
||||
Legacy check is at-least-once:
|
||||
|
||||
1. a read RPC returns the exact unacknowledged `legacy_direct` message IDs
|
||||
without changing them;
|
||||
2. the CLI prints the complete response; and
|
||||
3. a hidden ack atomically records receipts and marks those exact rows read.
|
||||
|
||||
A cut after read and before ack replays the same IDs. `--peek` and `--all`
|
||||
remain read-only.
|
||||
|
||||
Version A could mark a message read before losing its response. On adoption,
|
||||
every eligible read direct message connected to a still-active legacy attempt
|
||||
and lacking a post-adoption receipt is inserted into a durable recovery cohort
|
||||
using `legacy_mail_receipts` with `acknowledged_at = NULL`. Legacy check drains
|
||||
that stable cohort in bounded sequence-ordered pages before ordinary unread
|
||||
mail. Each page is labeled
|
||||
`[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`, replays until its post-print
|
||||
ack, and then advances. The cohort never grows after adoption, so every
|
||||
eligible row is covered without an unbounded response. Settled-at-cutover and
|
||||
unrelated historical mail do not enter it.
|
||||
|
||||
### `ask`
|
||||
|
||||
Legacy ask is an idempotent mutation followed by a resumable read:
|
||||
|
||||
1. create or find the direct legacy gate/question by verified Dispatch,
|
||||
normalized question, options, and recipient;
|
||||
2. atomically commit its message/thread plus operation receipt and return the
|
||||
stable question ID;
|
||||
3. wait in bounded read-only slices using that ID; and
|
||||
4. after printing the answer, hidden-ack that exact answer.
|
||||
|
||||
On first adoption, existing pending decision-gate threads are indexed into the
|
||||
same semantic identity. A retry resumes them instead of creating another gate.
|
||||
|
||||
### `reply`
|
||||
|
||||
Legacy reply requires the source thread, the verified coordinator principal,
|
||||
and a unique question owned by the adopted Run. One transaction inserts the
|
||||
reply, resolves the exact gate, marks the exact source, and completes the
|
||||
receipt.
|
||||
|
||||
An existing pre-update reply with the same source, principal, and body is
|
||||
reconstructed as success. A second body for the same source/principal is a
|
||||
conflict, not another reply.
|
||||
|
||||
### Coordinator and Task commands
|
||||
|
||||
After automatic binding, unchanged Task listing, Task settlement, dispatch,
|
||||
and gate inspection use the adopted Run. A new attempt receives a current
|
||||
preamble, capability, launch-token commitment, and current message contract.
|
||||
No unrelated Task can be created until the migrated coordinator binding is
|
||||
proven or explicitly taken over.
|
||||
|
||||
## Windows update behavior
|
||||
|
||||
No compatibility mutation holds a DB transaction while waiting. Windows
|
||||
legacy `ask` commits the question, prints its stable ID and exact resume
|
||||
command, then exits before a long wait whenever the installed launcher cannot
|
||||
prove that the executable is replaceable.
|
||||
|
||||
The native packaged launcher sets an internal
|
||||
`ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER=1` marker for its Electron-as-Node child.
|
||||
After the DB commit, legacy `ask` writes and flushes the stable question ID and
|
||||
the exact resume command, then exits with a documented `75` resume-required
|
||||
status. The command name comes only from the validated inherited
|
||||
`ORCA_CLI_COMMAND` packaged enum: `orca` for native packaged terminals or
|
||||
`orca-ide` for packaged WSL. Arbitrary environment text is rejected rather
|
||||
than rendered. The launcher propagates status 75. No long wait starts on this
|
||||
path.
|
||||
|
||||
This compatibility path makes the accepted question recoverable without
|
||||
claiming Windows updater or uninstaller process ownership. Generic executable
|
||||
drain, replacement, and uninstall behavior remains separate updater
|
||||
reliability work and requires its own Windows artifact proof.
|
||||
|
||||
## Rendering and authoritative guidance
|
||||
|
||||
Formatting is authority-aware:
|
||||
|
||||
- `[LEGACY COMPATIBILITY]`: live and attested; show only supported commands;
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`: one bounded cutover replay;
|
||||
- `[LEGACY READ-ONLY]`: retained but unactionable; no Reply or Ack hint; and
|
||||
- current messages: unchanged.
|
||||
|
||||
Update `skill-guides/orchestration.md` and regenerate its bundled source. The
|
||||
guide explains automatic adoption, exact continuity, the retired scheduler
|
||||
boundary, proof failures, current retries, and the Windows ask resume fallback.
|
||||
It must not tell agents to restart a worker whose Dispatch was adopted.
|
||||
|
||||
Formatting cannot use database provenance alone; current liveness and
|
||||
attestation are runtime facts. Recovery may reveal the exact already-live PTY
|
||||
once as an inactive background tab. It never creates a PTY or process, writes
|
||||
input, switches workspaces, focuses, splits, or injects a terminal.
|
||||
|
||||
## Deterministic verification
|
||||
|
||||
### Schema and authority
|
||||
|
||||
- Migrate exact pre-v7, hard-cutover, current, partial, WAL, reset, and fresh
|
||||
fixtures; preserve IDs and row counts and reuse one adopted Run.
|
||||
- Assert Task, Dispatch, decision-gate, message, question-thread, and retained
|
||||
Delivery Run ownership agrees; outstanding legacy Deliveries are fenced.
|
||||
- Prove current retries in the adopted Run remain current across repeated
|
||||
migrations.
|
||||
- Test unique worker/coordinator import from prior hook commitment plus current
|
||||
live hook, and every forged, stale, wrong-host, wrong-pane, wrong-process,
|
||||
duplicate-token, duplicate-role, equivalent-pane, and remote-attachment
|
||||
ambiguity.
|
||||
- Race a current-runtime hook before adoption and prove the immutable startup
|
||||
commitment is neither overwritten nor confused with live proof.
|
||||
- Cover manual coordinators with no `coordinator_runs` row and reject
|
||||
conflicting candidates from Task, Dispatch, and message provenance.
|
||||
- Assert all legacy authority failures occur before message/receipt/gate/state
|
||||
effects while current invalid-capability rejection remains unchanged.
|
||||
- Assert tokens are absent from RPC logs, errors, CLI output, and orchestration
|
||||
storage.
|
||||
|
||||
### Exact old behavior and replay
|
||||
|
||||
- Replay the pinned prompt's heartbeat, successful and `Failed:`
|
||||
`worker_done`, task-only escalation, ask, and consuming check.
|
||||
- Replay the old formatter's reply command separately.
|
||||
- Cover every operation once after B and once accepted by A immediately before
|
||||
the cutover where a durable semantic identity exists, including the bounded
|
||||
recovery replay for an A check and preservation of A's persisted settlement.
|
||||
- Prove one B invocation reuses its invocation ID across reconnect, while
|
||||
separate identical heartbeat/escalation invocations remain distinct.
|
||||
- Inject real transport cuts after check read, ask commit, reply commit, and
|
||||
completion commit; prove stable IDs and one receipt/effect.
|
||||
- Page a recovery cohort larger than one response limit across repeated
|
||||
disconnects and prove every eligible row appears until ack, then never again.
|
||||
- Test mixed legacy/current attempts for every send/check/ask/reply route and
|
||||
prove current Delivery cannot consume legacy direct mail and old coordinator
|
||||
check only advertises the exact non-consuming current Delivery command.
|
||||
|
||||
### Live two-launch E2E
|
||||
|
||||
The focused renderer/daemon restart regression reconstructs the rc.3-era
|
||||
orphan artifact in a shared candidate profile: a durable legacy Dispatch,
|
||||
sleeping provider record, and live daemon PTY with its visual binding removed.
|
||||
It proves startup restores the exact PID, PTY/incarnation, handle, tab/leaf,
|
||||
Task, and Dispatch; emits no provider-resume spawn or interruption; preserves
|
||||
coordinator focus; and remains inert after switching away and back. This
|
||||
candidate/candidate fixture covers the renderer regression deterministically
|
||||
but does not replace the distinct installed-A/candidate-B packaged cutover
|
||||
journey below. It does not claim packaged Windows updater behavior.
|
||||
|
||||
Use distinct installed A and B artifacts plus an external append-only fake
|
||||
agent spawn ledger:
|
||||
|
||||
1. launch A with coordinator and worker terminals and record the replaceable A
|
||||
desktop/main PID, runtime ID, version, and artifact hash separately from the
|
||||
durable daemon, coordinator, and worker identities;
|
||||
2. accept one command with durable semantic identity on A—pending ask, reply,
|
||||
completion, or check—cut its response, close A without stopping the daemon
|
||||
or fake agent, install B, and prove B owns the relaunched runtime;
|
||||
3. assert A's desktop/main process and runtime are gone, B has a different
|
||||
desktop/main PID, runtime ID, version, and artifact hash, while the daemon
|
||||
plus coordinator/worker PID and start time, PTY/session, terminal handle,
|
||||
tab/leaf/pane, worktree, Task, and Dispatch remain byte-identical;
|
||||
4. prove that exact pending ask, reply, completion, or check resumes or
|
||||
deduplicates on B; do not use heartbeat, escalation, or an already-answered
|
||||
A ask as an exactly-once oracle;
|
||||
5. run the remaining exact old lifecycle commands and settle the same
|
||||
Dispatch;
|
||||
6. switch away and back and assert no second spawn/tab and no
|
||||
`Conversation interrupted`; and
|
||||
7. create a current attempt on preserved state and prove current grammar.
|
||||
|
||||
The append-only spawn ledger and an append-only interruption ledger, daemon
|
||||
inventory, runtime terminal inventory, and every viewer DOM independently
|
||||
prove one launch, process, handle, tab, and no `Conversation interrupted`.
|
||||
Transport fault injection cuts the real CLI connection after check output but
|
||||
before ack, and after ask, reply, and completion commit. Re-run the byte-exact A
|
||||
argv under B and assert stable IDs plus exactly one message, gate, reply,
|
||||
settlement, and receipt.
|
||||
|
||||
Retain #11142's local visible-inactive test and extend its oracle beyond DOM
|
||||
counting with the ledgers and host inventory.
|
||||
|
||||
Run separate remote journeys:
|
||||
|
||||
- headed paired server A-to-B with a surviving external client;
|
||||
- headless `orca serve` A-to-B with the same surviving-client oracle;
|
||||
- both prove the remote PID/PTy continuity, both viewer states, zero
|
||||
client-local handles/tabs, and wrong-host evidence with zero effects;
|
||||
- Docker SSH restart/reconnect with a remote proof file binding the process,
|
||||
pane, and token commitment;
|
||||
- physical Windows WSL coverage proving the correct distro and rejecting a
|
||||
wrong-distro token; and
|
||||
- a git-independent folder workspace parameter through the same adoption
|
||||
journey.
|
||||
|
||||
Packaged Windows updater continuity is outside this migration gate. A separate
|
||||
updater change must prove candidate-built A/B install, executable release,
|
||||
resume, and normal uninstall behavior on Windows before making that guarantee.
|
||||
|
||||
## Implementation slices
|
||||
|
||||
1. Schema, atomic Run adoption, per-row contract provenance, and mixed mailbox.
|
||||
2. Hook-commitment import, live principal verifier, host scopes, and redaction.
|
||||
3. Atomic compatibility receipts plus lifecycle send/escalation.
|
||||
4. Check read/ack and cutover recovery replay.
|
||||
5. Ask create/resume, reply conflict semantics, and coordinator binding.
|
||||
6. CLI hidden envelope, SSH/WSL forwarding, Windows resume fallback.
|
||||
7. Authority-aware formatting and the version-matched orchestration guide.
|
||||
8. Two-launch, remote, platform, and #11142 regression coverage.
|
||||
9. Focused unit/type/lint/E2E checks and final adversarial review.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Updates are never blocked.
|
||||
- Every durable legacy Task is owned by an ordinary visible Run.
|
||||
- Every provably original live legacy Dispatch can finish through its existing
|
||||
prompt without changing process or worktree.
|
||||
- New attempts use the current contract and mailbox in that same Run.
|
||||
- No authority comes from IDs or handles alone.
|
||||
- No adoption step starts, stops, writes, signals, focuses, splits, or injects
|
||||
a terminal. Exact live-PTY recovery may restore one inactive background
|
||||
renderer tab/leaf binding.
|
||||
- B-era disconnects replay or deduplicate instead of losing or duplicating
|
||||
work; A-era repeatable signals and already-answered asks follow the explicit
|
||||
ambiguity recovery rules.
|
||||
- Lost scheduler memory is reported precisely without stranding durable state.
|
||||
- Local/background, remote, SSH, WSL, folder, and Windows guarantees have
|
||||
deterministic proof.
|
||||
- The PR contains this plan, focused implementation, and no unrelated changes.
|
||||
@@ -87,11 +87,21 @@ allocate one live operation per entry.
|
||||
|
||||
Every portable entry is admitted through `WorkspaceSpaceScanBudget` before retention:
|
||||
|
||||
- maximum entries held at once per worktree traversal: 100,000;
|
||||
- maximum entries in any one directory listing: 100,000;
|
||||
- maximum estimated live scan state per worktree traversal: 64 MiB.
|
||||
|
||||
The retained-byte estimate includes parent path and entry name UTF-16 storage plus conservative
|
||||
per-entry object overhead. These are admission limits, not post-allocation observations.
|
||||
The entry cap is per listing rather than per traversal because only a single directory's width is
|
||||
fixed by directory shape. A traversal-wide entry counter is charged by every worker holding a
|
||||
listing at once, so its verdict scales with the configured concurrency: at 48 workers, 48 × 2,100
|
||||
files (100,848 entries) was rejected while 100 × 1,500 (150,100 entries, 50% more) was admitted.
|
||||
Aggregate live retention stays bounded by the 64 MiB byte cap, which all concurrent listings share.
|
||||
|
||||
The retained-byte estimate includes entry name UTF-16 storage plus conservative per-entry object
|
||||
overhead, and each listing's parent path once. The parent path is charged per listing rather than
|
||||
per entry because a listing's entries all share a single parent-path string; charging it per entry
|
||||
multiplied it by the directory's width, so the 64 MiB cap tracked checkout depth instead of live
|
||||
heap and rejected the layouts above once the worktree path passed ~58 characters. These are
|
||||
admission limits, not post-allocation observations.
|
||||
|
||||
The budget measures what the traversal is holding **right now**, not what it has ever seen. A
|
||||
directory listing is charged when admitted and released once every one of its entries has been
|
||||
@@ -100,9 +110,10 @@ distinction decides real workspaces: a cumulative counter charged an ordinary 76
|
||||
worktree 61.2 MiB of its 64 MiB cap — 4% headroom, and tipping over purely because a longer branch
|
||||
name lengthens every absolute path. The same worktree peaks between 4 MiB and 8 MiB of live state.
|
||||
|
||||
Because the estimate scales with absolute path length, a cumulative cap would make success depend on
|
||||
where a user checks out their worktrees. A live cap depends only on directory shape, so it rejects
|
||||
genuinely pathological layouts (one directory holding six figures of entries) and nothing else.
|
||||
Neither cap may depend on where a user checks out their worktrees, so the estimate charges each
|
||||
absolute path once per listing rather than once per entry. A live cap depends only on directory
|
||||
shape, so it rejects genuinely pathological layouts (one directory holding six figures of entries)
|
||||
and nothing else.
|
||||
|
||||
Top-level directory enumeration for the `du` path uses the same budget. A capacity error must not
|
||||
fall through into the portable walker, because that would repeat an already rejected traversal.
|
||||
@@ -169,7 +180,7 @@ The deterministic regression measurements are:
|
||||
- peak simultaneous local `du` calls across repositories: exactly one;
|
||||
- peak simultaneous desktop-side SSH fallback traversals across repositories: at most two;
|
||||
- portable traversal live entry jobs: no more than its configured worker count;
|
||||
- portable entries held at once: at most 100,000 per worktree;
|
||||
- portable entries in any one directory listing: at most 100,000;
|
||||
- estimated live portable state: at most 64 MiB per worktree; an ordinary 76,788-entry worktree
|
||||
peaks between 4 MiB and 8 MiB;
|
||||
- progress and final row counts remain unchanged for scans below the limits.
|
||||
|
||||
+17
-1
@@ -208,6 +208,12 @@ export const electronViteConfig: UserConfig = {
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
|
||||
// main thread (macOS 26 AppKit scene-update deadlock) and can record
|
||||
// the stall for the next launch to report.
|
||||
'main-thread-hang-watchdog-entry': resolve(
|
||||
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
|
||||
),
|
||||
// Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on
|
||||
// spawnSync — codex app-server trust grants need a live event loop
|
||||
// but must finish before a Codex pane launch proceeds.
|
||||
@@ -218,7 +224,17 @@ export const electronViteConfig: UserConfig = {
|
||||
// this path for `orca agent hooks ...`, so it must survive rebuilds.
|
||||
'agent-hooks/managed-agent-hook-controls': resolve(
|
||||
'src/main/agent-hooks/managed-agent-hook-controls.ts'
|
||||
)
|
||||
),
|
||||
'ipc/local-agent-install-dir-detection': resolve(
|
||||
'src/main/ipc/local-agent-install-dir-detection.ts'
|
||||
),
|
||||
'ipc/tui-agent-detection-commands': resolve(
|
||||
'src/main/ipc/tui-agent-detection-commands.ts'
|
||||
),
|
||||
// Why: same rule — `orca account add` / `account list` import these.
|
||||
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'),
|
||||
'codex-cli/command': resolve('src/main/codex-cli/command.ts'),
|
||||
'win32-utils': resolve('src/main/win32-utils.ts')
|
||||
},
|
||||
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
|
||||
// consume these stable CommonJS paths.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'i18next-cli'
|
||||
|
||||
const output =
|
||||
process.env.ORCA_I18N_EXTRACTION_OUTPUT ?? 'tmp/localization-extraction/{{language}}.json'
|
||||
|
||||
export default defineConfig({
|
||||
locales: ['en'],
|
||||
extract: {
|
||||
input: ['src/**/*.{js,jsx,ts,tsx,mts,cts}'],
|
||||
ignore: [
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'**/__tests__/**',
|
||||
'**/__snapshots__/**',
|
||||
'**/assets/**'
|
||||
],
|
||||
output,
|
||||
defaultNS: false,
|
||||
functions: ['t', '*.t', 'translate', 'translateMain'],
|
||||
useTranslationNames: ['useTranslation'],
|
||||
sort: true,
|
||||
disablePlurals: true,
|
||||
removeUnusedKeys: true
|
||||
}
|
||||
})
|
||||
@@ -15,12 +15,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from '../../../src/transport/host-endpoint'
|
||||
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
|
||||
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
|
||||
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
|
||||
import type { HostProfile } from '../../../src/transport/types'
|
||||
|
||||
@@ -68,27 +64,24 @@ export default function EditHostScreen() {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
|
||||
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
|
||||
|
||||
const normalizedEndpoint = useMemo(
|
||||
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
|
||||
[address, fallbackPort, fallbackScheme]
|
||||
const endpointEdit = useMemo(
|
||||
() => (host ? resolveHostEndpointEdit(host.endpoint, address) : null),
|
||||
[address, host]
|
||||
)
|
||||
|
||||
const nameTrimmed = name.trim()
|
||||
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
|
||||
const endpointChanged =
|
||||
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
|
||||
const endpointChanged = endpointEdit?.kind === 'changed'
|
||||
const canSave =
|
||||
host != null &&
|
||||
endpointEdit != null &&
|
||||
nameTrimmed.length > 0 &&
|
||||
normalizedEndpoint.ok &&
|
||||
endpointEdit.kind !== 'invalid' &&
|
||||
(nameChanged || endpointChanged) &&
|
||||
!saving
|
||||
|
||||
async function handleSave() {
|
||||
if (!host || !hostId || savingRef.current) {
|
||||
if (!host || !hostId || !endpointEdit || savingRef.current) {
|
||||
return
|
||||
}
|
||||
const nextName = name.trim()
|
||||
@@ -96,14 +89,14 @@ export default function EditHostScreen() {
|
||||
setSaveError('Enter a name.')
|
||||
return
|
||||
}
|
||||
if (!normalizedEndpoint.ok) {
|
||||
setSaveError(normalizedEndpoint.error)
|
||||
if (endpointEdit.kind === 'invalid') {
|
||||
setSaveError(endpointEdit.error)
|
||||
return
|
||||
}
|
||||
|
||||
const willRename = nextName !== host.name
|
||||
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
|
||||
if (!willRename && !willUpdateEndpoint) {
|
||||
const nextEndpoint = endpointEdit.kind === 'changed' ? endpointEdit.endpoint : undefined
|
||||
if (!willRename && nextEndpoint === undefined) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
@@ -117,7 +110,7 @@ export default function EditHostScreen() {
|
||||
// other, and a host removed mid-edit throws instead of no-oping.
|
||||
await updateHostNameAndEndpoint(host.id, {
|
||||
...(willRename ? { name: nextName } : {}),
|
||||
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
|
||||
...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
|
||||
@@ -140,7 +133,7 @@ export default function EditHostScreen() {
|
||||
setSaving(false)
|
||||
router.back()
|
||||
|
||||
if (willUpdateEndpoint) {
|
||||
if (nextEndpoint !== undefined) {
|
||||
// Why: reconnect is a follow-on side effect of a save that already
|
||||
// committed — its failure or a hang must not be reported as a save
|
||||
// failure or block navigating back.
|
||||
@@ -247,12 +240,12 @@ export default function EditHostScreen() {
|
||||
(or 6768).
|
||||
</Text>
|
||||
|
||||
{normalizedEndpoint.ok ? (
|
||||
{endpointEdit == null ? null : endpointEdit.kind !== 'invalid' ? (
|
||||
<Text style={styles.preview} numberOfLines={2}>
|
||||
Connects to {normalizedEndpoint.endpoint}
|
||||
Connects to {endpointEdit.endpoint}
|
||||
</Text>
|
||||
) : address.trim().length > 0 ? (
|
||||
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
|
||||
<Text style={styles.previewError}>{endpointEdit.error}</Text>
|
||||
) : null}
|
||||
|
||||
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
|
||||
|
||||
@@ -198,6 +198,11 @@ import {
|
||||
confirmsMirroredTabSelection,
|
||||
type AppliedSnapshotMarker
|
||||
} from '../../../../src/session/session-tab-snapshot-gate'
|
||||
import {
|
||||
createInitialSessionAutoCreateState,
|
||||
useInitialSessionTerminalAutoCreate,
|
||||
useWorktreeSessionTabsLoaded
|
||||
} from '../../../../src/session/use-initial-session-terminal-autocreate'
|
||||
import {
|
||||
buildMarkdownDiskFallbackDoc,
|
||||
shouldReadMarkdownFromDiskAfterReadTabFailure
|
||||
@@ -239,6 +244,7 @@ import {
|
||||
getActiveTabIdForHandle,
|
||||
isFileExistsErrorMessage,
|
||||
isGestureMouseTrackingMode,
|
||||
isTerminalPhoneDisplayMode,
|
||||
MOBILE_SESSION_STATUS_LABELS,
|
||||
TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
|
||||
TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
|
||||
@@ -890,7 +896,7 @@ export default function SessionScreen() {
|
||||
const appliedSessionTabsRevisionRef = useRef(0)
|
||||
// Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back.
|
||||
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
|
||||
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
|
||||
const [terminalsLoaded, setTerminalsLoaded] = useWorktreeSessionTabsLoaded(worktreeId)
|
||||
const [input, setInput] = useState('')
|
||||
// Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted).
|
||||
const [terminalTextScale, setTerminalTextScale] = useState(1)
|
||||
@@ -1032,7 +1038,7 @@ export default function SessionScreen() {
|
||||
// Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render).
|
||||
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
|
||||
|
||||
const initialEmptySessionAutoCreateRef = useRef<string | null>(null)
|
||||
const initialSessionAutoCreateRef = useRef(createInitialSessionAutoCreateState())
|
||||
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
|
||||
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
|
||||
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
|
||||
@@ -1774,6 +1780,7 @@ export default function SessionScreen() {
|
||||
nextTabs = [...orphanedDraftTabs, ...nextTabs]
|
||||
}
|
||||
sessionTabsRef.current = nextTabs
|
||||
initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0
|
||||
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
|
||||
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
|
||||
const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs)
|
||||
@@ -2632,7 +2639,7 @@ export default function SessionScreen() {
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
pendingBrowserFocusPageIdRef.current = null
|
||||
pendingTerminalActivationAttemptRef.current = null
|
||||
initialEmptySessionAutoCreateRef.current = null
|
||||
initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()
|
||||
terminalDiagnosticsRef.current.resetRoute()
|
||||
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
|
||||
closedTabTombstonesRef.current.clear()
|
||||
@@ -3675,6 +3682,7 @@ export default function SessionScreen() {
|
||||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
|
||||
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
|
||||
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
|
||||
showToast,
|
||||
onNativeChatSendError: nativeChatSendError.show,
|
||||
onSuccess: triggerSelection,
|
||||
@@ -4160,14 +4168,6 @@ export default function SessionScreen() {
|
||||
})
|
||||
const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions)
|
||||
|
||||
const isPhoneMode = (handle: string | null): boolean => {
|
||||
if (!handle) {
|
||||
return false
|
||||
}
|
||||
const mode = terminalModes.get(handle)
|
||||
return mode === 'auto' || mode === 'phone' || mode === undefined
|
||||
}
|
||||
|
||||
const visibleTabs: MobileSessionTab[] = sessionTabs
|
||||
const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null
|
||||
const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null
|
||||
@@ -4227,22 +4227,20 @@ export default function SessionScreen() {
|
||||
const showEmptyState =
|
||||
connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!client ||
|
||||
!showEmptyState ||
|
||||
creating ||
|
||||
creatingBrowser ||
|
||||
creatingMarkdown ||
|
||||
initialEmptySessionAutoCreateRef.current === worktreeId
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: a sleeping/new workspace can hydrate with zero tabs; create the first terminal once so mobile isn't blank.
|
||||
initialEmptySessionAutoCreateRef.current = worktreeId
|
||||
setCreateError('')
|
||||
void handleCreateTerminal()
|
||||
}, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId])
|
||||
// Why: a newly created workspace can hydrate with zero tabs before its first terminal exists.
|
||||
useInitialSessionTerminalAutoCreate({
|
||||
client,
|
||||
newlyCreatedWorkspace: created === '1',
|
||||
connState,
|
||||
terminalsLoaded,
|
||||
visibleTabCount: visibleTabs.length,
|
||||
activeHandle,
|
||||
createInFlight: creating || creatingBrowser || creatingMarkdown,
|
||||
stateRef: initialSessionAutoCreateRef,
|
||||
worktreeId,
|
||||
consumeCreationRoute: () => router.setParams({ created: undefined }),
|
||||
createTerminal: () => void handleCreateTerminal()
|
||||
})
|
||||
|
||||
// Why: reconnect trickles to 90s at its give-up cap; surface tap-to-retry so recovery needn't wait it out (issue #5049).
|
||||
const connectionVerdict = classifyConnection({
|
||||
@@ -4827,12 +4825,12 @@ export default function SessionScreen() {
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={
|
||||
isPhoneMode(activeHandle)
|
||||
isTerminalPhoneDisplayMode(activeHandle, terminalModes)
|
||||
? 'Switch to desktop mode'
|
||||
: 'Switch to phone mode'
|
||||
}
|
||||
>
|
||||
{isPhoneMode(activeHandle) ? (
|
||||
{isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
|
||||
<Monitor
|
||||
size={14}
|
||||
color={canSend ? colors.textSecondary : colors.textMuted}
|
||||
@@ -5211,11 +5209,12 @@ export default function SessionScreen() {
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onToggleChat: toggleTabChatView,
|
||||
isPhoneMode,
|
||||
isPhoneMode: (handle) => isTerminalPhoneDisplayMode(handle, terminalModes),
|
||||
onToggleDisplayMode: (handle) => void toggleDisplayMode(handle),
|
||||
onRename: setRenameTarget,
|
||||
onClear: (target) => void handleClearTerminal(target),
|
||||
onClose: (target) => void handleCloseTerminal(target),
|
||||
onCloseSessionTab: (tab) => void handleCloseSessionTab(tab),
|
||||
bulkCloseActions
|
||||
})}
|
||||
onClose={() => setActionTarget(null)}
|
||||
|
||||
@@ -28,6 +28,7 @@ export type MobileSessionTab =
|
||||
launchAgent?: TuiAgent
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
launchDraftCreatedAt?: number
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
@@ -60,6 +60,13 @@ import {
|
||||
resolveMobileSyntaxLanguage
|
||||
} from '../../../src/session/mobile-file-syntax'
|
||||
import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary'
|
||||
import { buildGitLabCheckSummary } from '../../../src/tasks/gitlab-check-summary'
|
||||
import {
|
||||
getHostedMergeLabel,
|
||||
getHostedReviewLabel,
|
||||
getHostedReviewSignalTone,
|
||||
getHostedChecksLabel
|
||||
} from '../../../src/tasks/mobile-hosted-check-status'
|
||||
import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params'
|
||||
import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability'
|
||||
import {
|
||||
@@ -138,11 +145,13 @@ import {
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
GitHubOwnerRepo,
|
||||
ProviderCheckSummary,
|
||||
PersistedTrustedOrcaHooks,
|
||||
SparsePreset,
|
||||
TuiAgent
|
||||
} from '../../../../src/shared/types'
|
||||
import type { SshConnectionState } from '../../../../src/shared/ssh-types'
|
||||
import type { HostedReviewDecision } from '../../../../src/shared/hosted-review'
|
||||
import {
|
||||
githubProjectHost,
|
||||
githubProjectIdentityKey as githubProjectKey
|
||||
@@ -181,31 +190,20 @@ type GitHubWorkItem = {
|
||||
reviewDecision?: string | null
|
||||
reviewRequests?: GitHubAssignableUser[]
|
||||
latestReviews?: GitHubPRReviewSummary[]
|
||||
checksSummary?: GitHubPRCheckSummary
|
||||
checksSummary?: ProviderCheckSummary
|
||||
mergeable?: GitHubPRMergeableState
|
||||
mergeStateStatus?: string | null
|
||||
}
|
||||
|
||||
type GitHubAssignableUser = {
|
||||
login: string
|
||||
name?: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
type GitHubPRReviewSummary = {
|
||||
login: string
|
||||
state?: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
type GitHubPRCheckSummary = {
|
||||
state: 'success' | 'failure' | 'pending' | 'none'
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
}
|
||||
|
||||
type GitHubPRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
|
||||
|
||||
type GitHubPRReviewerRow = {
|
||||
@@ -214,13 +212,11 @@ type GitHubPRReviewerRow = {
|
||||
avatarUrl?: string | null
|
||||
stateLabel: string
|
||||
}
|
||||
|
||||
type GitHubRepoSources = {
|
||||
issues: GitHubOwnerRepo | null
|
||||
prs: GitHubOwnerRepo | null
|
||||
upstreamCandidate: GitHubOwnerRepo | null
|
||||
}
|
||||
|
||||
type TaskRuntimeStatus = {
|
||||
capabilities?: string[]
|
||||
}
|
||||
@@ -229,7 +225,6 @@ type TasksSupportState =
|
||||
| { kind: 'unknown'; client: RpcClient | null }
|
||||
| { kind: 'supported'; client: RpcClient }
|
||||
| { kind: 'unsupported'; client: RpcClient }
|
||||
|
||||
type GitLabWorkItem = {
|
||||
id: string
|
||||
type: 'issue' | 'mr'
|
||||
@@ -244,6 +239,10 @@ type GitLabWorkItem = {
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
projectRef?: { host: string; path: string }
|
||||
checksSummary?: ProviderCheckSummary
|
||||
mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
|
||||
reviewDecision?: HostedReviewDecision
|
||||
reviewerCount?: number
|
||||
repoId: string
|
||||
repoName: string
|
||||
}
|
||||
@@ -1262,23 +1261,6 @@ function hostedBranchSummary(item: TaskItem): { head: string; base: string } | n
|
||||
return null
|
||||
}
|
||||
|
||||
function getGitHubChecksLabel(item: GitHubWorkItem): string {
|
||||
const summary = item.checksSummary
|
||||
if (!summary) {
|
||||
return 'Checks'
|
||||
}
|
||||
if (summary.total === 0) {
|
||||
return 'No checks'
|
||||
}
|
||||
if (summary.failed > 0) {
|
||||
return `${summary.failed} failing`
|
||||
}
|
||||
if (summary.pending > 0) {
|
||||
return `${summary.pending} pending`
|
||||
}
|
||||
return `${summary.passed}/${summary.total} passed`
|
||||
}
|
||||
|
||||
function getGitHubMergeLabel(item: GitHubWorkItem): string {
|
||||
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
|
||||
return 'Merge'
|
||||
@@ -1382,46 +1364,6 @@ function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string {
|
||||
return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}`
|
||||
}
|
||||
|
||||
function getGitHubPRSignalTone(
|
||||
item: GitHubWorkItem,
|
||||
signal: 'review' | 'checks' | 'merge'
|
||||
): 'neutral' | 'success' | 'warning' | 'danger' {
|
||||
if (signal === 'review') {
|
||||
if (item.reviewDecision === 'APPROVED') {
|
||||
return 'success'
|
||||
}
|
||||
if (item.reviewDecision === 'CHANGES_REQUESTED') {
|
||||
return 'danger'
|
||||
}
|
||||
if (item.reviewRequests && item.reviewRequests.length > 0) {
|
||||
return 'warning'
|
||||
}
|
||||
return 'neutral'
|
||||
}
|
||||
if (signal === 'checks') {
|
||||
if (item.checksSummary?.state === 'success') {
|
||||
return 'success'
|
||||
}
|
||||
if (item.checksSummary?.state === 'failure') {
|
||||
return 'danger'
|
||||
}
|
||||
if (item.checksSummary?.state === 'pending') {
|
||||
return 'warning'
|
||||
}
|
||||
return 'neutral'
|
||||
}
|
||||
if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') {
|
||||
return 'danger'
|
||||
}
|
||||
if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') {
|
||||
return 'warning'
|
||||
}
|
||||
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
|
||||
return 'success'
|
||||
}
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function mergeGitHubAssignableUsers(
|
||||
users: GitHubAssignableUser[],
|
||||
seeds: GitHubAssignableUser[]
|
||||
@@ -4345,7 +4287,7 @@ export default function MobileTasksScreen() {
|
||||
const details = response.result as {
|
||||
body?: string
|
||||
comments?: DetailComment[]
|
||||
item?: { labels?: string[] }
|
||||
item?: { labels?: string[]; mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' }
|
||||
assignees?: string[]
|
||||
pipelineJobs?: Array<{
|
||||
id?: number
|
||||
@@ -4355,6 +4297,8 @@ export default function MobileTasksScreen() {
|
||||
webUrl?: string | null
|
||||
duration?: number | null
|
||||
}>
|
||||
reviewers?: unknown[]
|
||||
approvalState?: { approvalsRequired: number | null; approvalsLeft: number | null }
|
||||
} | null
|
||||
if (!details) {
|
||||
throw new Error('Details not found')
|
||||
@@ -4368,6 +4312,44 @@ export default function MobileTasksScreen() {
|
||||
assignees: details.assignees ?? [],
|
||||
pipelineJobs: details.pipelineJobs ?? []
|
||||
})
|
||||
const checksSummary = buildGitLabCheckSummary(details.pipelineJobs ?? [])
|
||||
const reviewDecision: Exclude<HostedReviewDecision, null> | undefined =
|
||||
details.approvalState?.approvalsRequired && details.approvalState.approvalsLeft === 0
|
||||
? 'approved'
|
||||
: details.approvalState?.approvalsLeft && details.approvalState.approvalsLeft > 0
|
||||
? 'review_required'
|
||||
: undefined
|
||||
const hydratedStatus = {
|
||||
...(details.item?.mergeable !== undefined ? { mergeable: details.item.mergeable } : {}),
|
||||
...(reviewDecision !== undefined ? { reviewDecision } : {}),
|
||||
...(details.reviewers !== undefined ? { reviewerCount: details.reviewers.length } : {})
|
||||
}
|
||||
setActionItem((current) =>
|
||||
current?.provider === 'gitlab' && current.source.id === actionItem.source.id
|
||||
? {
|
||||
...current,
|
||||
source: {
|
||||
...current.source,
|
||||
checksSummary,
|
||||
...hydratedStatus
|
||||
}
|
||||
}
|
||||
: current
|
||||
)
|
||||
setItems((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.provider === 'gitlab' && candidate.source.id === actionItem.source.id
|
||||
? {
|
||||
...candidate,
|
||||
source: {
|
||||
...candidate.source,
|
||||
checksSummary,
|
||||
...hydratedStatus
|
||||
}
|
||||
}
|
||||
: candidate
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -9689,6 +9671,7 @@ export default function MobileTasksScreen() {
|
||||
const item = entry.item
|
||||
const repo = taskRepositoryMeta(item, reposById)
|
||||
const isGitHubPr = item.provider === 'github' && item.source.type === 'pr'
|
||||
const isGitLabMr = item.provider === 'gitlab' && item.source.type === 'mr'
|
||||
const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null
|
||||
const branchSummary = hostedBranchSummary(item)
|
||||
return (
|
||||
@@ -9734,45 +9717,53 @@ export default function MobileTasksScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{isGitHubPr ? (
|
||||
{isGitHubPr || isGitLabMr ? (
|
||||
<View style={styles.prSignalRow}>
|
||||
{githubPrDelta ? (
|
||||
{isGitHubPr && githubPrDelta ? (
|
||||
<View style={styles.prSignalChip}>
|
||||
<Text style={styles.prSignalText} numberOfLines={1}>
|
||||
{githubPrDelta}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{isGitHubPr || isGitLabMr ? (
|
||||
<View
|
||||
style={[
|
||||
styles.prSignalChip,
|
||||
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'review'))
|
||||
]}
|
||||
>
|
||||
<Text style={styles.prSignalText} numberOfLines={1}>
|
||||
{isGitHubPr
|
||||
? getGitHubReviewSummary(item.source)
|
||||
: getHostedReviewLabel(item.source)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View
|
||||
style={[
|
||||
styles.prSignalChip,
|
||||
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'review'))
|
||||
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'checks'))
|
||||
]}
|
||||
>
|
||||
<Text style={styles.prSignalText} numberOfLines={1}>
|
||||
{getGitHubReviewSummary(item.source)}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.prSignalChip,
|
||||
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'checks'))
|
||||
]}
|
||||
>
|
||||
<Text style={styles.prSignalText} numberOfLines={1}>
|
||||
{getGitHubChecksLabel(item.source)}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.prSignalChip,
|
||||
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'merge'))
|
||||
]}
|
||||
>
|
||||
<Text style={styles.prSignalText} numberOfLines={1}>
|
||||
{getGitHubMergeLabel(item.source)}
|
||||
{getHostedChecksLabel(item.source)}
|
||||
</Text>
|
||||
</View>
|
||||
{isGitHubPr || isGitLabMr ? (
|
||||
<View
|
||||
style={[
|
||||
styles.prSignalChip,
|
||||
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'merge'))
|
||||
]}
|
||||
>
|
||||
<Text style={styles.prSignalText} numberOfLines={1}>
|
||||
{isGitHubPr
|
||||
? getGitHubMergeLabel(item.source)
|
||||
: getHostedMergeLabel(item.source)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"acorn": "8.15.0",
|
||||
"esbuild": "0.25.4",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"happy-dom": "^20.9.0",
|
||||
"oxfmt": "^0.52.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"react-test-renderer": "19.2.6",
|
||||
|
||||
Generated
+42
-2
@@ -168,6 +168,9 @@ importers:
|
||||
expo-module-scripts:
|
||||
specifier: ^55.0.2
|
||||
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(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-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
happy-dom:
|
||||
specifier: ^20.9.0
|
||||
version: 20.11.1
|
||||
oxfmt:
|
||||
specifier: ^0.52.0
|
||||
version: 0.52.0
|
||||
@@ -188,7 +191,7 @@ importers:
|
||||
version: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
version: 4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -2741,6 +2744,9 @@ packages:
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@@ -3177,6 +3183,10 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
@@ -3556,6 +3566,10 @@ packages:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
@@ -4286,6 +4300,10 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
happy-dom@20.11.1:
|
||||
resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
has-bigints@1.1.0:
|
||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9661,6 +9679,8 @@ snapshots:
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
@@ -10236,6 +10256,10 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -10611,6 +10635,8 @@ snapshots:
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@@ -11614,6 +11640,19 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
happy-dom@20.11.1:
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
buffer-image-size: 0.6.4
|
||||
entities: 7.0.1
|
||||
whatwg-mimetype: 3.0.0
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
has-bigints@1.1.0: {}
|
||||
|
||||
has-flag@3.0.0: {}
|
||||
@@ -14235,7 +14274,7 @@ snapshots:
|
||||
tsx: 4.22.4
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest@4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)):
|
||||
vitest@4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
@@ -14259,6 +14298,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.1.1
|
||||
happy-dom: 20.11.1
|
||||
jsdom: 20.0.3
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -12,6 +12,7 @@ import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
|
||||
import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { handleMockAccountRequest } from './mock-server-account-rpc'
|
||||
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
|
||||
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
|
||||
import {
|
||||
createMockTerminals,
|
||||
FAKE_SCROLLBACK,
|
||||
@@ -124,7 +125,8 @@ export function handleRequest(
|
||||
handleMockGitRequest(request, respond, success) ||
|
||||
handleMockFilePreviewRequest(request, respond, success, error) ||
|
||||
handleMockAccountRequest(request, respond, success, error) ||
|
||||
handleMockNativeChatRequest(request, respond, success, error, ws)
|
||||
handleMockNativeChatRequest(request, respond, success, error, ws) ||
|
||||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
|
||||
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
|
||||
|
||||
// Why: the client's snapshot-acceptance gate keys on the publisher epoch, so it
|
||||
// must stay stable for the process and change on restart like a real publisher —
|
||||
// hence a uuid, not a clock read two restarts could land on.
|
||||
// The `mobile-local:` prefix is reserved for phone-local writes — never use it.
|
||||
const PUBLICATION_EPOCH = `mock-server:${randomUUID()}`
|
||||
const GROUP_ID = 'group-1'
|
||||
const PARENT_TAB_ID = 'tab-1'
|
||||
// The host only ever publishes terminal-layout UUIDs here; pane-key parsing
|
||||
// rejects any other shape, so a placeholder would mask pane-attribution bugs.
|
||||
const LEAF_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
||||
// The host publishes terminal surfaces as `${parentTabId}::${leafId}`.
|
||||
const SURFACE_TAB_ID = `${PARENT_TAB_ID}::${LEAF_ID}`
|
||||
|
||||
/** One ready terminal tab bound to the `term-1` fixture. Mirrors the full
|
||||
* `session.tabs.list` contract so mock-server repros of tab, split-pane, and
|
||||
* pane-attribution bugs aren't shape-incomplete. */
|
||||
function createMockSessionTabs(worktreeId: string): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: PUBLICATION_EPOCH,
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: GROUP_ID,
|
||||
activeTabId: SURFACE_TAB_ID,
|
||||
activeTabType: 'terminal',
|
||||
// Groups track top-level tabs, so they carry parentTabId, not surface ids.
|
||||
tabGroups: [
|
||||
{
|
||||
id: GROUP_ID,
|
||||
activeTabId: PARENT_TAB_ID,
|
||||
tabOrder: [PARENT_TAB_ID],
|
||||
recentTabIds: [PARENT_TAB_ID]
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: SURFACE_TAB_ID,
|
||||
title: 'zsh',
|
||||
parentTabId: PARENT_TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** Default session-tabs backend: without it the session screen hangs on
|
||||
* 'Loading tabs'. Returns false for methods it does not own. */
|
||||
export function handleMockSessionTabsRequest(
|
||||
request: RpcRequest,
|
||||
respond: (response: RpcResponse) => void,
|
||||
success: (id: string, result: unknown, streaming?: boolean) => RpcResponse,
|
||||
// Shared with `terminal.list` so both surfaces agree on which worktree an
|
||||
// absent or `id:`-prefixed selector means.
|
||||
resolveWorktreeId: (selector: unknown) => string | undefined
|
||||
): boolean {
|
||||
if (request.method !== 'session.tabs.list') {
|
||||
return false
|
||||
}
|
||||
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
|
||||
respond(success(request.id, createMockSessionTabs(worktreeId)))
|
||||
return true
|
||||
}
|
||||
@@ -10,15 +10,17 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
prepareMobileAiVaultSessionResume,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
RESUME_RPC_TIMEOUT_MS,
|
||||
type MobileAiVaultResumeSettings
|
||||
} from '../session/ai-vault-resume-launch'
|
||||
import {
|
||||
prepareMobileAiVaultSessionResume,
|
||||
RESUME_RPC_TIMEOUT_MS
|
||||
} from '../session/ai-vault-resume-preparation'
|
||||
import { triggerError, triggerSuccess } from '../platform/haptics'
|
||||
import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
|
||||
import { MountedBottomDrawer } from './mounted-bottom-drawer'
|
||||
|
||||
@@ -31,6 +31,37 @@ export function BottomDrawer({
|
||||
zIndex
|
||||
}: Props) {
|
||||
const [mounted, setMounted] = useState(visible)
|
||||
const onAfterCloseRef = useRef(onAfterClose)
|
||||
const hiddenHandledRef = useRef(false)
|
||||
const afterClosePendingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
onAfterCloseRef.current = onAfterClose
|
||||
}, [onAfterClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
hiddenHandledRef.current = false
|
||||
afterClosePendingRef.current = false
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted || !afterClosePendingRef.current) {
|
||||
return
|
||||
}
|
||||
afterClosePendingRef.current = false
|
||||
onAfterCloseRef.current?.()
|
||||
}, [mounted])
|
||||
|
||||
const handleHidden = useCallback(() => {
|
||||
if (hiddenHandledRef.current) {
|
||||
return
|
||||
}
|
||||
hiddenHandledRef.current = true
|
||||
afterClosePendingRef.current = true
|
||||
setMounted(false)
|
||||
}, [])
|
||||
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
|
||||
|
||||
// Why: opening drawers should mount before commit; waiting for a passive
|
||||
@@ -49,10 +80,7 @@ export function BottomDrawer({
|
||||
<MountedBottomDrawer
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onHidden={() => {
|
||||
setMounted(false)
|
||||
onAfterClose?.()
|
||||
}}
|
||||
onHidden={handleHidden}
|
||||
dragContentToDismiss={dragContentToDismiss}
|
||||
contentScrollable={contentScrollable}
|
||||
fillAvailable={fillAvailable}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
vi.mock('./mounted-bottom-drawer', () => ({
|
||||
MountedBottomDrawer: 'MountedBottomDrawer'
|
||||
}))
|
||||
|
||||
function renderDrawer(
|
||||
visible: boolean,
|
||||
onClose: () => void,
|
||||
onAfterClose: () => void
|
||||
): ReactTestRenderer {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(
|
||||
BottomDrawer,
|
||||
{ visible, onClose, onAfterClose },
|
||||
createElement('DrawerContent')
|
||||
)
|
||||
)
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('Bottom drawer did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
function updateDrawer(
|
||||
renderer: ReactTestRenderer,
|
||||
visible: boolean,
|
||||
onClose: () => void,
|
||||
onAfterClose: () => void
|
||||
): void {
|
||||
act(() => {
|
||||
renderer.update(
|
||||
createElement(
|
||||
BottomDrawer,
|
||||
{ visible, onClose, onAfterClose },
|
||||
createElement('DrawerContent')
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function mountedDrawer(renderer: ReactTestRenderer) {
|
||||
return renderer.root.findByType('MountedBottomDrawer')
|
||||
}
|
||||
|
||||
describe('BottomDrawer close lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const originalConsoleError = console.error
|
||||
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const message = args[0]
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
(message.includes('react-test-renderer is deprecated') ||
|
||||
message.includes('The current testing environment is not configured to support act'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps close stable and delivers the latest action once after unmount', () => {
|
||||
const firstAfterClose = vi.fn()
|
||||
const rendered: { current?: ReactTestRenderer } = {}
|
||||
const latestAfterClose = vi.fn(() => {
|
||||
expect(rendered.current?.toJSON()).toBeNull()
|
||||
})
|
||||
const renderer = renderDrawer(true, vi.fn(), firstAfterClose)
|
||||
rendered.current = renderer
|
||||
const initialOnHidden = mountedDrawer(renderer).props.onHidden
|
||||
|
||||
updateDrawer(renderer, false, vi.fn(), firstAfterClose)
|
||||
const closingOnHidden = mountedDrawer(renderer).props.onHidden
|
||||
updateDrawer(renderer, false, vi.fn(), latestAfterClose)
|
||||
const rerenderedOnHidden = mountedDrawer(renderer).props.onHidden
|
||||
|
||||
expect(closingOnHidden).toBe(initialOnHidden)
|
||||
expect(rerenderedOnHidden).toBe(initialOnHidden)
|
||||
|
||||
act(() => {
|
||||
rerenderedOnHidden()
|
||||
rerenderedOnHidden()
|
||||
})
|
||||
act(() => {
|
||||
rerenderedOnHidden()
|
||||
})
|
||||
|
||||
expect(firstAfterClose).not.toHaveBeenCalled()
|
||||
expect(latestAfterClose).toHaveBeenCalledTimes(1)
|
||||
expect(renderer.toJSON()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -149,8 +149,13 @@ describe('edit host handleSave', () => {
|
||||
})
|
||||
|
||||
it('rename-only save updates only the name and does not reconnect', async () => {
|
||||
const storedEndpoint = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
|
||||
dependencies.loadHosts.mockResolvedValueOnce([{ ...HOST_FIXTURE, endpoint: storedEndpoint }])
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Address', ' wss://%64esk.example.com:443 ')
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
|
||||
expect(findText(renderer, `Connects to ${storedEndpoint}`)).toBe(true)
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import {
|
||||
handleRequest,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from '../scripts/mock-server-rpc-handlers'
|
||||
|
||||
function callRpc(method: string, params?: Record<string, unknown>): RpcResponse {
|
||||
let response: RpcResponse | undefined
|
||||
const request: RpcRequest = { id: 'request-1', method, ...(params ? { params } : {}) }
|
||||
handleRequest(
|
||||
request,
|
||||
(nextResponse) => {
|
||||
response = nextResponse
|
||||
},
|
||||
{} as WebSocket
|
||||
)
|
||||
expect(response).toBeDefined()
|
||||
return response!
|
||||
}
|
||||
|
||||
function listSessionTabs(worktree: string): RpcResponse {
|
||||
return callRpc('session.tabs.list', { worktree })
|
||||
}
|
||||
|
||||
describe('mock server session tabs fixture', () => {
|
||||
it('returns a contract-complete terminal surface for the requested worktree', () => {
|
||||
const response = listSessionTabs('id:repo-1::worktree-1')
|
||||
|
||||
expect(response.result).toEqual({
|
||||
worktree: 'repo-1::worktree-1',
|
||||
publicationEpoch: expect.stringMatching(/^mock-server:/),
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1'],
|
||||
recentTabIds: ['tab-1']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
title: 'zsh',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('passes a bare worktree selector through unprefixed', () => {
|
||||
const response = listSessionTabs('repo-1::worktree-1')
|
||||
|
||||
expect((response.result as { worktree: string }).worktree).toBe('repo-1::worktree-1')
|
||||
})
|
||||
|
||||
it('falls back to the same worktree terminal.list uses when no selector is sent', () => {
|
||||
const terminals = callRpc('terminal.list').result as {
|
||||
terminals: { worktreeId: string }[]
|
||||
}
|
||||
const expected = terminals.terminals[0]?.worktreeId
|
||||
expect(expected).toBeTruthy()
|
||||
|
||||
const tabs = callRpc('session.tabs.list').result as { worktree: string }
|
||||
expect(tabs.worktree).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
} from './local-notification-scheduling'
|
||||
import {
|
||||
adoptNotificationEpoch,
|
||||
catchUpWatermarkSeq,
|
||||
enqueueHostDelivery,
|
||||
getHostNotificationSession,
|
||||
quarantineCatchUpWatermark,
|
||||
releaseQueuedShowNotificationId,
|
||||
resolveCatchUpQuarantine,
|
||||
saveWatermark,
|
||||
seedWatermarkFromStorage,
|
||||
seenKeyForEvent,
|
||||
@@ -68,7 +71,10 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
releaseQueuedShowNotificationId(session, event.notificationId)
|
||||
}
|
||||
}
|
||||
})
|
||||
// Why swallowed: the caller is an un-awaited handler, so a rejected show would
|
||||
// surface as an unhandled rejection (a RN redbox) instead of being retried by
|
||||
// the next catch-up — which is now possible, since `seen` is marked after the show.
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function deliverLive(
|
||||
@@ -76,87 +82,127 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
event: NotificationEvent | DismissNotificationEvent
|
||||
): Promise<void> {
|
||||
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
|
||||
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key) {
|
||||
session.seen.add(key)
|
||||
}
|
||||
const epochAtDelivery = session.lastDeliveredEpoch
|
||||
if (type === 'notification') {
|
||||
await showLocalNotification(event as NotificationEvent, hostId)
|
||||
} else {
|
||||
await dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
}
|
||||
// Why after the await, exactly like the watermark below: `seen` asserts this event
|
||||
// reached the user (#8129). Marked before, a rejected show leaves the key behind and
|
||||
// every later replay is dropped as a duplicate — loss the quarantine cannot recover,
|
||||
// since the first event to drain a batch lifts it past the one never shown.
|
||||
const key = seenKeyForEvent(event)
|
||||
// A mid-flight epoch adoption already cleared the counter lifetime this key indexes.
|
||||
if (key && session.lastDeliveredEpoch === epochAtDelivery) {
|
||||
session.seen.add(key)
|
||||
}
|
||||
// Why after the await (#8591): the watermark is a promise that everything up
|
||||
// to this seq has been shown. Advancing it before the local notification lands
|
||||
// means a process death in between silently drops it — the next launch asks the
|
||||
// desktop for seq greater than one the user never saw.
|
||||
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
|
||||
session.lastDeliveredSeq = event.notificationSeq
|
||||
// Persisted as a pair: a seq is only trustworthy alongside the epoch it indexes.
|
||||
// Why clamped: while a failed catch-up's range is still unrecovered, persisting
|
||||
// the live seq would let the next catch-up ask from above the gap and the desktop
|
||||
// would cut it. resolveCatchUpQuarantine writes the held-back value on success.
|
||||
void saveWatermark(hostId, {
|
||||
seq: session.lastDeliveredSeq,
|
||||
seq: catchUpWatermarkSeq(session),
|
||||
epoch: session.lastDeliveredEpoch
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Claimed inline rather than via queueDelivery: the batch is already one queue
|
||||
// entry, and re-enqueueing per item is what let a live event cut in.
|
||||
async function deliverMissedEvent(
|
||||
event: NotificationEvent | DismissNotificationEvent
|
||||
): Promise<void> {
|
||||
// No pre-marking here either: deliverLive marks the key once the show lands.
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key && session.seen.has(key)) {
|
||||
return
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deliverLive('notification', event)
|
||||
} finally {
|
||||
releaseQueuedShowNotificationId(session, event.notificationId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'dismiss') {
|
||||
await deliverLive('dismiss', event)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap).
|
||||
async function fetchMissed(): Promise<void> {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Captured before the request: everything at or below it is known delivered, so
|
||||
// it is the floor the watermark falls back to if this catch-up never completes.
|
||||
const askFrom = catchUpWatermarkSeq(session)
|
||||
const missed = await client
|
||||
.sendRequest('notifications.getMissedSince', {
|
||||
lastSeenSeq: session.lastDeliveredSeq,
|
||||
lastSeenSeq: askFrom,
|
||||
// Why: sending the epoch lets the desktop reject a watermark from a counter
|
||||
// it no longer has and return the whole retained buffer instead of nothing.
|
||||
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return []
|
||||
return null
|
||||
}
|
||||
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
|
||||
adoptNotificationEpoch(session, hostId, result?.epoch)
|
||||
return Array.isArray(result?.notifications) ? result.notifications : []
|
||||
})
|
||||
.catch(() => [])
|
||||
.catch(() => null)
|
||||
if (missed == null) {
|
||||
// Why quarantine rather than retry: the range this catch-up abandoned stays
|
||||
// unrecovered until SOME later one succeeds, and a live seq persisting past it
|
||||
// meanwhile would make the desktop cut it forever.
|
||||
quarantineCatchUpWatermark(session, hostId, askFrom)
|
||||
return
|
||||
}
|
||||
// Why the whole batch is ONE queue entry (#8591): awaiting per event returns to
|
||||
// the event loop between replays, so a live seq 11 slots into the chain between
|
||||
// seq 6 and 7 and persists a watermark past a notification still unshown. Why the
|
||||
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
|
||||
// chain for that would stall live delivery on a slow link.
|
||||
await enqueueHostDelivery(session, async () => {
|
||||
for (const raw of missed) {
|
||||
// Re-checked per event: the batch can start before a teardown and still be
|
||||
// draining after it, and a torn-down host must stop pushing.
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const event = raw as NotificationEvent | DismissNotificationEvent
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key && session.seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
if (key) {
|
||||
session.seen.add(key)
|
||||
}
|
||||
// Claimed inline rather than via queueDelivery: the batch is already one
|
||||
// queue entry, and re-enqueueing per item is what let a live event cut in.
|
||||
if (event.type === 'notification') {
|
||||
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
|
||||
continue
|
||||
// Advances only past events this batch settled, so a teardown or a failing show
|
||||
// quarantines the true contiguous point instead of the range it never reached.
|
||||
let contiguousSeq = askFrom
|
||||
let drained = false
|
||||
try {
|
||||
for (const raw of missed) {
|
||||
// Re-checked per event: the batch can start before a teardown and still be
|
||||
// draining after it, and a torn-down host must stop pushing.
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deliverLive('notification', event)
|
||||
} finally {
|
||||
releaseQueuedShowNotificationId(session, event.notificationId)
|
||||
}
|
||||
} else if (event.type === 'dismiss') {
|
||||
await deliverLive('dismiss', event)
|
||||
const event = raw as NotificationEvent | DismissNotificationEvent
|
||||
await deliverMissedEvent(event)
|
||||
contiguousSeq = event.notificationSeq ?? contiguousSeq
|
||||
}
|
||||
drained = true
|
||||
} finally {
|
||||
if (drained) {
|
||||
resolveCatchUpQuarantine(session, hostId)
|
||||
} else {
|
||||
quarantineCatchUpWatermark(session, hostId, contiguousSeq)
|
||||
}
|
||||
}
|
||||
})
|
||||
// Why swallowed here: the `finally` above already recorded the contiguous point,
|
||||
// and the only caller is an un-awaited 'ready' continuation — letting a failed
|
||||
// show escape turns every one into an unhandled rejection (a RN redbox).
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
seedWatermarkFromStorage(session, hostId)
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function persistedSeq(): number {
|
||||
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
|
||||
}
|
||||
|
||||
type MissedOutcome =
|
||||
| { kind: 'reject' }
|
||||
| { kind: 'notOk' }
|
||||
| { kind: 'ok'; notifications: unknown[] }
|
||||
// Rejects only once `settle()` is called, so a live event can land mid-request.
|
||||
| { kind: 'heldReject' }
|
||||
|
||||
function makeHostClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const askedFrom: number[] = []
|
||||
let outcome: MissedOutcome = { kind: 'ok', notifications: [] }
|
||||
let releaseHeld: (() => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn(() => {
|
||||
onData = null
|
||||
})
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
|
||||
if (method !== 'notifications.getMissedSince') {
|
||||
return { ok: true, result: undefined } as never
|
||||
}
|
||||
askedFrom.push((params as { lastSeenSeq: number }).lastSeenSeq)
|
||||
if (outcome.kind === 'heldReject') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHeld = resolve
|
||||
})
|
||||
throw new Error('socket closed')
|
||||
}
|
||||
if (outcome.kind === 'reject') {
|
||||
throw new Error('socket closed')
|
||||
}
|
||||
if (outcome.kind === 'notOk') {
|
||||
return { ok: false, error: { message: 'timeout' } } as never
|
||||
}
|
||||
return { ok: true, result: { notifications: outcome.notifications } } as never
|
||||
})
|
||||
}
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
askedFrom,
|
||||
setOutcome(next: MissedOutcome) {
|
||||
outcome = next
|
||||
},
|
||||
settleHeld() {
|
||||
releaseHeld?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notification(seq: number) {
|
||||
return {
|
||||
type: 'notification',
|
||||
title: `m${seq}`,
|
||||
body: 'b',
|
||||
notificationId: `agent:${seq}`,
|
||||
notificationSeq: seq
|
||||
}
|
||||
}
|
||||
|
||||
describe('#8591 catch-up failure quarantines the watermark', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storage.clear()
|
||||
resetHostNotificationSessionsForTests()
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => {
|
||||
// The phone was offline while seqs 6-7 dispatched. The catch-up that would have
|
||||
// replayed them dies (socket close / timeout / ok:false), and live traffic keeps
|
||||
// flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by
|
||||
// `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the
|
||||
// window stays open until some catch-up succeeds, not for one round trip.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({ kind: 'reject' })
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(host.askedFrom).toEqual([5])
|
||||
|
||||
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(5)
|
||||
|
||||
// Second catch-up also fails; the gap is still open.
|
||||
host.setOutcome({ kind: 'notOk' })
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(host.askedFrom).toEqual([5, 5])
|
||||
expect(persistedSeq()).toBe(5)
|
||||
|
||||
// Third succeeds and replays the abandoned range.
|
||||
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(host.askedFrom).toEqual([5, 5, 5])
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
// Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents.
|
||||
// m11/m12 are the live events that kept flowing while the gap stayed open.
|
||||
expect(titles).toEqual(['m11', 'm12', 'm6', 'm7'])
|
||||
|
||||
// Only now may the watermark move past the recovered range.
|
||||
expect(persistedSeq()).toBe(12)
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(host.askedFrom).toEqual([5, 5, 5, 12])
|
||||
})
|
||||
|
||||
it('rolls back a watermark a live event stored while the catch-up was in flight', async () => {
|
||||
// getMissedSince waits up to 30s, so live traffic routinely persists during it.
|
||||
// Clamping only writes made AFTER the failure leaves that higher seq on disk, and
|
||||
// the next launch reads it back and resumes past the range this catch-up abandoned.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({ kind: 'heldReject' })
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(host.askedFrom).toEqual([5])
|
||||
|
||||
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(11)
|
||||
|
||||
host.settleHeld()
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(5)
|
||||
})
|
||||
|
||||
it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => {
|
||||
// The batch can start before a teardown and still be draining after it, so the
|
||||
// events past the interruption were never shown. A live seq arriving on the next
|
||||
// connection must not persist over them.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({
|
||||
kind: 'ok',
|
||||
notifications: [notification(6), notification(7), notification(8)]
|
||||
})
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
|
||||
if ((request as { content: { title: string } }).content.title === 'm6') {
|
||||
unsubscribe?.()
|
||||
}
|
||||
return 'sched-1'
|
||||
})
|
||||
|
||||
unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toEqual(['m6'])
|
||||
|
||||
// A fresh subscription on the same module-scope session takes a live seq 20 before
|
||||
// its own catch-up, then resumes from 6 rather than from 20.
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
|
||||
const host2 = makeHostClient()
|
||||
host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] })
|
||||
subscribeToDesktopNotifications(host2.client, 'host-1')
|
||||
host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(6)
|
||||
|
||||
host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(host2.askedFrom).toEqual([6])
|
||||
expect(
|
||||
vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
).toEqual(['m6', 'm20', 'm7', 'm8'])
|
||||
expect(persistedSeq()).toBe(20)
|
||||
})
|
||||
|
||||
it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => {
|
||||
// The quarantine only holds the RANGE. If the failing event is also marked seen,
|
||||
// the next catch-up re-fetches it and the dedup guard drops it — the banner is
|
||||
// never shown, and the first later event to drain the batch lifts the quarantine
|
||||
// past it. Silent loss with the watermark looking healthy.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
|
||||
|
||||
let failNext = true
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
|
||||
const title = (request as { content: { title: string } }).content.title
|
||||
if (title === 'm6' && failNext) {
|
||||
failNext = false
|
||||
throw new Error('scheduling rejected')
|
||||
}
|
||||
return 'sched-1'
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(5)
|
||||
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toEqual(['m6', 'm6', 'm7'])
|
||||
expect(host.askedFrom).toEqual([5, 5])
|
||||
expect(persistedSeq()).toBe(7)
|
||||
})
|
||||
|
||||
it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => {
|
||||
// The same hole without any catch-up failing: the live path marks seen before the
|
||||
// show, so a rejected show leaves the key behind while the watermark stays put.
|
||||
// The next catch-up dutifully re-fetches the seq and the guard eats it.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({ kind: 'ok', notifications: [] })
|
||||
|
||||
let failNext = true
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
throw new Error('scheduling rejected')
|
||||
}
|
||||
return 'sched-1'
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(5)
|
||||
|
||||
host.setOutcome({ kind: 'ok', notifications: [notification(6)] })
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toEqual(['m6', 'm6'])
|
||||
expect(persistedSeq()).toBe(6)
|
||||
})
|
||||
})
|
||||
@@ -135,6 +135,9 @@ export type HostNotificationSession = {
|
||||
// Counter lifetime lastDeliveredSeq belongs to; null until one is known. A
|
||||
// mismatch on reconnect means the desktop restarted and the watermark is void.
|
||||
lastDeliveredEpoch: string | null
|
||||
// Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is
|
||||
// outstanding; null when none has failed. See quarantineCatchUpWatermark.
|
||||
catchUpQuarantineSeq: number | null
|
||||
seen: ReturnType<typeof createSeenNotificationGuard>
|
||||
// False only until the host's first subscription reaches 'ready' — a true cold open.
|
||||
connectedBefore: boolean
|
||||
@@ -161,6 +164,7 @@ export function getHostNotificationSession(hostId: string): HostNotificationSess
|
||||
session = {
|
||||
lastDeliveredSeq: 0,
|
||||
lastDeliveredEpoch: null,
|
||||
catchUpQuarantineSeq: null,
|
||||
seen: createSeenNotificationGuard(),
|
||||
connectedBefore: false,
|
||||
hadStoredWatermark: false,
|
||||
@@ -239,6 +243,55 @@ export function resetHostNotificationSessionsForTests(): void {
|
||||
sessionsByHost.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the catch-up watermark at the last seq known delivered contiguously,
|
||||
* after a catch-up that did not complete.
|
||||
*
|
||||
* Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned
|
||||
* catch-up otherwise lets the NEXT one ask from above the range it gave up on —
|
||||
* the desktop cuts by seq, so those notifications are never replayed and are
|
||||
* gone. Lowest wins: an earlier failure's gap is still open.
|
||||
*/
|
||||
export function quarantineCatchUpWatermark(
|
||||
session: HostNotificationSession,
|
||||
hostId: string,
|
||||
contiguousSeq: number
|
||||
): void {
|
||||
session.catchUpQuarantineSeq =
|
||||
session.catchUpQuarantineSeq == null
|
||||
? contiguousSeq
|
||||
: Math.min(session.catchUpQuarantineSeq, contiguousSeq)
|
||||
// Why re-persist: a live event delivered while the catch-up was still in flight
|
||||
// already stored a seq above the gap. Clamping only later writes would leave that
|
||||
// value on disk, so a restart still resumes past the abandoned range.
|
||||
void saveWatermark(hostId, {
|
||||
seq: catchUpWatermarkSeq(session),
|
||||
epoch: session.lastDeliveredEpoch
|
||||
})
|
||||
}
|
||||
|
||||
/** Lift the quarantine once a catch-up completes, persisting what it held back. */
|
||||
export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void {
|
||||
if (session.catchUpQuarantineSeq == null) {
|
||||
return
|
||||
}
|
||||
session.catchUpQuarantineSeq = null
|
||||
void saveWatermark(hostId, {
|
||||
seq: session.lastDeliveredSeq,
|
||||
epoch: session.lastDeliveredEpoch
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The seq a catch-up may ask from and the highest seq safe to persist — the live
|
||||
* watermark, clamped to any open gap.
|
||||
*/
|
||||
export function catchUpWatermarkSeq(session: HostNotificationSession): number {
|
||||
return session.catchUpQuarantineSeq == null
|
||||
? session.lastDeliveredSeq
|
||||
: Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq)
|
||||
}
|
||||
|
||||
// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark
|
||||
// from a previous lifetime indexes a counter that no longer exists. Comparing it
|
||||
// against the fresh counter makes `lastSeenSeq >= seq` true for everything and
|
||||
@@ -262,6 +315,8 @@ export function adoptNotificationEpoch(
|
||||
// counter re-issues those same low seqs, so a stale `seq:1` would silently drop
|
||||
// the new counter's first bell. The dedup window belongs to one counter lifetime.
|
||||
session.seen.clear()
|
||||
// The quarantined gap indexed the dead counter; the watermark it guarded is gone too.
|
||||
session.catchUpQuarantineSeq = null
|
||||
session.lastDeliveredEpoch = epoch
|
||||
void saveWatermark(hostId, { seq: 0, epoch })
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
RESUME_RPC_TIMEOUT_MS
|
||||
resumeAiVaultSessionInTerminal
|
||||
} from './ai-vault-resume-launch'
|
||||
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
|
||||
|
||||
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
|
||||
return {
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
buildAiVaultResumeShellCommand,
|
||||
realHomeCodexResumeEnvDeletion
|
||||
} from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
isAiVaultPrepareSessionResumeUnavailableError,
|
||||
isLegacySharedCodexHome
|
||||
} from '../../../src/shared/ai-vault-resume-preparation'
|
||||
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
|
||||
import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume'
|
||||
import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume'
|
||||
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
|
||||
@@ -167,43 +164,6 @@ function normalizeMobileAiVaultResumeCommandOverrides(
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Why: without an explicit timeout, a socket drop mid-resume parks the request
|
||||
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
|
||||
export const RESUME_RPC_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function prepareMobileAiVaultSessionResume(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
session: AiVaultSession
|
||||
): Promise<AiVaultSession> {
|
||||
if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) {
|
||||
return session
|
||||
}
|
||||
const response = await client.sendRequest(
|
||||
'aiVault.prepareSessionResume',
|
||||
{
|
||||
agent: session.agent,
|
||||
filePath: session.filePath,
|
||||
codexHome: session.codexHome,
|
||||
executionHostId: session.executionHostId
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!response.ok) {
|
||||
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
|
||||
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
|
||||
return session
|
||||
}
|
||||
throw new Error(
|
||||
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
|
||||
)
|
||||
}
|
||||
const result = response.result as { useRealCodexHome?: unknown } | null
|
||||
if (result?.useRealCodexHome !== true) {
|
||||
return session
|
||||
}
|
||||
return { ...session, codexHome: null }
|
||||
}
|
||||
|
||||
export async function resumeAiVaultSessionInTerminal(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
|
||||
@@ -2,12 +2,15 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
prepareMobileAiVaultSessionResume,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
RESUME_RPC_TIMEOUT_MS
|
||||
resumeAiVaultSessionInTerminal
|
||||
} from './ai-vault-resume-launch'
|
||||
import {
|
||||
prepareMobileAiVaultSessionResume,
|
||||
RESUME_RPC_TIMEOUT_MS
|
||||
} from './ai-vault-resume-preparation'
|
||||
|
||||
const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home'
|
||||
const PER_ACCOUNT_HOME = '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
|
||||
|
||||
function legacySession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
|
||||
return {
|
||||
@@ -121,10 +124,11 @@ describe('prepareMobileAiVaultSessionResume', () => {
|
||||
{ agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' },
|
||||
{
|
||||
agent: 'codex' as const,
|
||||
codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
|
||||
codexHome: PER_ACCOUNT_HOME,
|
||||
executionHostId: 'ssh:server-1' as AiVaultSession['executionHostId']
|
||||
},
|
||||
{ agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' }
|
||||
])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => {
|
||||
])('does not prepare unrepinnable session $agent at $codexHome', async (overrides) => {
|
||||
const current = legacySession(overrides)
|
||||
const sendRequest = vi.fn()
|
||||
|
||||
@@ -132,6 +136,51 @@ describe('prepareMobileAiVaultSessionResume', () => {
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repins a per-account session to the home the host substitutes', async () => {
|
||||
const substituteHome = '/Users/ada/Library/Application Support/orca/codex-accounts/b/home'
|
||||
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { useRealCodexHome: false, substituteCodexHome: substituteHome }
|
||||
})
|
||||
|
||||
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, current)
|
||||
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'aiVault.prepareSessionResume',
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: current.filePath,
|
||||
codexHome: PER_ACCOUNT_HOME,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
expect(prepared.codexHome).toBe(substituteHome)
|
||||
expect(launch.command).toContain(`CODEX_HOME='${substituteHome}'`)
|
||||
})
|
||||
|
||||
it('keeps a per-account session home when an older host sends no repin', async () => {
|
||||
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { useRealCodexHome: false }
|
||||
})
|
||||
|
||||
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
|
||||
})
|
||||
|
||||
it('keeps a per-account session usable when the host cannot prepare at all', async () => {
|
||||
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' }
|
||||
})
|
||||
|
||||
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: 'internal_error',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
isAiVaultPrepareSessionResumeUnavailableError,
|
||||
isLegacySharedCodexHome,
|
||||
isPerAccountManagedCodexHome
|
||||
} from '../../../src/shared/ai-vault-resume-preparation'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
// Why: without an explicit timeout, a socket drop mid-resume parks the request
|
||||
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
|
||||
export const RESUME_RPC_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function prepareMobileAiVaultSessionResume(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
session: AiVaultSession
|
||||
): Promise<AiVaultSession> {
|
||||
// Why: per-account repinning runs on the serving host, whose account
|
||||
// selection only applies to that host's own ("local") sessions.
|
||||
const needsAccountRepin =
|
||||
isPerAccountManagedCodexHome(session.codexHome) &&
|
||||
(!session.executionHostId || session.executionHostId === LOCAL_EXECUTION_HOST_ID)
|
||||
if (
|
||||
session.agent !== 'codex' ||
|
||||
(!isLegacySharedCodexHome(session.codexHome) && !needsAccountRepin)
|
||||
) {
|
||||
return session
|
||||
}
|
||||
const response = await client.sendRequest(
|
||||
'aiVault.prepareSessionResume',
|
||||
{
|
||||
agent: session.agent,
|
||||
filePath: session.filePath,
|
||||
codexHome: session.codexHome,
|
||||
executionHostId: session.executionHostId
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!response.ok) {
|
||||
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
|
||||
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
|
||||
return session
|
||||
}
|
||||
throw new Error(
|
||||
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
|
||||
)
|
||||
}
|
||||
const result = response.result as {
|
||||
useRealCodexHome?: unknown
|
||||
substituteCodexHome?: unknown
|
||||
} | null
|
||||
if (result?.useRealCodexHome === true) {
|
||||
return { ...session, codexHome: null }
|
||||
}
|
||||
// Why: older hosts never send a repin home, so absence keeps the session's own home.
|
||||
if (typeof result?.substituteCodexHome === 'string' && result.substituteCodexHome) {
|
||||
return { ...session, codexHome: result.substituteCodexHome }
|
||||
}
|
||||
return session
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
CheckStatus,
|
||||
GitHubAssignableUser,
|
||||
GitHubPRCheckSummary,
|
||||
ProviderCheckSummary,
|
||||
GitHubPRMergeMethod,
|
||||
GitHubPRMergeMethodSettings,
|
||||
GitHubPRReviewSummary,
|
||||
@@ -182,12 +182,18 @@ export function readMergeMethodSettings(value: unknown): GitHubPRMergeMethodSett
|
||||
}
|
||||
}
|
||||
|
||||
export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefined {
|
||||
export function readCheckSummary(value: unknown): ProviderCheckSummary | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const state = value.state
|
||||
if (state !== 'success' && state !== 'failure' && state !== 'pending' && state !== 'none') {
|
||||
if (
|
||||
state !== 'success' &&
|
||||
state !== 'failure' &&
|
||||
state !== 'pending' &&
|
||||
state !== 'neutral' &&
|
||||
state !== 'none'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
@@ -195,6 +201,7 @@ export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefin
|
||||
total: readNumber(value.total) ?? 0,
|
||||
passed: readNumber(value.passed) ?? 0,
|
||||
failed: readNumber(value.failed) ?? 0,
|
||||
pending: readNumber(value.pending) ?? 0
|
||||
pending: readNumber(value.pending) ?? 0,
|
||||
neutral: readNumber(value.neutral) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
shouldAutoCreateInitialSessionTerminal,
|
||||
type InitialSessionTerminalAutoCreateInput
|
||||
} from './initial-session-terminal'
|
||||
|
||||
function baseInput(
|
||||
overrides: Partial<InitialSessionTerminalAutoCreateInput> = {}
|
||||
): InitialSessionTerminalAutoCreateInput {
|
||||
return {
|
||||
newlyCreatedWorkspace: true,
|
||||
connected: true,
|
||||
tabsLoaded: true,
|
||||
visibleTabCount: 0,
|
||||
hasActiveTerminalHandle: false,
|
||||
createInFlight: false,
|
||||
sawSessionTabs: false,
|
||||
autoCreatedForWorktree: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays the route state the session screen actually holds
|
||||
* (`app/h/[hostId]/session/[worktreeId].tsx`): `applySessionTabs` publishes the
|
||||
* tab list and flips `terminalsLoaded`, `handleCloseSessionTab` prunes the
|
||||
* closed tab and nulls the active handle.
|
||||
*/
|
||||
class SessionRouteState {
|
||||
newlyCreatedWorkspace = true
|
||||
tabsLoaded = false
|
||||
tabIds: string[] = []
|
||||
activeHandle: string | null = null
|
||||
sawSessionTabs = false
|
||||
autoCreatedForWorktree = false
|
||||
|
||||
applySessionTabs(tabIds: string[], activeHandle: string | null = tabIds[0] ? 'pty-1' : null) {
|
||||
this.tabIds = tabIds
|
||||
this.activeHandle = activeHandle
|
||||
if (tabIds.length > 0) {
|
||||
this.sawSessionTabs = true
|
||||
}
|
||||
this.tabsLoaded = true
|
||||
}
|
||||
|
||||
closeSessionTab(tabId: string) {
|
||||
this.tabIds = this.tabIds.filter((candidate) => candidate !== tabId)
|
||||
this.activeHandle = null
|
||||
}
|
||||
|
||||
gate(): boolean {
|
||||
return shouldAutoCreateInitialSessionTerminal({
|
||||
newlyCreatedWorkspace: this.newlyCreatedWorkspace,
|
||||
connected: true,
|
||||
tabsLoaded: this.tabsLoaded,
|
||||
visibleTabCount: this.tabIds.length,
|
||||
hasActiveTerminalHandle: this.activeHandle !== null,
|
||||
createInFlight: false,
|
||||
sawSessionTabs: this.sawSessionTabs,
|
||||
autoCreatedForWorktree: this.autoCreatedForWorktree
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('shouldAutoCreateInitialSessionTerminal', () => {
|
||||
it('creates the first terminal for a workspace that hydrates with nothing', () => {
|
||||
const route = new SessionRouteState()
|
||||
route.applySessionTabs([])
|
||||
expect(route.gate()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not re-create after the user closes the last tab (#9717, #7345)', () => {
|
||||
const route = new SessionRouteState()
|
||||
route.applySessionTabs(['tab-1'])
|
||||
expect(route.gate()).toBe(false)
|
||||
|
||||
route.closeSessionTab('tab-1')
|
||||
expect(route.tabIds).toHaveLength(0)
|
||||
expect(route.activeHandle).toBeNull()
|
||||
// Emptiness that follows a populated list is a close, not a cold hydrate.
|
||||
expect(route.gate()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not re-create after the consumed creation route remounts empty', () => {
|
||||
const creationRoute = new SessionRouteState()
|
||||
creationRoute.applySessionTabs([])
|
||||
expect(creationRoute.gate()).toBe(true)
|
||||
creationRoute.newlyCreatedWorkspace = false
|
||||
creationRoute.autoCreatedForWorktree = true
|
||||
creationRoute.applySessionTabs(['tab-1'])
|
||||
creationRoute.closeSessionTab('tab-1')
|
||||
expect(creationRoute.gate()).toBe(false)
|
||||
|
||||
const reopenedRoute = new SessionRouteState()
|
||||
reopenedRoute.newlyCreatedWorkspace = creationRoute.newlyCreatedWorkspace
|
||||
reopenedRoute.applySessionTabs([])
|
||||
expect(reopenedRoute.gate()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not create from stale empty state while switching ordinary workspace routes', () => {
|
||||
expect(
|
||||
shouldAutoCreateInitialSessionTerminal(
|
||||
baseInput({
|
||||
newlyCreatedWorkspace: false,
|
||||
tabsLoaded: true,
|
||||
visibleTabCount: 0
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not re-create when the host drops every tab mid-session', () => {
|
||||
const route = new SessionRouteState()
|
||||
route.applySessionTabs(['tab-1', 'tab-2'])
|
||||
route.applySessionTabs([], null)
|
||||
expect(route.gate()).toBe(false)
|
||||
})
|
||||
|
||||
it('stays armed while an empty workspace is still loading', () => {
|
||||
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ tabsLoaded: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for the host connection', () => {
|
||||
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ connected: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('defers to a lagging snapshot that still has a streaming terminal', () => {
|
||||
expect(
|
||||
shouldAutoCreateInitialSessionTerminal(baseInput({ hasActiveTerminalHandle: true }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not stack a second create on an in-flight one', () => {
|
||||
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ createInFlight: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('fires at most once per workspace on this route', () => {
|
||||
expect(
|
||||
shouldAutoCreateInitialSessionTerminal(baseInput({ autoCreatedForWorktree: true }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not fire while tabs are visible', () => {
|
||||
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ visibleTabCount: 2 }))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// Mirrors the desktop `shouldAutoCreateInitialTerminal` gate: a newly created
|
||||
// workspace that hydrates empty gets one terminal so the session isn't blank.
|
||||
// Kept pure (no react-native imports) so the gate is unit-testable.
|
||||
|
||||
export type InitialSessionTerminalAutoCreateInput = {
|
||||
/** Navigation came directly from creating this workspace. */
|
||||
newlyCreatedWorkspace: boolean
|
||||
/** Host connection is up and an RPC client is attached. */
|
||||
connected: boolean
|
||||
/** At least one session-tab snapshot has been applied on this route. */
|
||||
tabsLoaded: boolean
|
||||
/** Session tabs currently rendered on the phone. */
|
||||
visibleTabCount: number
|
||||
/** A terminal is still streaming even though the tab list reads empty. */
|
||||
hasActiveTerminalHandle: boolean
|
||||
/** A terminal, browser, or markdown create is already in flight. */
|
||||
createInFlight: boolean
|
||||
/** This route has shown at least one session tab for this workspace. */
|
||||
sawSessionTabs: boolean
|
||||
/** This route already auto-created a terminal for this workspace. */
|
||||
autoCreatedForWorktree: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to auto-create the first terminal of a newly created mobile session.
|
||||
*
|
||||
* `sawSessionTabs` is the resurrection guard: emptiness that *follows* a
|
||||
* populated tab list was produced by a close (the user's, or the host dropping
|
||||
* a dead pane), and re-creating there spawns a brand-new terminal the user
|
||||
* never asked for — issues #9717 / #7345. Only a workspace that has shown
|
||||
* nothing since its creation route opened is eligible.
|
||||
*/
|
||||
export function shouldAutoCreateInitialSessionTerminal(
|
||||
input: InitialSessionTerminalAutoCreateInput
|
||||
): boolean {
|
||||
if (!input.newlyCreatedWorkspace || !input.connected || !input.tabsLoaded) {
|
||||
return false
|
||||
}
|
||||
if (input.visibleTabCount > 0 || input.hasActiveTerminalHandle) {
|
||||
return false
|
||||
}
|
||||
if (input.createInFlight || input.autoCreatedForWorktree) {
|
||||
return false
|
||||
}
|
||||
return !input.sawSessionTabs
|
||||
}
|
||||
@@ -10,6 +10,8 @@ function extname(filePath: string): string {
|
||||
const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'typescript',
|
||||
'.cts': 'typescript',
|
||||
'.mts': 'typescript',
|
||||
'.js': 'javascript',
|
||||
'.jsx': 'javascript',
|
||||
'.mjs': 'javascript',
|
||||
|
||||
@@ -11,9 +11,12 @@ import type { MobileDiffLine } from './mobile-diff-lines'
|
||||
describe('mobile file syntax highlighting', () => {
|
||||
it('detects common source languages from file paths', () => {
|
||||
expect(detectMobileFileLanguage('src/App.tsx')).toBe('typescript')
|
||||
expect(detectMobileFileLanguage('config/vitest.config.mts')).toBe('typescript')
|
||||
expect(detectMobileFileLanguage('C:\\repo\\scripts\\postinstall.CTS')).toBe('typescript')
|
||||
expect(detectMobileFileLanguage('scripts/deploy.sh')).toBe('shell')
|
||||
expect(detectMobileFileLanguage('Dockerfile')).toBe('dockerfile')
|
||||
expect(resolveMobileSyntaxLanguage('src/App.tsx')).toBe('typescript')
|
||||
expect(resolveMobileSyntaxLanguage('worktrees/feature/build.cts')).toBe('typescript')
|
||||
expect(resolveMobileSyntaxLanguage('Dockerfile')).toBe('plaintext')
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export type MobileNativeChatTab = {
|
||||
agentStatus?: AgentStatusEntry | null
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
launchDraftCreatedAt?: number
|
||||
}
|
||||
|
||||
/** Resolve a session tab to the transcript identity native chat needs, or
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
function sendResult(accepted: boolean, id = 'send'): RpcSuccess {
|
||||
return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
|
||||
@@ -101,3 +102,52 @@ describe('pasteMobileNativeChatImagePaths', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearing a parked multi-line launch draft before the image paste', () => {
|
||||
it('leads with the caller-sized burst instead of one Ctrl+U', async () => {
|
||||
// One Ctrl+U kills only the LAST line, so the draft's earlier lines would
|
||||
// survive and ride along with the image as part of the prompt body.
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true)])
|
||||
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png'],
|
||||
clearInput
|
||||
})
|
||||
|
||||
expect(client.calls[0]?.params.text).toBe(clearInput)
|
||||
expect(client.calls[0]?.params.text).not.toBe('\x15')
|
||||
})
|
||||
|
||||
it('clears once, before the paste — never between or after the image writes', async () => {
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
|
||||
const clearInput = buildAgentTuiClearInputForText('a\nb\nc')
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
|
||||
clearInput
|
||||
})
|
||||
|
||||
expect(client.calls.filter((call) => call.params.text === clearInput)).toHaveLength(1)
|
||||
expect(client.calls[0]?.params.text).toBe(clearInput)
|
||||
})
|
||||
|
||||
it('falls back to a single Ctrl+U when no draft is parked', async () => {
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true)])
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png']
|
||||
})
|
||||
|
||||
expect(client.calls[0]?.params.text).toBe('\x15')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@ type PasteImagesArgs = {
|
||||
/** Budget shared with the rest of the user action (the text body that follows, or
|
||||
* the send this is healing for). Omit to open a fresh one for this paste alone. */
|
||||
readonly deadline?: number
|
||||
/** Bytes for the leading clear. Defaults to a single Ctrl+U, which clears only
|
||||
* ONE logical line — callers holding a parked multi-line launch draft must
|
||||
* pass a burst, or its earlier lines survive and glue onto the message. */
|
||||
readonly clearInput?: string
|
||||
}
|
||||
|
||||
/** Clears the agent's unsubmitted input line, then pastes each uploaded image
|
||||
@@ -38,7 +42,8 @@ export async function pasteMobileNativeChatImagePaths({
|
||||
terminal,
|
||||
deviceToken,
|
||||
imagePaths,
|
||||
deadline: sharedDeadline
|
||||
deadline: sharedDeadline,
|
||||
clearInput
|
||||
}: PasteImagesArgs): Promise<boolean> {
|
||||
const mobileClient: MobileTerminalClient | null = deviceToken
|
||||
? { id: deviceToken, type: 'mobile' }
|
||||
@@ -49,7 +54,7 @@ export async function pasteMobileNativeChatImagePaths({
|
||||
// once and let each write draw from what's left.
|
||||
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
|
||||
for (const text of [
|
||||
MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
...imagePaths.map(buildMobileImagePastePayload)
|
||||
]) {
|
||||
const remainingMs = deadline - Date.now()
|
||||
|
||||
@@ -5,9 +5,11 @@ import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-clien
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
|
||||
openMobileNativeChatSendBudget,
|
||||
clearMobileNativeChatInput,
|
||||
sendMobileNativeChatMessage,
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
function clientWithResponse(response: unknown): RpcClient {
|
||||
return {
|
||||
@@ -29,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
|
||||
mobileClient: { id: 'device', type: 'mobile' }
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
@@ -38,6 +41,7 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
enter: true,
|
||||
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
|
||||
client: { id: 'device', type: 'mobile' }
|
||||
},
|
||||
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
|
||||
@@ -285,3 +289,83 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMobileNativeChatInput', () => {
|
||||
const accepted = {
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
const params = (client: RpcClient) =>
|
||||
vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string; enter: boolean }
|
||||
|
||||
it('writes the burst as its OWN non-submitting write', async () => {
|
||||
// Bundling the burst into the body write reached the agent as LITERAL Ctrl+U
|
||||
// text and the parked draft concatenated (observed live).
|
||||
const client = clientWithResponse(accepted)
|
||||
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
|
||||
await expect(
|
||||
clearMobileNativeChatInput({ client, terminal: 'term', clearInput })
|
||||
).resolves.toBe(true)
|
||||
expect(params(client)).toMatchObject({ text: clearInput, enter: false })
|
||||
})
|
||||
|
||||
it('reports failure when the host rejects the clear', async () => {
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: false } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
await expect(
|
||||
clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' })
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to start an underfunded clear rather than half-clearing', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await expect(
|
||||
clearMobileNativeChatInput({
|
||||
client,
|
||||
terminal: 'term',
|
||||
clearInput: '\x15',
|
||||
deadline: Date.now() + 10
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the body write never carries a multi-line burst', () => {
|
||||
const accepted = {
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
const sentText = (client: RpcClient): string =>
|
||||
(vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text
|
||||
|
||||
it('still prefixes only a single Ctrl+U when asked to clear first', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await sendMobileNativeChatMessage({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
clearInputFirst: true
|
||||
})
|
||||
expect(sentText(client)).toBe('\x15hello')
|
||||
})
|
||||
|
||||
it('never prefixes a clear when the caller already pasted (image sends)', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await sendMobileNativeChatMessage({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'caption',
|
||||
clearInputFirst: false
|
||||
})
|
||||
expect(sentText(client)).toBe('caption')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,11 @@ type MobileTerminalClient = {
|
||||
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
|
||||
// same byte before its body), so a launch-context prefill parked there cannot
|
||||
// concatenate with a mobile chat message. The host writes text bytes verbatim.
|
||||
//
|
||||
// One Ctrl+U clears ONE logical line, which is all this prefix can do. A parked
|
||||
// launch draft is routinely multi-line (every Linear block is); callers that know
|
||||
// one is parked must call clearMobileNativeChatInput FIRST — see
|
||||
// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law.
|
||||
const CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
type MobileNativeChatSendArgs = {
|
||||
@@ -19,6 +24,8 @@ type MobileNativeChatSendArgs = {
|
||||
text: string
|
||||
enter?: boolean
|
||||
clearInputFirst?: boolean
|
||||
/** Exact host launch draft this submitting write resolves when accepted. */
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
mobileClient?: MobileTerminalClient
|
||||
/** Shared budget for a whole user action (heal → paste → text, or one selector's
|
||||
* keystroke sequence). Omit to give this write its own full budget. */
|
||||
@@ -59,6 +66,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
|
||||
terminal: args.terminal,
|
||||
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
|
||||
enter: args.enter ?? true,
|
||||
...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}),
|
||||
...(args.mobileClient ? { client: args.mobileClient } : {})
|
||||
},
|
||||
// The budget covers this whole write, reconnect wait included — a chat send
|
||||
@@ -84,3 +92,42 @@ export async function sendMobileNativeChatMessage(
|
||||
): Promise<boolean> {
|
||||
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the agent's input line as its OWN write, before any body.
|
||||
*
|
||||
* Why not prefix it onto the body write: a multi-line clear burst bundled into
|
||||
* the same `terminal.send` as the text reached the agent as LITERAL Ctrl+U
|
||||
* characters — the draft survived and the burst landed in the middle of the
|
||||
* message (observed live: draft + 21 literal \x15 + body). A standalone write is
|
||||
* the shape the image paste has always used, and it clears as intended.
|
||||
*/
|
||||
export async function clearMobileNativeChatInput(args: {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
clearInput: string
|
||||
mobileClient?: MobileTerminalClient
|
||||
deadline?: number
|
||||
}): Promise<boolean> {
|
||||
const timeoutMs =
|
||||
args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now()
|
||||
if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const response = await args.client.sendRequest(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: args.terminal,
|
||||
text: args.clearInput,
|
||||
enter: false,
|
||||
...(args.mobileClient ? { client: args.mobileClient } : {})
|
||||
},
|
||||
{ timeoutMs, budgetSpansConnect: true }
|
||||
)
|
||||
return isTerminalSendRpcAccepted(response)
|
||||
} catch {
|
||||
// A failed clear must not send the body on top of an uncleared line.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
|
||||
|
||||
describe('isTerminalPhoneDisplayMode', () => {
|
||||
it('uses phone mode for automatic, phone, and unreported terminals', () => {
|
||||
const modes = new Map([
|
||||
['auto', 'auto'],
|
||||
['phone', 'phone']
|
||||
] as const)
|
||||
|
||||
expect(isTerminalPhoneDisplayMode('auto', modes)).toBe(true)
|
||||
expect(isTerminalPhoneDisplayMode('phone', modes)).toBe(true)
|
||||
expect(isTerminalPhoneDisplayMode('missing', modes)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects absent handles and desktop terminals', () => {
|
||||
const modes = new Map([['desktop', 'desktop']] as const)
|
||||
|
||||
expect(isTerminalPhoneDisplayMode(null, modes)).toBe(false)
|
||||
expect(isTerminalPhoneDisplayMode('desktop', modes)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,17 @@ export function isGestureMouseTrackingMode(
|
||||
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
|
||||
}
|
||||
|
||||
export function isTerminalPhoneDisplayMode(
|
||||
handle: string | null,
|
||||
terminalModes: ReadonlyMap<string, 'auto' | 'phone' | 'desktop'>
|
||||
): boolean {
|
||||
if (!handle) {
|
||||
return false
|
||||
}
|
||||
const mode = terminalModes.get(handle)
|
||||
return mode === undefined || mode === 'auto' || mode === 'phone'
|
||||
}
|
||||
|
||||
export function getActiveTabIdForHandle(
|
||||
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
|
||||
terminalHandle: string | null
|
||||
|
||||
@@ -9,6 +9,10 @@ const reconciliationHookSource = readFileSync(
|
||||
new URL('./use-mobile-session-tabs-reconciliation.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const autoCreateHookSource = readFileSync(
|
||||
new URL('./use-initial-session-terminal-autocreate.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function sliceBetween(startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
@@ -19,18 +23,49 @@ function sliceBetween(startPattern: string, endPattern: string): string {
|
||||
}
|
||||
|
||||
describe('mobile session startup', () => {
|
||||
it('auto-creates one terminal for an initially empty connected session', () => {
|
||||
expect(source).toContain('const initialEmptySessionAutoCreateRef = useRef<string | null>(null)')
|
||||
expect(source).toContain('initialEmptySessionAutoCreateRef.current = null')
|
||||
|
||||
const autoCreateEffect = sliceBetween(
|
||||
'if (\n !client ||\n !showEmptyState',
|
||||
'const terminalSummary ='
|
||||
it('auto-creates one terminal for a newly created empty session', () => {
|
||||
expect(source).toContain('useWorktreeSessionTabsLoaded(worktreeId)')
|
||||
expect(source).toContain(
|
||||
'initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()'
|
||||
)
|
||||
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current === worktreeId')
|
||||
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current = worktreeId')
|
||||
expect(autoCreateEffect).toContain("setCreateError('')")
|
||||
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
|
||||
|
||||
const autoCreateCall = sliceBetween(
|
||||
'useInitialSessionTerminalAutoCreate({',
|
||||
'const connectionVerdict ='
|
||||
)
|
||||
expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef')
|
||||
expect(autoCreateCall).toContain(
|
||||
'consumeCreationRoute: () => router.setParams({ created: undefined })'
|
||||
)
|
||||
expect(autoCreateCall).toContain("newlyCreatedWorkspace: created === '1'")
|
||||
expect(autoCreateCall).toContain('visibleTabCount: visibleTabs.length')
|
||||
expect(autoCreateCall).toContain('createTerminal: () => void handleCreateTerminal()')
|
||||
|
||||
expect(autoCreateHookSource).toContain('shouldAutoCreateInitialSessionTerminal({')
|
||||
expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree === worktreeId')
|
||||
expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree = worktreeId')
|
||||
expect(autoCreateHookSource).toContain("connState === 'connected'")
|
||||
expect(autoCreateHookSource).toContain('(visibleTabCount > 0 || activeHandle !== null)')
|
||||
// Why: both callbacks are re-created every render, so the effect must reach them
|
||||
// through useEffectEvent rather than deps or a render-time ref write.
|
||||
expect(autoCreateHookSource).toContain('useEffectEvent(args.consumeCreationRoute)')
|
||||
expect(autoCreateHookSource).toContain('useEffectEvent(args.createTerminal)')
|
||||
expect(autoCreateHookSource).toContain('consumeCreationRoute()')
|
||||
expect(autoCreateHookSource).toContain('createTerminal()')
|
||||
})
|
||||
|
||||
it('arms the auto-create only until the route has published a tab (#9717)', () => {
|
||||
// Emptiness after a populated list is a close, not a cold hydrate.
|
||||
expect(source).toContain(
|
||||
'initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0'
|
||||
)
|
||||
|
||||
const autoCreateCall = sliceBetween(
|
||||
'useInitialSessionTerminalAutoCreate({',
|
||||
'const connectionVerdict ='
|
||||
)
|
||||
expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef')
|
||||
expect(autoCreateHookSource).toContain('sawSessionTabs: stateRef.current.sawSessionTabs')
|
||||
})
|
||||
|
||||
it('delegates stream ownership while retaining the exact terminal polling cadence', () => {
|
||||
|
||||
@@ -9,24 +9,38 @@ vi.mock('lucide-react-native', () => ({
|
||||
SquareTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
type SheetArgs = Parameters<typeof getMobileTerminalActionSheetActions>[0]
|
||||
|
||||
function buildActions(overrides: Partial<SheetArgs> = {}) {
|
||||
return getMobileTerminalActionSheetActions({
|
||||
target: { handle: 'terminal-1' },
|
||||
tabs: [],
|
||||
isTabChatView: () => false,
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
onDismiss: vi.fn(),
|
||||
onToggleChat: vi.fn(),
|
||||
isPhoneMode: () => false,
|
||||
onToggleDisplayMode: vi.fn(),
|
||||
onRename: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onCloseSessionTab: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
const terminalTab = (id: string, handle: string | null) => ({
|
||||
type: 'terminal',
|
||||
id,
|
||||
terminal: handle
|
||||
})
|
||||
|
||||
describe('getMobileTerminalActionSheetActions', () => {
|
||||
it('defers Rename until after the action sheet closes', () => {
|
||||
const target = { handle: 'terminal-1' }
|
||||
const onDismiss = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
const actions = getMobileTerminalActionSheetActions({
|
||||
target,
|
||||
tabs: [],
|
||||
isTabChatView: () => false,
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
onDismiss,
|
||||
onToggleChat: vi.fn(),
|
||||
isPhoneMode: () => false,
|
||||
onToggleDisplayMode: vi.fn(),
|
||||
onRename,
|
||||
onClear: vi.fn(),
|
||||
onClose: vi.fn()
|
||||
})
|
||||
const actions = buildActions({ target, onDismiss, onRename })
|
||||
|
||||
const rename = actions.find((action) => action.label === 'Rename')
|
||||
expect(rename).toMatchObject({ closeBeforePress: true })
|
||||
@@ -35,4 +49,51 @@ describe('getMobileTerminalActionSheetActions', () => {
|
||||
expect(onRename).toHaveBeenCalledWith(target)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: handle-only close lets the pending-terminal effect resurrect the tab (#6927, #7345).
|
||||
it('closes the handle through its session tab, not the handle-only path', () => {
|
||||
const onClose = vi.fn()
|
||||
const onCloseSessionTab = vi.fn()
|
||||
const tab = terminalTab('tab-1::leaf-1', 'terminal-1')
|
||||
const actions = buildActions({
|
||||
target: { handle: 'terminal-1' },
|
||||
tabs: [tab],
|
||||
onClose,
|
||||
onCloseSessionTab
|
||||
})
|
||||
|
||||
actions.find((action) => action.label === 'Close')?.onPress()
|
||||
expect(onCloseSessionTab).toHaveBeenCalledWith(tab)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the split leaf that owns the handle, not its sibling', () => {
|
||||
const onCloseSessionTab = vi.fn()
|
||||
const first = terminalTab('tab-1::leaf-1', 'terminal-1')
|
||||
const second = terminalTab('tab-1::leaf-2', 'terminal-2')
|
||||
const actions = buildActions({
|
||||
target: { handle: 'terminal-2' },
|
||||
tabs: [first, second],
|
||||
onCloseSessionTab
|
||||
})
|
||||
|
||||
actions.find((action) => action.label === 'Close')?.onPress()
|
||||
expect(onCloseSessionTab).toHaveBeenCalledWith(second)
|
||||
})
|
||||
|
||||
it('falls back to the handle close when no session tab owns the handle', () => {
|
||||
const onClose = vi.fn()
|
||||
const onCloseSessionTab = vi.fn()
|
||||
const target = { handle: 'terminal-9' }
|
||||
const actions = buildActions({
|
||||
target,
|
||||
tabs: [terminalTab('tab-1::leaf-1', 'terminal-1'), terminalTab('tab-2::leaf-1', null)],
|
||||
onClose,
|
||||
onCloseSessionTab
|
||||
})
|
||||
|
||||
actions.find((action) => action.label === 'Close')?.onPress()
|
||||
expect(onClose).toHaveBeenCalledWith(target)
|
||||
expect(onCloseSessionTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,12 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null }
|
||||
|
||||
/** Builds the terminal long-press menu without adding another action block to the
|
||||
* already dense session route. Native chat stays first as the view switch. */
|
||||
export function getMobileTerminalActionSheetActions<Target extends { handle: string }>(args: {
|
||||
export function getMobileTerminalActionSheetActions<
|
||||
Target extends { handle: string },
|
||||
Tab extends TerminalTab
|
||||
>(args: {
|
||||
target: Target | null
|
||||
tabs: readonly TerminalTab[]
|
||||
tabs: readonly Tab[]
|
||||
isTabChatView: (tabId: string) => boolean
|
||||
nativeChatTranscriptIsLocalReadable: boolean
|
||||
onDismiss: () => void
|
||||
@@ -18,7 +21,10 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
|
||||
onToggleDisplayMode: (handle: string) => void
|
||||
onRename: (target: Target) => void
|
||||
onClear: (target: Target) => void
|
||||
/** Fallback for a live handle with no matching session tab. */
|
||||
onClose: (target: Target) => void
|
||||
/** Preferred path runs host teardown and records the local tombstone. */
|
||||
onCloseSessionTab: (tab: Tab) => void
|
||||
/** Appended after Close; receives the pressed tab's id so the session route's
|
||||
* bulk-close builder can resolve the anchor itself. */
|
||||
bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[]
|
||||
@@ -28,6 +34,7 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
|
||||
return []
|
||||
}
|
||||
const phoneMode = args.isPhoneMode(target.handle)
|
||||
const sessionTab = args.tabs.find((tab) => tab.terminal === target.handle)
|
||||
return [
|
||||
...getMobileNativeChatToggleActions({
|
||||
terminalHandle: target.handle,
|
||||
@@ -65,12 +72,13 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
args.onDismiss()
|
||||
if (sessionTab) {
|
||||
args.onCloseSessionTab(sessionTab)
|
||||
return
|
||||
}
|
||||
args.onClose(target)
|
||||
}
|
||||
},
|
||||
...(args.bulkCloseActions?.(
|
||||
args.tabs.find((tab) => tab.terminal === target.handle)?.id,
|
||||
args.onDismiss
|
||||
) ?? [])
|
||||
...(args.bulkCloseActions?.(sessionTab?.id, args.onDismiss) ?? [])
|
||||
]
|
||||
}
|
||||
|
||||
@@ -102,12 +102,14 @@ describe('mobile terminal records', () => {
|
||||
}
|
||||
const seeded: MobileTerminalSessionTab = {
|
||||
...base,
|
||||
launchDraft: 'https://github.com/o/r/issues/12'
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
launchDraftCreatedAt: 1
|
||||
}
|
||||
|
||||
expect(mobileSessionTabsEqual([base], [seeded])).toBe(false)
|
||||
expect(mobileSessionTabsEqual([seeded], [base])).toBe(false)
|
||||
expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true)
|
||||
expect(mobileSessionTabsEqual([seeded], [{ ...seeded, launchDraftCreatedAt: 2 }])).toBe(false)
|
||||
})
|
||||
|
||||
it('treats terminal agent-status changes as session-tab changes', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export type MobileTerminalSessionTab = {
|
||||
agentStatus?: AgentStatusEntry | null
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
launchDraftCreatedAt?: number
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
isActive: boolean
|
||||
}
|
||||
@@ -89,6 +90,7 @@ function mobileSessionTabEqual(
|
||||
// A frame whose only delta is the launch draft appearing or retracting
|
||||
// still has to reach the chat composer.
|
||||
a.launchDraft === b.launchDraft &&
|
||||
a.launchDraftCreatedAt === b.launchDraftCreatedAt &&
|
||||
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
|
||||
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createElement, type RefObject } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
useInitialSessionTerminalAutoCreate,
|
||||
useWorktreeSessionTabsLoaded
|
||||
} from './use-initial-session-terminal-autocreate'
|
||||
|
||||
type HarnessProps = {
|
||||
newlyCreatedWorkspace: boolean
|
||||
terminalsLoaded: boolean
|
||||
visibleTabCount: number
|
||||
worktreeId: string
|
||||
connected?: boolean
|
||||
hasClient?: boolean
|
||||
}
|
||||
|
||||
describe('useInitialSessionTerminalAutoCreate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let stateRef: RefObject<{
|
||||
autoCreatedForWorktree: string | null
|
||||
sawSessionTabs: boolean
|
||||
}>
|
||||
const consumeCreationRoute = vi.fn()
|
||||
const createTerminal = vi.fn()
|
||||
|
||||
function Harness(props: HarnessProps): null {
|
||||
useInitialSessionTerminalAutoCreate({
|
||||
client: props.hasClient === false ? null : {},
|
||||
newlyCreatedWorkspace: props.newlyCreatedWorkspace,
|
||||
connState: props.connected === false ? 'disconnected' : 'connected',
|
||||
terminalsLoaded: props.terminalsLoaded,
|
||||
visibleTabCount: props.visibleTabCount,
|
||||
activeHandle: null,
|
||||
createInFlight: false,
|
||||
stateRef,
|
||||
worktreeId: props.worktreeId,
|
||||
consumeCreationRoute,
|
||||
createTerminal
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function render(props: HarnessProps): Promise<void> {
|
||||
await act(async () => {
|
||||
if (renderer) {
|
||||
renderer.update(createElement(Harness, props))
|
||||
} else {
|
||||
renderer = create(createElement(Harness, props))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
stateRef = {
|
||||
current: { autoCreatedForWorktree: null, sawSessionTabs: false }
|
||||
}
|
||||
consumeCreationRoute.mockClear()
|
||||
createTerminal.mockClear()
|
||||
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
|
||||
throw new Error(String(args[0]))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('waits for the new worktree snapshot after route reuse', async () => {
|
||||
await render({
|
||||
newlyCreatedWorkspace: false,
|
||||
terminalsLoaded: true,
|
||||
visibleTabCount: 0,
|
||||
worktreeId: 'existing'
|
||||
})
|
||||
await render({
|
||||
newlyCreatedWorkspace: true,
|
||||
terminalsLoaded: false,
|
||||
visibleTabCount: 0,
|
||||
worktreeId: 'new'
|
||||
})
|
||||
expect(consumeCreationRoute).not.toHaveBeenCalled()
|
||||
expect(createTerminal).not.toHaveBeenCalled()
|
||||
|
||||
await render({
|
||||
newlyCreatedWorkspace: true,
|
||||
terminalsLoaded: true,
|
||||
visibleTabCount: 0,
|
||||
worktreeId: 'new'
|
||||
})
|
||||
expect(consumeCreationRoute).not.toHaveBeenCalled()
|
||||
expect(createTerminal).toHaveBeenCalledOnce()
|
||||
|
||||
await render({
|
||||
newlyCreatedWorkspace: true,
|
||||
terminalsLoaded: true,
|
||||
visibleTabCount: 1,
|
||||
worktreeId: 'new'
|
||||
})
|
||||
expect(consumeCreationRoute).toHaveBeenCalledOnce()
|
||||
expect(createTerminal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('consumes a populated creation route before a later remount', async () => {
|
||||
await render({
|
||||
newlyCreatedWorkspace: true,
|
||||
terminalsLoaded: true,
|
||||
visibleTabCount: 1,
|
||||
worktreeId: 'new'
|
||||
})
|
||||
expect(consumeCreationRoute).toHaveBeenCalledOnce()
|
||||
expect(createTerminal).not.toHaveBeenCalled()
|
||||
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
stateRef = {
|
||||
current: { autoCreatedForWorktree: null, sawSessionTabs: false }
|
||||
}
|
||||
await render({
|
||||
newlyCreatedWorkspace: false,
|
||||
terminalsLoaded: true,
|
||||
visibleTabCount: 0,
|
||||
worktreeId: 'new'
|
||||
})
|
||||
expect(createTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ connected: false, hasClient: true },
|
||||
{ connected: true, hasClient: false }
|
||||
])(
|
||||
'keeps the creation route armed until a client reconnects (connected=$connected, hasClient=$hasClient)',
|
||||
async ({ connected, hasClient }) => {
|
||||
await render({
|
||||
newlyCreatedWorkspace: true,
|
||||
terminalsLoaded: true,
|
||||
visibleTabCount: 0,
|
||||
worktreeId: 'new',
|
||||
connected,
|
||||
hasClient
|
||||
})
|
||||
expect(consumeCreationRoute).not.toHaveBeenCalled()
|
||||
expect(createTerminal).not.toHaveBeenCalled()
|
||||
|
||||
await render({
|
||||
newlyCreatedWorkspace: true,
|
||||
terminalsLoaded: true,
|
||||
visibleTabCount: 0,
|
||||
worktreeId: 'new'
|
||||
})
|
||||
expect(consumeCreationRoute).not.toHaveBeenCalled()
|
||||
expect(createTerminal).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('useWorktreeSessionTabsLoaded', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let current: readonly [boolean, (loaded: boolean) => void] | null = null
|
||||
|
||||
function Harness({ worktreeId }: { worktreeId: string }): null {
|
||||
current = useWorktreeSessionTabsLoaded(worktreeId)
|
||||
return null
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
current = null
|
||||
})
|
||||
|
||||
it('does not carry a loaded snapshot across a reused route', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { worktreeId: 'existing' }))
|
||||
})
|
||||
act(() => current?.[1](true))
|
||||
expect(current?.[0]).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { worktreeId: 'new' }))
|
||||
})
|
||||
expect(current?.[0]).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useEffectEvent, useReducer, type RefObject } from 'react'
|
||||
import { shouldAutoCreateInitialSessionTerminal } from './initial-session-terminal'
|
||||
|
||||
type InitialSessionTerminalAutoCreateState = {
|
||||
autoCreatedForWorktree: string | null
|
||||
sawSessionTabs: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh auto-create bookkeeping. The route must re-create this on every worktree
|
||||
* change, or a revisit inherits the previous workspace's `sawSessionTabs`.
|
||||
*/
|
||||
export function createInitialSessionAutoCreateState(): InitialSessionTerminalAutoCreateState {
|
||||
return { autoCreatedForWorktree: null, sawSessionTabs: false }
|
||||
}
|
||||
|
||||
type InitialSessionTerminalAutoCreateArgs = {
|
||||
client: unknown
|
||||
newlyCreatedWorkspace: boolean
|
||||
connState: string
|
||||
terminalsLoaded: boolean
|
||||
visibleTabCount: number
|
||||
activeHandle: string | null
|
||||
createInFlight: boolean
|
||||
stateRef: RefObject<InitialSessionTerminalAutoCreateState>
|
||||
worktreeId: string
|
||||
consumeCreationRoute: () => void
|
||||
createTerminal: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the first terminal of a newly created mobile workspace that hydrates
|
||||
* empty, at most once per route. See initial-session-terminal.
|
||||
*/
|
||||
export function useInitialSessionTerminalAutoCreate(
|
||||
args: InitialSessionTerminalAutoCreateArgs
|
||||
): void {
|
||||
const {
|
||||
client,
|
||||
newlyCreatedWorkspace,
|
||||
connState,
|
||||
terminalsLoaded,
|
||||
visibleTabCount,
|
||||
activeHandle,
|
||||
createInFlight,
|
||||
stateRef,
|
||||
worktreeId
|
||||
} = args
|
||||
// Why: the route re-creates both callbacks every render; useEffectEvent keeps them
|
||||
// out of the deps without mutating a ref during render.
|
||||
const consumeCreationRoute = useEffectEvent(args.consumeCreationRoute)
|
||||
const createTerminal = useEffectEvent(args.createTerminal)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
newlyCreatedWorkspace &&
|
||||
client &&
|
||||
connState === 'connected' &&
|
||||
terminalsLoaded &&
|
||||
(visibleTabCount > 0 || activeHandle !== null)
|
||||
) {
|
||||
consumeCreationRoute()
|
||||
}
|
||||
if (
|
||||
!client ||
|
||||
!shouldAutoCreateInitialSessionTerminal({
|
||||
newlyCreatedWorkspace,
|
||||
connected: connState === 'connected',
|
||||
tabsLoaded: terminalsLoaded,
|
||||
visibleTabCount,
|
||||
hasActiveTerminalHandle: activeHandle !== null,
|
||||
createInFlight,
|
||||
sawSessionTabs: stateRef.current.sawSessionTabs,
|
||||
autoCreatedForWorktree: stateRef.current.autoCreatedForWorktree === worktreeId
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
stateRef.current.autoCreatedForWorktree = worktreeId
|
||||
createTerminal()
|
||||
}, [
|
||||
activeHandle,
|
||||
client,
|
||||
connState,
|
||||
createInFlight,
|
||||
newlyCreatedWorkspace,
|
||||
stateRef,
|
||||
terminalsLoaded,
|
||||
visibleTabCount,
|
||||
worktreeId
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks "tabs hydrated" per worktree so a reused route reports `false` until the
|
||||
* new workspace's own snapshot lands, rather than inheriting the old one's.
|
||||
*/
|
||||
export function useWorktreeSessionTabsLoaded(
|
||||
worktreeId: string
|
||||
): readonly [boolean, (loaded: boolean) => void] {
|
||||
const [loadedForWorktree, setLoaded] = useReducer(
|
||||
(_current: string | null, loaded: boolean) => (loaded ? worktreeId : null),
|
||||
null
|
||||
)
|
||||
return [loadedForWorktree === worktreeId, setLoaded]
|
||||
}
|
||||
@@ -35,6 +35,8 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
setComposerText: vi.fn(),
|
||||
pending: [],
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft: () => null,
|
||||
readSeededLaunchDraftSeed: () => null,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
@@ -327,6 +329,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
terminal: 'term-1',
|
||||
launchAgent: 'claude',
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
launchDraftCreatedAt: 7,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
@@ -385,6 +388,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
expect(draftsArgs.at(-1)).toMatchObject({
|
||||
tabId: 'tab-1',
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
launchDraftCreatedAt: 7,
|
||||
chatActive: true,
|
||||
transcriptLoading: false
|
||||
})
|
||||
|
||||
@@ -73,6 +73,9 @@ export type MobileNativeChatController = {
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
}
|
||||
|
||||
/** Owns mobile native-chat state and teardown outside the already dense session
|
||||
@@ -134,6 +137,8 @@ export function useMobileNativeChatController(args: {
|
||||
setComposerText: setChatComposerText,
|
||||
pending: chatPending,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
@@ -145,6 +150,7 @@ export function useMobileNativeChatController(args: {
|
||||
sessionId: activeChatSessionId,
|
||||
messages: nativeChatSession.messages,
|
||||
launchDraft: activeSessionTab?.launchDraft ?? null,
|
||||
launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? null,
|
||||
// Why: pass the raw draft plus this flag rather than nulling it off-chat —
|
||||
// a null is indistinguishable from a host retraction, and peeking at the
|
||||
// terminal view would permanently decline the prefill.
|
||||
@@ -243,6 +249,7 @@ export function useMobileNativeChatController(args: {
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
@@ -278,6 +285,7 @@ export function useMobileNativeChatController(args: {
|
||||
loadNativeChatFiles,
|
||||
handleNativeChatQuestionAnswer,
|
||||
handleNativeChatSend,
|
||||
handleNativeChatSendWithOutcome
|
||||
handleNativeChatSendWithOutcome,
|
||||
readSeededLaunchDraft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
sessionId = `session-${tabId}`,
|
||||
messages = [],
|
||||
launchDraft = null,
|
||||
launchDraftCreatedAt = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false
|
||||
}: {
|
||||
@@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
sessionId?: string | null
|
||||
messages?: NativeChatMessage[]
|
||||
launchDraft?: string | null
|
||||
launchDraftCreatedAt?: number | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
}): null {
|
||||
@@ -55,6 +57,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
sessionId,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading
|
||||
})
|
||||
@@ -97,6 +100,21 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
|
||||
it('captures the generation paired with the adopted text', async () => {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
launchDraft: 'issue link',
|
||||
launchDraftCreatedAt: 7
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 })
|
||||
})
|
||||
|
||||
it('does not overwrite typed composer text with a launch draft', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('typed first'))
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type UnconfirmedSend
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
||||
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
|
||||
export type MobileNativeChatPendingMessage = {
|
||||
id: string
|
||||
@@ -43,6 +45,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string | null
|
||||
launchDraftCreatedAt?: number | null
|
||||
/** Whether the tab is currently resolved to the chat view. Off-chat the
|
||||
* launch-draft effects hold their state instead of acting on it. */
|
||||
chatActive?: boolean
|
||||
@@ -55,6 +58,11 @@ export function useMobileNativeChatDrafts(args: {
|
||||
setComposerText: Dispatch<SetStateAction<string>>
|
||||
pending: MobileNativeChatPendingMessage[]
|
||||
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
|
||||
/** Launch-context text still believed to be parked on the agent's TUI input
|
||||
* line, or null once it has been declined or retired. Send paths size their
|
||||
* pre-clear from it, since one Ctrl+U clears only one logical line. */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
|
||||
/** Clear the composer at send time, before the RPC settles. */
|
||||
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
/** Put the text back after a definite rejection, unless newer edits exist. */
|
||||
@@ -73,6 +81,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
sessionId,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive = true,
|
||||
transcriptLoading
|
||||
} = args
|
||||
@@ -91,62 +100,15 @@ export function useMobileNativeChatDrafts(args: {
|
||||
activePendingKeyRef.current = pendingKey
|
||||
const mountedRef = useRef(false)
|
||||
|
||||
// Seeded launch-context text per tab; '' marks a permanent decline so a
|
||||
// cleared composer never resurrects the prefill.
|
||||
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
|
||||
|
||||
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
|
||||
// adopt it once as the composer draft so mobile shows the same context.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!draftKey ||
|
||||
!chatActive ||
|
||||
!launchDraft?.trim() ||
|
||||
seededLaunchDraftByKeyRef.current.has(draftKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: `session.tabs` carries launchDraft before the transcript read settles,
|
||||
// and an empty (or previous tab's) list would let the decline below misjudge
|
||||
// an already-submitted prefill — long enough for a send to duplicate it.
|
||||
if (transcriptLoading) {
|
||||
return
|
||||
}
|
||||
// A user turn already in the transcript means the one-line TUI prefill was
|
||||
// submitted or deliberately cleared; decline instead of resurrecting it.
|
||||
if (messages.some((message) => normalizedUserText(message) !== null)) {
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, '')
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
|
||||
|
||||
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
|
||||
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
|
||||
// it (desktop sent or reconciled it). User edits are always kept.
|
||||
useEffect(() => {
|
||||
// Same gates as the seed: off-chat there is no retraction to read (the tab
|
||||
// publishes no draft to us), and an untrusted transcript would wipe an
|
||||
// untouched copy on the strength of another tab's user turns.
|
||||
if (!draftKey || !chatActive || transcriptLoading) {
|
||||
return
|
||||
}
|
||||
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
|
||||
if (!seeded) {
|
||||
return
|
||||
}
|
||||
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
|
||||
if (!hasUserTurn && launchDraft?.trim()) {
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, '')
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
|
||||
const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({
|
||||
draftKey,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading,
|
||||
setDrafts
|
||||
})
|
||||
|
||||
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(value) => {
|
||||
@@ -358,6 +320,8 @@ export function useMobileNativeChatDrafts(args: {
|
||||
setComposerText,
|
||||
pending,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
@@ -69,6 +70,7 @@ function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): Hook
|
||||
showToast: vi.fn(),
|
||||
onSendError: vi.fn(),
|
||||
baseSend: vi.fn().mockResolvedValue('accepted'),
|
||||
readSeededLaunchDraft: () => null,
|
||||
sleep: async () => {},
|
||||
...overrides
|
||||
}
|
||||
@@ -201,6 +203,40 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
expect(hook!.attachments).toEqual([])
|
||||
})
|
||||
|
||||
it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => {
|
||||
// A single Ctrl+U kills only the last line, so the draft's earlier lines
|
||||
// would survive the clear and ride along with the image as prompt body.
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
sendResult(true),
|
||||
sendResult(true)
|
||||
])
|
||||
const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
mount(
|
||||
baseArgs({
|
||||
client: client as unknown as RpcClient,
|
||||
deviceTokenRef: { current: 'device-1' },
|
||||
readSeededLaunchDraft: () => draft
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('look at this')
|
||||
})
|
||||
|
||||
const firstSend = client.calls.find((c) => c.method === 'terminal.send')
|
||||
expect(firstSend?.params).toMatchObject({
|
||||
text: buildAgentTuiClearInputForText(draft),
|
||||
enter: false
|
||||
})
|
||||
expect(firstSend?.params.text).not.toBe('\x15')
|
||||
})
|
||||
|
||||
it('spends one budget across the image paste and the text body that follows', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
@@ -56,6 +57,10 @@ type Args = {
|
||||
imagePreviewUris?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text parked on the agent's TUI input line, or null. The
|
||||
* paste's leading clear must cover every line of it, or the draft's earlier
|
||||
* lines survive and ride along with the image. */
|
||||
readonly readSeededLaunchDraft: () => string | null
|
||||
readonly onAttachSuccess?: () => void
|
||||
readonly onError?: () => void
|
||||
// Injected so the settle between image paste and submit is instant in tests.
|
||||
@@ -106,6 +111,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
showToast,
|
||||
onSendError,
|
||||
baseSend,
|
||||
readSeededLaunchDraft,
|
||||
onAttachSuccess,
|
||||
onError,
|
||||
sleep = defaultSleep
|
||||
@@ -269,12 +275,16 @@ export function useMobileNativeChatImageAttachments({
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const seededLaunchDraft = readSeededLaunchDraft()
|
||||
const pasted = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
imagePaths: pendingImages.map((attachment) => attachment.path),
|
||||
deadline
|
||||
deadline,
|
||||
...(seededLaunchDraft
|
||||
? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) }
|
||||
: {})
|
||||
})
|
||||
if (!pasted) {
|
||||
// Keep the chips so the user can retry; the failed paste never submitted.
|
||||
@@ -351,6 +361,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
enabled,
|
||||
onError,
|
||||
onSendError,
|
||||
readSeededLaunchDraft,
|
||||
scopeKey,
|
||||
sleep
|
||||
]
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { normalizedUserText } from './mobile-native-chat-draft-reconcile'
|
||||
|
||||
export type MobileNativeChatLaunchDraftSeed = {
|
||||
text: string
|
||||
createdAt: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopting the host's launch-context prefill as the mobile composer draft, and
|
||||
* retiring it again once it is resolved elsewhere. Split out of the drafts hook
|
||||
* so the general draft/pending accounting stays separate from this one concern.
|
||||
*/
|
||||
export function useMobileNativeChatLaunchDraftSeed(args: {
|
||||
draftKey: string | null
|
||||
messages: readonly NativeChatMessage[]
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string | null
|
||||
launchDraftCreatedAt?: number | null
|
||||
chatActive: boolean
|
||||
transcriptLoading?: boolean
|
||||
setDrafts: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}): {
|
||||
/** Text still believed to be parked on the agent's TUI input line, or null
|
||||
* once declined or retired. Send paths size their pre-clear from it, since
|
||||
* one Ctrl+U clears only one logical line. */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
|
||||
} {
|
||||
const {
|
||||
draftKey,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading,
|
||||
setDrafts
|
||||
} = args
|
||||
|
||||
// Seeded launch-context text per tab; null marks a permanent decline so a
|
||||
// cleared composer never resurrects the prefill.
|
||||
const seededLaunchDraftByKeyRef = useRef(
|
||||
new Map<string, MobileNativeChatLaunchDraftSeed | null>()
|
||||
)
|
||||
|
||||
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
|
||||
// adopt it once as the composer draft so mobile shows the same context.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!draftKey ||
|
||||
!chatActive ||
|
||||
!launchDraft?.trim() ||
|
||||
seededLaunchDraftByKeyRef.current.has(draftKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: `session.tabs` carries launchDraft before the transcript read settles,
|
||||
// and an empty (or previous tab's) list would let the decline below misjudge
|
||||
// an already-submitted prefill — long enough for a send to duplicate it.
|
||||
if (transcriptLoading) {
|
||||
return
|
||||
}
|
||||
// A user turn already in the transcript means the TUI prefill was submitted
|
||||
// or deliberately cleared; decline instead of resurrecting it.
|
||||
if (messages.some((message) => normalizedUserText(message) !== null)) {
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, null)
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, {
|
||||
text: launchDraft,
|
||||
createdAt: launchDraftCreatedAt ?? null
|
||||
})
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
|
||||
)
|
||||
}, [
|
||||
chatActive,
|
||||
draftKey,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
messages,
|
||||
setDrafts,
|
||||
transcriptLoading
|
||||
])
|
||||
|
||||
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
|
||||
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
|
||||
// it (desktop sent or reconciled it). User edits are always kept.
|
||||
useEffect(() => {
|
||||
// Same gates as the seed: off-chat there is no retraction to read (the tab
|
||||
// publishes no draft to us), and an untrusted transcript would wipe an
|
||||
// untouched copy on the strength of another tab's user turns.
|
||||
if (!draftKey || !chatActive || transcriptLoading) {
|
||||
return
|
||||
}
|
||||
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
|
||||
if (!seeded) {
|
||||
return
|
||||
}
|
||||
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
|
||||
if (!hasUserTurn && launchDraft?.trim()) {
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, null)
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading])
|
||||
|
||||
// A missing or declined entry means there is nothing of ours on the TUI line.
|
||||
const readSeededLaunchDraft = useCallback(
|
||||
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null),
|
||||
[draftKey]
|
||||
)
|
||||
const readSeededLaunchDraftSeed = useCallback(
|
||||
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null),
|
||||
[draftKey]
|
||||
)
|
||||
|
||||
return { readSeededLaunchDraft, readSeededLaunchDraftSeed }
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Covers the wiring the image-attachments suite structurally cannot: that hook
|
||||
// injects its own baseSend stub, so it never observes the real send params.
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const sendWithOutcome = vi.fn()
|
||||
const clearInputWrite = vi.fn()
|
||||
vi.mock('./mobile-native-chat-send', () => ({
|
||||
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
|
||||
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
|
||||
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
|
||||
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS: 2_000
|
||||
}))
|
||||
vi.mock('./mobile-native-chat-stale-input', () => ({
|
||||
healMobileNativeChatStaleInput: () => Promise.resolve(true)
|
||||
}))
|
||||
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
type Send = ReturnType<typeof useMobileNativeChatMessageSend>
|
||||
|
||||
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
|
||||
describe('useMobileNativeChatMessageSend', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let api: Send | null = null
|
||||
|
||||
const mount = (
|
||||
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
|
||||
): void => {
|
||||
function Probe(): null {
|
||||
api = useMobileNativeChatMessageSend({
|
||||
client: { sendRequest: vi.fn() } as never,
|
||||
enabled: true,
|
||||
handleRef: { current: 'term' },
|
||||
deviceTokenRef: { current: 'device' },
|
||||
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend: () => {},
|
||||
restoreRejectedDraft: () => {},
|
||||
acceptSend: () => {},
|
||||
holdUnconfirmedSend: () => {},
|
||||
onSendError: () => {}
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe))
|
||||
})
|
||||
}
|
||||
|
||||
const sentArgs = (): {
|
||||
clearInputFirst?: boolean
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
} =>
|
||||
sendWithOutcome.mock.calls[0]![0] as {
|
||||
clearInputFirst?: boolean
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
}
|
||||
const clearArgs = (): { clearInput?: string } =>
|
||||
(clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string }
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
sendWithOutcome.mockReset()
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
clearInputWrite.mockReset()
|
||||
clearInputWrite.mockResolvedValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderer?.unmount()
|
||||
})
|
||||
renderer = null
|
||||
api = null
|
||||
})
|
||||
|
||||
it('sizes the pre-clear to every line of a parked launch draft', async () => {
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT))
|
||||
})
|
||||
|
||||
it('issues the burst as its OWN write, before the body', async () => {
|
||||
// Bundled into the body write it arrived as literal Ctrl+U text.
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearInputWrite).toHaveBeenCalledTimes(1)
|
||||
expect(sendWithOutcome).toHaveBeenCalledTimes(1)
|
||||
expect(clearInputWrite.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sendWithOutcome.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts without sending the body when the clear is rejected', async () => {
|
||||
// Sending on top of an uncleared line is exactly the concatenation bug.
|
||||
clearInputWrite.mockResolvedValue(false)
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
let result: boolean | undefined
|
||||
await act(async () => {
|
||||
result = await api!.send('hello')
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => {
|
||||
// A Ctrl+U written immediately before body text in the SAME write arrives as
|
||||
// a literal control character, so it would head the received message.
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(sentArgs().clearInputFirst).toBe(false)
|
||||
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
|
||||
})
|
||||
|
||||
it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(sentArgs().clearInputFirst).toBe(true)
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes no clear at all when nothing is parked on the line', async () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearInputWrite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the draft at send time, so a retired seed stops widening the clear', async () => {
|
||||
let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 }
|
||||
mount(() => parked)
|
||||
await act(async () => {
|
||||
await api!.send('first')
|
||||
})
|
||||
parked = null
|
||||
await act(async () => {
|
||||
await api!.send('second')
|
||||
})
|
||||
expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true })
|
||||
expect(clearInputWrite).toHaveBeenCalledTimes(1)
|
||||
expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false })
|
||||
})
|
||||
|
||||
it('does not clear an image send after the image was pasted', async () => {
|
||||
// A second clear here would wipe the image that was just pasted.
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('caption', ['file:///a.png'])
|
||||
})
|
||||
expect(clearInputWrite).not.toHaveBeenCalled()
|
||||
expect(sentArgs().clearInputFirst).toBe(false)
|
||||
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
|
||||
})
|
||||
|
||||
it('does not resolve a composer seed from a question-card answer', async () => {
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.answerQuestion('1')
|
||||
})
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useCallback, type MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
clearMobileNativeChatInput,
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
|
||||
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
export type MobileNativeChatMessageSend = {
|
||||
/** Composer send that syncs the draft (clear on send, restore on rejection). */
|
||||
@@ -32,6 +35,9 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
handleRef: MutableRefObject<string | null>
|
||||
deviceTokenRef: MutableRefObject<string | null>
|
||||
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
|
||||
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
|
||||
* at send time so the pre-clear can be sized to every line it occupies. */
|
||||
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
|
||||
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
|
||||
@@ -48,6 +54,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
handleRef,
|
||||
deviceTokenRef,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
@@ -95,6 +102,36 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
if (syncComposer) {
|
||||
clearDraftForSend(origin, text)
|
||||
}
|
||||
// Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears
|
||||
// only one logical line. Size the clear to the text Orca injected, with
|
||||
// slack — the user can also have typed into the TUI line directly, so that
|
||||
// line count is a lower bound. Mobile cannot read the agent's screen, so
|
||||
// there is no empty-line observable to confirm against here; the upper
|
||||
// bound plus the host's write acceptance is what makes it safe.
|
||||
//
|
||||
// The burst goes out as its OWN write: bundled into the body write it
|
||||
// arrived as literal Ctrl+U text and the draft concatenated (see
|
||||
// clearMobileNativeChatInput). A rejected clear aborts the send rather
|
||||
// than pasting on top of an uncleared line.
|
||||
const seededLaunchDraft = readSeededLaunchDraftSeed()
|
||||
if (seededLaunchDraft && !images?.length) {
|
||||
const cleared = await clearMobileNativeChatInput({
|
||||
client,
|
||||
terminal: handle,
|
||||
clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text),
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
if (!cleared) {
|
||||
if (syncComposer) {
|
||||
restoreRejectedDraft(origin, text)
|
||||
}
|
||||
onSendError('Message not sent')
|
||||
return 'rejected'
|
||||
}
|
||||
}
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
@@ -105,7 +142,20 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
// this message. An image send already led its own paste with Ctrl+U, and a
|
||||
// second one here would wipe the image it just pasted (desktop's image path
|
||||
// likewise clears once, before the paste, and never again).
|
||||
clearInputFirst: !images?.length,
|
||||
//
|
||||
// Also skipped once the dedicated clear above ran: the line is already
|
||||
// empty, and a Ctrl+U written immediately before body text in the SAME
|
||||
// write reaches the agent as a literal control character rather than a
|
||||
// keypress (observed live as a stray \x15 heading the received message).
|
||||
clearInputFirst: !images?.length && !seededLaunchDraft,
|
||||
...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
|
||||
? {
|
||||
resolvedLaunchDraft: {
|
||||
text: seededLaunchDraft.text,
|
||||
createdAt: seededLaunchDraft.createdAt
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
@@ -141,6 +191,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
handleRef,
|
||||
holdUnconfirmedSend,
|
||||
onSendError,
|
||||
readSeededLaunchDraftSeed,
|
||||
restoreRejectedDraft
|
||||
]
|
||||
)
|
||||
|
||||
@@ -31,6 +31,9 @@ type Args = {
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text parked on the agent's TUI input line, or null — sizes
|
||||
* the image paste's leading clear so a multi-line draft cannot ride along. */
|
||||
readonly readSeededLaunchDraft: () => string | null
|
||||
readonly showToast: (message: string, durationMs?: number) => void
|
||||
/** Native-chat send failures — rendered in the composer's inline banner. */
|
||||
readonly onNativeChatSendError: (message: string) => void
|
||||
@@ -54,6 +57,7 @@ export function useMobileSessionImageAttachments({
|
||||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend,
|
||||
nativeChatBaseSend,
|
||||
readSeededLaunchDraft,
|
||||
showToast,
|
||||
onNativeChatSendError,
|
||||
onSuccess,
|
||||
@@ -86,6 +90,7 @@ export function useMobileSessionImageAttachments({
|
||||
showToast,
|
||||
onSendError: onNativeChatSendError,
|
||||
baseSend: nativeChatBaseSend,
|
||||
readSeededLaunchDraft,
|
||||
onAttachSuccess: onSuccess,
|
||||
onError
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildGitHubCheckSummary } from './github-check-summary'
|
||||
import { buildGitLabCheckSummary } from './gitlab-check-summary'
|
||||
|
||||
describe('buildGitHubCheckSummary', () => {
|
||||
it('returns none for empty check lists', () => {
|
||||
@@ -8,7 +9,8 @@ describe('buildGitHubCheckSummary', () => {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
pending: 0
|
||||
pending: 0,
|
||||
neutral: 0
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,22 +26,35 @@ describe('buildGitHubCheckSummary', () => {
|
||||
total: 3,
|
||||
passed: 1,
|
||||
failed: 1,
|
||||
pending: 1
|
||||
pending: 1,
|
||||
neutral: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('marks all completed non-failing checks as successful', () => {
|
||||
it('keeps neutral and unknown terminal conclusions out of passed', () => {
|
||||
expect(
|
||||
buildGitHubCheckSummary([
|
||||
{ status: 'completed', conclusion: 'success' },
|
||||
{ status: 'completed', conclusion: 'neutral' }
|
||||
])
|
||||
).toEqual({
|
||||
state: 'success',
|
||||
state: 'neutral',
|
||||
total: 2,
|
||||
passed: 2,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
pending: 0
|
||||
pending: 0,
|
||||
neutral: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls up GitLab jobs with unknown terminal statuses as neutral', () => {
|
||||
expect(buildGitLabCheckSummary([{ status: 'success' }, { status: 'future_status' }])).toEqual({
|
||||
state: 'neutral',
|
||||
total: 2,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
neutral: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,12 @@ export type GitHubCheckLike = {
|
||||
}
|
||||
|
||||
export type GitHubCheckSummary = {
|
||||
state: 'success' | 'failure' | 'pending' | 'none'
|
||||
state: 'success' | 'failure' | 'pending' | 'neutral' | 'none'
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
neutral: number
|
||||
}
|
||||
|
||||
function isFailedCheck(check: GitHubCheckLike): boolean {
|
||||
@@ -28,18 +29,32 @@ function isPendingCheck(check: GitHubCheckLike): boolean {
|
||||
export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary {
|
||||
let failed = 0
|
||||
let pending = 0
|
||||
let neutral = 0
|
||||
|
||||
for (const check of checks) {
|
||||
if (isFailedCheck(check)) {
|
||||
if (isFailedCheck(check) || check.conclusion === 'action_required') {
|
||||
failed += 1
|
||||
} else if (isPendingCheck(check)) {
|
||||
pending += 1
|
||||
} else if (check.conclusion === 'success') {
|
||||
continue
|
||||
} else {
|
||||
neutral += 1
|
||||
}
|
||||
}
|
||||
|
||||
const total = checks.length
|
||||
const passed = Math.max(0, total - failed - pending)
|
||||
const state = total === 0 ? 'none' : failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success'
|
||||
const passed = total - failed - pending - neutral
|
||||
const state =
|
||||
total === 0
|
||||
? 'none'
|
||||
: failed > 0
|
||||
? 'failure'
|
||||
: pending > 0
|
||||
? 'pending'
|
||||
: neutral > 0
|
||||
? 'neutral'
|
||||
: 'success'
|
||||
|
||||
return { state, total, passed, failed, pending }
|
||||
return { state, total, passed, failed, pending, neutral }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user