From 26ee9bbfabe1334241e32defa6d27a89aec66f41 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:26:04 -0700 Subject: [PATCH] [CI] Reduce repeated test setup and unnecessary packaging (#27308) * [CI] Reduce repeated test setup and remove source text checks * [CI] Consolidate transcript tests and preserve fixture-only CI routing * [CI] Retarget reliability gate to consolidated transcript suite --- config/reliability-gates.jsonc | 10 +- config/scripts/pr-code-change-scope.mjs | 7 +- .../scripts/pr-unit-support-routing.test.mjs | 58 +- ...rees-create-execution-host-routing.test.ts | 229 ---- src/main/ipc/worktrees-forget-local.test.ts | 424 ++++++- .../worktrees-issue-command-overrides.test.ts | 144 ++- ...trees-preserved-branch-fork-remote.test.ts | 327 ------ ...rktrees-remove-host-disambiguation.test.ts | 513 -------- ...trees-setup-launch-sparse-checkout.test.ts | 320 ++++- ...rees-ssh-branch-conflict-suffixing.test.ts | 334 ------ ...ktrees-ssh-fork-push-target-remote.test.ts | 244 +++- .../ipc/worktrees-ssh-pr-head-fetch.test.ts | 248 +++- .../ipc/worktrees-ssh-setup-launch.test.ts | 406 ------- .../antigravity-readiness-transcripts.test.ts | 532 ++++++--- ...avity-screen-readiness-transcripts.test.ts | 151 --- .../claude-task-wakeup-indexed-read.test.ts | 83 -- ...cline-screen-readiness-transcripts.test.ts | 500 ++++++-- ...codex-header-readiness-transcripts.test.ts | 835 ++++++++----- .../runtime/codex-quiet-ready-screen.test.ts | 1032 ++++++++++------- .../codex-startup-dialog-transcripts.test.ts | 205 ---- .../freebuff-status-transcript.test.ts | 146 --- .../hermes-readiness-transcript.test.ts | 128 -- ...hed-agent-composer-readiness-codex.test.ts | 129 --- .../launched-agent-worker-start-grok.test.ts | 63 - .../runtime/muse-readiness-transcript.test.ts | 44 - .../runtime/omp-readiness-transcripts.test.ts | 714 ++++++++---- ...pencode-worker-composer-transcript.test.ts | 169 --- src/main/runtime/qoder-transcripts.test.ts | 80 -- .../messaging/check-delivery-history.test.ts | 46 - .../check-superseded-terminal.test.ts | 135 --- .../check-worker-consumer-fencing.test.ts | 384 +++++- .../dispatch-recipient-identity.test.ts | 137 --- .../run-bound-mailbox-safety.test.ts | 360 ++++++ .../run-bound-recipient-mail.test.ts | 189 --- .../messaging/send-dispatch-authority.test.ts | 249 ++++ .../messaging/send-receipt-plumbing.test.ts | 107 -- .../messaging/send-unbound-terminals.test.ts | 31 - .../messaging/settled-dispatch-mail.test.ts | 124 -- .../messaging/worker-report-authority.test.ts | 253 ---- .../antigravity-worker-lifecycle.test.ts | 102 -- .../context-only-dispatch-retry.test.ts | 58 - .../worker/legacy-dispatch-projection.test.ts | 122 -- .../self-dispatch-nesting-depth.test.ts | 71 -- .../worker-brief-agent-in-front.test.ts | 50 - .../worker/worker-list-run-scope-rpc.test.ts | 280 +++++ .../worker/worker-read-projection.test.ts | 38 - .../worker-release-ownership-guard.test.ts | 102 +- .../worker-start-terminal-target.test.ts | 100 ++ .../worker/zcode-worker-readiness.test.ts | 54 - .../screen-ruled-readiness-scope.test.ts | 32 - .../runtime/stale-title-display-clear.test.ts | 170 --- ...rminal-interactive-wait-visibility.test.ts | 789 +++++++------ .../tui-idle-hook-lane-runtime.test.ts | 588 ++++++---- ...-idle-launch-readiness-shell-title.test.ts | 95 -- .../zcode-readiness-transcript.test.ts | 100 -- ...ession.approval-and-start-failure.test.tsx | 277 +++++ ...turedSession.command-refusal-line.test.tsx | 96 -- ...ucturedSession.final-read-failure.test.tsx | 117 -- ...eChatStructuredSession.first-read.test.tsx | 148 --- ...ucturedSession.history-read-state.test.tsx | 238 ++++ ...tStructuredSession.newer-approval.test.tsx | 142 --- ...eChatStructuredSession.read-error.test.tsx | 169 --- ...StructuredSession.refusal-notices.test.tsx | 241 ++++ ...turedSession.start-failure-notice.test.tsx | 162 --- ...tiveChatStructuredSession.test-harness.tsx | 5 + .../lib/github-source-runtime-context.test.ts | 23 - .../src/lib/local-file-access.test.ts | 111 -- 67 files changed, 6917 insertions(+), 7653 deletions(-) delete mode 100644 src/main/ipc/worktrees-create-execution-host-routing.test.ts delete mode 100644 src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts delete mode 100644 src/main/ipc/worktrees-remove-host-disambiguation.test.ts delete mode 100644 src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts delete mode 100644 src/main/ipc/worktrees-ssh-setup-launch.test.ts delete mode 100644 src/main/runtime/antigravity-screen-readiness-transcripts.test.ts delete mode 100644 src/main/runtime/claude-task-wakeup-indexed-read.test.ts delete mode 100644 src/main/runtime/codex-startup-dialog-transcripts.test.ts delete mode 100644 src/main/runtime/freebuff-status-transcript.test.ts delete mode 100644 src/main/runtime/hermes-readiness-transcript.test.ts delete mode 100644 src/main/runtime/launched-agent-composer-readiness-codex.test.ts delete mode 100644 src/main/runtime/launched-agent-worker-start-grok.test.ts delete mode 100644 src/main/runtime/muse-readiness-transcript.test.ts delete mode 100644 src/main/runtime/opencode-worker-composer-transcript.test.ts delete mode 100644 src/main/runtime/qoder-transcripts.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts delete mode 100644 src/main/runtime/screen-ruled-readiness-scope.test.ts delete mode 100644 src/main/runtime/stale-title-display-clear.test.ts delete mode 100644 src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts delete mode 100644 src/main/runtime/zcode-readiness-transcript.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.first-read.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index db244cabe76..936a10b4f23 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -337,12 +337,12 @@ "invariant": "An ended tracked task stays pending until its wake-up and finishing turn end; missing wake-ups expire only after 60 idle seconds. Cancellation remains owned by the executing host, duplicate delivery cannot create a fresh completion veto, and fresh native rest can recover a lost finishing Stop.", "oracle": "Replay captured hook ordering through production HTTP entry points and native ready bytes through the actual automation observer. Check local/relay state, cancellation/new-turn fencing, fresh versus retained rest, bounded task/timer ownership, and zero global status reads during indexed runtime waits.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/tui-idle-hook-lane-runtime.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" ], "testFiles": [ "src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts", "src/main/runtime/tui-idle-claude-task-wakeup.test.ts", - "src/main/runtime/claude-task-wakeup-indexed-read.test.ts", + "src/main/runtime/tui-idle-hook-lane-runtime.test.ts", "src/shared/claude-owed-notification-resource-contract.test.ts", "src/shared/agent-hook-listener-claude-task-notification.test.ts", "src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts", @@ -358,7 +358,7 @@ ] }, { - "file": "src/main/runtime/claude-task-wakeup-indexed-read.test.ts", + "file": "src/main/runtime/tui-idle-hook-lane-runtime.test.ts", "assertions": [ "reads only its pane with 512 unrelated agents, including an empty index: pending=%s" ] @@ -376,10 +376,10 @@ "date": "2026-10-06", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/tui-idle-hook-lane-runtime.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts", "result": "passed", "durationSeconds": 8.62, - "summary": "Exact stored command with repository Vitest configuration passes 148 tests across eight files including out-of-order launch/end/delivery controls. Includes real HTTP/relay ingress, 12 blank/padded Agent/Bash/Monitor IDs, captured-native-rest automation completion, duplicate/cancellation controls, owner/phase gates, timer/task bounds and indexed reads. Three upstream interrupt suites pass 28 more tests separately." + "summary": "Exact stored command with repository Vitest configuration passes 148 tests across eight files including out-of-order launch/end/delivery controls. Includes real HTTP/relay ingress, 12 blank/padded Agent/Bash/Monitor IDs, captured-native-rest automation completion, duplicate/cancellation controls, owner/phase gates, timer/task bounds and indexed reads. Three upstream interrupt suites pass 28 more tests separately. This dated receipt is unchanged; command now names the relocated test selector, not a new run. Original 2026-10-06 command: ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" } ], "runtimeBudget": { diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index abb759c69f1..ddc84b4efb6 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -574,9 +574,12 @@ function isProductBundlePath(file, extraPrefixes) { return matchesPrefix(file, extraPrefixes) } -// This shared fixture is consumed only by unit suites and their placement rig. +// These shared fixtures are consumed only by unit tests. export function isUnitTestSupportSource(file) { - return file === 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' + return ( + file === 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' || + file === 'src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx' + ) } function isTestFile(file) { diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs index 318b74bc780..21fbdfd452c 100644 --- a/config/scripts/pr-unit-support-routing.test.mjs +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -3,30 +3,44 @@ import { classifyPrJobs } from './pr-code-change-scope.mjs' import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const harness = 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' +const structuredSessionHarness = + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx' -describe('shared web-session unit support routing', () => { - it('keeps unit checks and lint while excluding packaging for the exact harness', () => { - const result = classifyPrJobs([harness]) - expect(result.should_run).toBe(true) - expect(result.static_analysis).toBe(true) - expect(result.typecheck).toBe(true) - expect(result.test).toBe(true) - expect(result.package).toBe(false) - expect(result.package_windows).toBe(false) - }) - - it('retains packaging for unknown helpers and mixed product changes', () => { - for (const file of [ - 'src/renderer/src/runtime/web-session-tabs-sync-next-test-harness.ts', - 'src/renderer/src/runtime/web-session-tabs-sync.ts' - ]) { - const result = classifyPrJobs([harness, file]) - expect(result.should_run, file).toBe(true) - expect(result.test, file).toBe(true) - expect(result.package, file).toBe(true) - expect(result.package_windows, file).toBe(true) +describe('unit support routing', () => { + it.each([harness, structuredSessionHarness])( + 'keeps unit checks and lint while excluding packaging for %s', + (unitHarness) => { + const result = classifyPrJobs([unitHarness]) + expect(result.should_run).toBe(true) + expect(result.static_analysis).toBe(true) + expect(result.typecheck).toBe(true) + expect(result.test).toBe(true) + expect(result.package).toBe(false) + expect(result.package_windows).toBe(false) } - }) + ) + + it.each([harness, structuredSessionHarness])( + 'retains packaging for unknown helpers and mixed product changes with %s', + (unitHarness) => { + for (const file of [ + 'src/renderer/src/runtime/web-session-tabs-sync-next-test-harness.ts', + 'src/renderer/src/runtime/web-session-tabs-sync.ts', + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.next-test-harness.tsx', + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx', + 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', + 'src/main/browser/browser-route-tcp-egress.electron.test.ts' + ]) { + const result = classifyPrJobs([unitHarness, file]) + expect(result.should_run, file).toBe(true) + expect(result.test, file).toBe(true) + expect(result.package, file).toBe( + file !== 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts' + ) + expect(result.package_windows, file).toBe(true) + } + } + ) it('does not turn the exact unit harness into a two-app E2E', () => { expect(selectPrE2eSpecs([harness])).toEqual([]) diff --git a/src/main/ipc/worktrees-create-execution-host-routing.test.ts b/src/main/ipc/worktrees-create-execution-host-routing.test.ts deleted file mode 100644 index 36f1e816ea6..00000000000 --- a/src/main/ipc/worktrees-create-execution-host-routing.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - addWorktreeMock, - getActiveMultiplexerMock, - getSshGitProviderMock, - listWorktreesMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -const REMOTE_REPO_PATH = '/remote/repo' - -function makeRepo(fields: Record) { - return { - id: 'repo-1', - path: REMOTE_REPO_PATH, - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: 'origin/main', - ...fields - } -} - -function makeProvider(worktreePath: string) { - return { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - // A hit here reads as "branch already exists"; the create loop would then rename. - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: worktreePath, - head: 'abc123', - branch: 'refs/heads/wt', - isBare: false, - isMainWorktree: false - } - ]) - } -} - -function useRepo(repo: ReturnType): void { - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) -} - -describe('worktrees:create execution host routing', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('creates on the SSH host for a row that names it only as executionHostId', async () => { - // No `connectionId`: the raw read answered "local" and ran `git worktree add` on the client - // against `/remote/repo`. The runtime sibling already resolved this row remotely. - useRepo(makeRepo({ executionHostId: 'ssh:target-a' })) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(provider.addWorktree).toHaveBeenCalledTimes(1) - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('keeps two simultaneously registered SSH hosts apart', async () => { - useRepo(makeRepo({ executionHostId: 'ssh:target-b' })) - const providerA = makeProvider('/remote/repo-wt-a') - const providerB = makeProvider('/remote/repo-wt-b') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? providerA : connectionId === 'target-b' ? providerB : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(providerB.addWorktree).toHaveBeenCalledTimes(1) - expect(providerA.addWorktree).not.toHaveBeenCalled() - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a runtime row with no nested SSH target instead of creating locally', async () => { - useRepo(makeRepo({ executionHostId: 'runtime:env-1' })) - - await expect( - handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - ).rejects.toThrow('not dispatched by this process') - - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { - // `target-a` names a target inside env-1. A same-named one registered here is another machine, - // so creating through it lands the checkout on the wrong host. - useRepo(makeRepo({ executionHostId: 'runtime:env-1', connectionId: 'target-a' })) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await expect( - handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - ).rejects.toThrow('not dispatched by this process') - - expect(provider.addWorktree).not.toHaveBeenCalled() - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('answers local for a row that declares itself local while carrying a connection', async () => { - // A contradictory row: `getRepoSshConnectionId` lets `local` win, and the runtime sibling has - // always read it that way. The raw field sent it remote, so the two entry points disagreed. - useRepo( - makeRepo({ path: '/workspace/repo', executionHostId: 'local', connectionId: 'target-a' }) - ) - listWorktreesMock.mockResolvedValue([ - { - path: '/workspace/wt', - head: 'abc123', - branch: 'wt', - isBare: false, - isMainWorktree: false - } - ]) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(addWorktreeMock).toHaveBeenCalledTimes(1) - expect(provider.addWorktree).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index 95fdcc27b30..77e2557f0c6 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -14,7 +14,9 @@ import { killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock, - getSshPtyProviderMock + getSshPtyProviderMock, + parseOrcaYamlMock, + hasHooksFileMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' @@ -355,3 +357,423 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree execution-host disambiguation', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + } + const sshRepo = { + ...localRepo, + path: '/remote/repo', + displayName: 'ssh', + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: sshRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(localRepo) + store.getRepos.mockReturnValue([localRepo, sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-shared::/remote/feature-wt', + hostId: 'ssh:conn-1' + }) + + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a row whose execution host and connection id name different machines', async () => { + // #11163: everything below the handler picks the filesystem from `repo.connectionId` while the + // prune, the archive-hook route and the home guard come from the resolved execution host. This + // row would have listed a remote checkout on this client and deleted a same-named local path. + const brokenRepo = { + id: 'repo-host-only', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: null, + executionHostId: 'ssh:conn-1' as const + } + const provider = { + listWorktrees: vi.fn(), + removeWorktree: vi.fn(), + worktreeIsClean: vi.fn() + } + store.getRepo.mockReturnValue(brokenRepo) + store.getRepos.mockReturnValue([brokenRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-host-only::/remote/feature-wt', + force: true + }) + ).rejects.toThrow( + 'Refusing to delete worktree: repo repo-host-only names execution host ssh:conn-1, but its checkout is only reachable as local.' + ) + + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(provider.listWorktrees).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses the mirror row that names local while carrying a connection id', async () => { + const brokenRepo = { + id: 'repo-local-spelled', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + executionHostId: 'local' as const + } + store.getRepo.mockReturnValue(brokenRepo) + store.getRepos.mockReturnValue([brokenRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-local-spelled::/remote/feature-wt', + force: true + }) + ).rejects.toThrow( + 'Refusing to delete worktree: repo repo-local-spelled names execution host local, but its checkout is only reachable as ssh:conn-1.' + ) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a row owned by a runtime environment this process does not execute', async () => { + // `runtime:` deletes on that environment's own server. Its repo row looks local here — + // `connectionId` is null — so a host id that fell back to "local" would delete a same-named + // path on this client. + const runtimeRepo = { + id: 'repo-runtime-owned', + path: '/env/repo', + displayName: 'env', + badgeColor: '#000', + addedAt: 0, + connectionId: null, + executionHostId: 'runtime:env-1' as const + } + store.getRepo.mockReturnValue(runtimeRepo) + store.getRepos.mockReturnValue([runtimeRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-runtime-owned::/env/feature-wt', + force: true + }) + ).rejects.toThrow('Refusing to delete worktree: repo repo-runtime-owned names execution host') + + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('accepts a row whose two spellings differ only by percent-escaping', async () => { + // `getRepoExecutionHostId` returns the stored text while the connection id is re-encoded, so a + // byte compare would refuse this consistent row over a `%20`. + const repo = { + id: 'repo-escaped', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn one', + executionHostId: 'ssh:conn one' as const + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: repo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(repo) + store.getRepos.mockReturnValue([repo]) + getSshGitProviderMock.mockReturnValue(provider) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-escaped::/remote/feature-wt' + }) + + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + }) + + it('tears down the remote session when an ownerless remote worktree is deleted', async () => { + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: sshRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(sshRepo) + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + // Why: no WorktreeMeta means removal cannot read the owner back; the repo is the only host source. + store.getWorktreeMeta.mockReturnValue(undefined) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/remote/feature-wt' + }) + + // The whole point: without the repo's host the ownerless row would clear the local session instead. + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/remote/feature-wt', + 'ssh:conn-1' + ) + }) + + it('fails closed when duplicate repo ids are deleted without a host', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-shared::/remote/feature-wt' + }) + ).rejects.toThrow('Repo not found: repo-shared') + + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('inspects hooks on the requested host when repo ids collide', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n archive: remote-cleanup', + isBinary: false + }) + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) + + await expect( + handlers['hooks:check'](null, { + repoId: 'repo-shared', + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({ + status: 'ok', + hasHooks: true, + hooks: { scripts: { archive: 'remote-cleanup' } }, + mayNeedUpdate: false + }) + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(hasHooksFileMock).not.toHaveBeenCalled() + }) + + it('fails hook inspection closed when duplicate repo ids omit the host', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + + await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({ + status: 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + }) + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + expect(hasHooksFileMock).not.toHaveBeenCalled() + }) + + it('inspects setup-script imports on the requested host when repo ids collide', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + const fsProvider = { + readFile: vi.fn(async (filePath: string) => { + if (filePath === '/remote/repo/.superset/config.json') { + return { content: '{"setup":"remote setup"}', isBinary: false } + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }), + stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + store.getRepo.mockReturnValue(localRepo) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + + await expect( + handlers['hooks:inspectSetupScriptImports'](null, { + repoId: 'repo-shared', + hostId: 'ssh:conn-1' + }) + ).resolves.toContainEqual( + expect.objectContaining({ + provider: 'superset', + setup: 'remote setup' + }) + ) + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json') + }) + + it('does not coalesce forget requests for the same id on different hosts', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + let finishFirst!: () => void + killAllProcessesForWorktreeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + }) + ) + .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + const first = handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-shared::/same/path', + hostId: 'local' + }) as Promise + await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-shared::/same/path', + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) + + finishFirst() + await expect(first).resolves.toEqual({}) + }) + + // The repos are gone (host removed), so only the single global meta key is left to + // key on — and it names one host. It must not merge two hosts' forgets into one. + it('does not let stale metadata coalesce an explicit host forget onto another host', async () => { + const worktreeId = 'repo-gone::/same/path' + store.getRepos.mockReturnValue([]) + store.getRepo.mockReturnValue(undefined) + store.getWorktreeMeta.mockReturnValue({ hostId: 'local' }) + let finishFirst!: () => void + killAllProcessesForWorktreeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + }) + ) + .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + const first = handlers['worktrees:forgetLocal'](null, { + worktreeId, + hostId: 'local' + }) as Promise + await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId, + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) + + finishFirst() + await expect(first).resolves.toEqual({}) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') + }) +}) diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index 05287f3d713..07818ed7d30 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -7,7 +7,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createIssueCommandRunnerScriptMock, getSshFilesystemProviderMock, - getSshGitProviderMock + getSshGitProviderMock, + addWorktreeMock, + getActiveMultiplexerMock, + listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -93,6 +96,56 @@ vi.mock('../runtime/worktree-teardown', async () => ) vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) +const REMOTE_REPO_PATH = '/remote/repo' + +function makeRepo(fields: Record) { + return { + id: 'repo-1', + path: REMOTE_REPO_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main', + ...fields + } +} + +function makeProvider(worktreePath: string) { + return { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + // A hit here reads as "branch already exists"; the create loop would then rename. + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: worktreePath, + head: 'abc123', + branch: 'refs/heads/wt', + isBare: false, + isMainWorktree: false + } + ]) + } +} + +function useRepo(repo: ReturnType): void { + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) +} + describe('registerWorktreeHandlers', () => { beforeEach(() => { setupWorktreeHandlers() @@ -369,3 +422,92 @@ describe('registerWorktreeHandlers', () => { ) }) }) + +describe('worktrees:create execution host routing', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('creates on the SSH host for a row that names it only as executionHostId', async () => { + // No `connectionId`: the raw read answered "local" and ran `git worktree add` on the client + // against `/remote/repo`. The runtime sibling already resolved this row remotely. + useRepo(makeRepo({ executionHostId: 'ssh:target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(provider.addWorktree).toHaveBeenCalledTimes(1) + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('keeps two simultaneously registered SSH hosts apart', async () => { + useRepo(makeRepo({ executionHostId: 'ssh:target-b' })) + const providerA = makeProvider('/remote/repo-wt-a') + const providerB = makeProvider('/remote/repo-wt-b') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? providerA : connectionId === 'target-b' ? providerB : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(providerB.addWorktree).toHaveBeenCalledTimes(1) + expect(providerA.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row with no nested SSH target instead of creating locally', async () => { + useRepo(makeRepo({ executionHostId: 'runtime:env-1' })) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { + // `target-a` names a target inside env-1. A same-named one registered here is another machine, + // so creating through it lands the checkout on the wrong host. + useRepo(makeRepo({ executionHostId: 'runtime:env-1', connectionId: 'target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(provider.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('answers local for a row that declares itself local while carrying a connection', async () => { + // A contradictory row: `getRepoSshConnectionId` lets `local` win, and the runtime sibling has + // always read it that way. The raw field sent it remote, so the two entry points disagreed. + useRepo( + makeRepo({ path: '/workspace/repo', executionHostId: 'local', connectionId: 'target-a' }) + ) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/wt', + head: 'abc123', + branch: 'wt', + isBare: false, + isMainWorktree: false + } + ]) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(addWorktreeMock).toHaveBeenCalledTimes(1) + expect(provider.addWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts deleted file mode 100644 index bd3501abbe7..00000000000 --- a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - removeWorktreeMock, - forceDeleteLocalBranchMock, - gitExecFileAsyncMock, - getSshGitProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('preserves the branch on remove for worktrees created from an existing local branch', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true })) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(removeWorktreeMock).toHaveBeenCalledWith( - '/workspace/repo', - '/workspace/feature-wt', - false, - expect.objectContaining({ - deleteBranch: false, - knownRemovedWorktree: expect.objectContaining({ - branch: 'feature', - head: 'feature', - path: '/workspace/feature-wt' - }) - }) - ) - }) - - it('force-deletes a branch that was preserved by safe worktree removal', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId: 'repo-1::/workspace/feature-wt', - branchName: 'feature/test', - expectedHead: 'def456' - }) - - expect(result).toMatchObject({ deleted: true }) - expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( - '/workspace/repo', - 'feature/test', - 'def456' - ) - }) - - it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const worktreeId = 'repo-ssh::/remote/feature-wt' - const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined } - const provider = { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'def456', - branch: 'feature/test', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepos.mockReturnValue([localRepo, repo]) - store.getRepo.mockReturnValue(localRepo) - store.getWorktreeMeta.mockReturnValue( - makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true }) - ) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() }) - - await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' }) - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId, - branchName: 'feature/test', - expectedHead: 'def456', - hostId: 'ssh:conn-1' - }) - - expect(result).toMatchObject({ deleted: true }) - expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( - '/remote/repo', - 'feature/test', - 'def456' - ) - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - }) - - it('rejects stale preserved-branch cleanup actions with an old head', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'new456' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - await expect( - handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId: 'repo-1::/workspace/feature-wt', - branchName: 'feature/test', - expectedHead: 'old123' - }) - ).rejects.toThrow('No preserved branch cleanup is pending') - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - }) - - it('removes an unused Orca-created fork remote after deleting its worktree', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }) - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'config') { - throw new Error('no branch config') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { - cwd: '/workspace/repo' - }) - }) - - it('keeps an Orca-created fork remote while another worktree still uses it', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), - 'repo-1::/workspace/other-wt': makeWorktreeMeta({ - pushTarget: { - ...pushTarget, - branchName: 'other-branch' - } - }) - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( - ['remote', 'remove', 'pr-contributor-orca'], - expect.any(Object) - ) - }) - - it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), - 'repo-2::/workspace/other-wt': makeWorktreeMeta({ - pushTarget: { - ...pushTarget, - branchName: 'other-branch' - } - }) - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'config') { - throw new Error('no branch config') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { - cwd: '/workspace/repo' - }) - }) -}) diff --git a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts deleted file mode 100644 index ed10ef8f6ad..00000000000 --- a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - removeWorktreeMock, - listWorktreesMock, - parseOrcaYamlMock, - hasHooksFileMock, - getSshGitProviderMock, - getSshFilesystemProviderMock, - killAllProcessesForWorktreeMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: null - } - const sshRepo = { - ...localRepo, - path: '/remote/repo', - displayName: 'ssh', - connectionId: 'conn-1' - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: sshRepo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(localRepo) - store.getRepos.mockReturnValue([localRepo, sshRepo]) - getSshGitProviderMock.mockReturnValue(provider) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-shared::/remote/feature-wt', - hostId: 'ssh:conn-1' - }) - - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a row whose execution host and connection id name different machines', async () => { - // #11163: everything below the handler picks the filesystem from `repo.connectionId` while the - // prune, the archive-hook route and the home guard come from the resolved execution host. This - // row would have listed a remote checkout on this client and deleted a same-named local path. - const brokenRepo = { - id: 'repo-host-only', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: null, - executionHostId: 'ssh:conn-1' as const - } - const provider = { - listWorktrees: vi.fn(), - removeWorktree: vi.fn(), - worktreeIsClean: vi.fn() - } - store.getRepo.mockReturnValue(brokenRepo) - store.getRepos.mockReturnValue([brokenRepo]) - getSshGitProviderMock.mockReturnValue(provider) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-host-only::/remote/feature-wt', - force: true - }) - ).rejects.toThrow( - 'Refusing to delete worktree: repo repo-host-only names execution host ssh:conn-1, but its checkout is only reachable as local.' - ) - - expect(listWorktreesMock).not.toHaveBeenCalled() - expect(provider.listWorktrees).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses the mirror row that names local while carrying a connection id', async () => { - const brokenRepo = { - id: 'repo-local-spelled', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - executionHostId: 'local' as const - } - store.getRepo.mockReturnValue(brokenRepo) - store.getRepos.mockReturnValue([brokenRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-local-spelled::/remote/feature-wt', - force: true - }) - ).rejects.toThrow( - 'Refusing to delete worktree: repo repo-local-spelled names execution host local, but its checkout is only reachable as ssh:conn-1.' - ) - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a row owned by a runtime environment this process does not execute', async () => { - // `runtime:` deletes on that environment's own server. Its repo row looks local here — - // `connectionId` is null — so a host id that fell back to "local" would delete a same-named - // path on this client. - const runtimeRepo = { - id: 'repo-runtime-owned', - path: '/env/repo', - displayName: 'env', - badgeColor: '#000', - addedAt: 0, - connectionId: null, - executionHostId: 'runtime:env-1' as const - } - store.getRepo.mockReturnValue(runtimeRepo) - store.getRepos.mockReturnValue([runtimeRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-runtime-owned::/env/feature-wt', - force: true - }) - ).rejects.toThrow('Refusing to delete worktree: repo repo-runtime-owned names execution host') - - expect(listWorktreesMock).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('accepts a row whose two spellings differ only by percent-escaping', async () => { - // `getRepoExecutionHostId` returns the stored text while the connection id is re-encoded, so a - // byte compare would refuse this consistent row over a `%20`. - const repo = { - id: 'repo-escaped', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn one', - executionHostId: 'ssh:conn one' as const - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(repo) - store.getRepos.mockReturnValue([repo]) - getSshGitProviderMock.mockReturnValue(provider) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-escaped::/remote/feature-wt' - }) - - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - }) - - it('tears down the remote session when an ownerless remote worktree is deleted', async () => { - const sshRepo = { - id: 'repo-1', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: sshRepo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(sshRepo) - store.getRepos.mockReturnValue([sshRepo]) - getSshGitProviderMock.mockReturnValue(provider) - // Why: no WorktreeMeta means removal cannot read the owner back; the repo is the only host source. - store.getWorktreeMeta.mockReturnValue(undefined) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/remote/feature-wt' - }) - - // The whole point: without the repo's host the ownerless row would clear the local session instead. - expect(store.removeWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/remote/feature-wt', - 'ssh:conn-1' - ) - }) - - it('fails closed when duplicate repo ids are deleted without a host', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-shared::/remote/feature-wt' - }) - ).rejects.toThrow('Repo not found: repo-shared') - - expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() - }) - - it('inspects hooks on the requested host when repo ids collide', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n archive: remote-cleanup', - isBinary: false - }) - } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) - - await expect( - handlers['hooks:check'](null, { - repoId: 'repo-shared', - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({ - status: 'ok', - hasHooks: true, - hooks: { scripts: { archive: 'remote-cleanup' } }, - mayNeedUpdate: false - }) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') - expect(hasHooksFileMock).not.toHaveBeenCalled() - }) - - it('fails hook inspection closed when duplicate repo ids omit the host', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - - await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({ - status: 'error', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - }) - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() - expect(hasHooksFileMock).not.toHaveBeenCalled() - }) - - it('inspects setup-script imports on the requested host when repo ids collide', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath === '/remote/repo/.superset/config.json') { - return { content: '{"setup":"remote setup"}', isBinary: false } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), - stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - store.getRepo.mockReturnValue(localRepo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:inspectSetupScriptImports'](null, { - repoId: 'repo-shared', - hostId: 'ssh:conn-1' - }) - ).resolves.toContainEqual( - expect.objectContaining({ - provider: 'superset', - setup: 'remote setup' - }) - ) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json') - }) - - it('does not coalesce forget requests for the same id on different hosts', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - let finishFirst!: () => void - killAllProcessesForWorktreeMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => - resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - }) - ) - .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - - const first = handlers['worktrees:forgetLocal'](null, { - worktreeId: 'repo-shared::/same/path', - hostId: 'local' - }) as Promise - await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) - - await expect( - handlers['worktrees:forgetLocal'](null, { - worktreeId: 'repo-shared::/same/path', - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({}) - expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) - - finishFirst() - await expect(first).resolves.toEqual({}) - }) - - // The repos are gone (host removed), so only the single global meta key is left to - // key on — and it names one host. It must not merge two hosts' forgets into one. - it('does not let stale metadata coalesce an explicit host forget onto another host', async () => { - const worktreeId = 'repo-gone::/same/path' - store.getRepos.mockReturnValue([]) - store.getRepo.mockReturnValue(undefined) - store.getWorktreeMeta.mockReturnValue({ hostId: 'local' }) - let finishFirst!: () => void - killAllProcessesForWorktreeMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => - resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - }) - ) - .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - - const first = handlers['worktrees:forgetLocal'](null, { - worktreeId, - hostId: 'local' - }) as Promise - await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) - - await expect( - handlers['worktrees:forgetLocal'](null, { - worktreeId, - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({}) - expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) - - finishFirst() - await expect(first).resolves.toEqual({}) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') - }) -}) diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index cbf4c904096..bfb857da5f4 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -6,7 +6,12 @@ import { getEffectiveHooksMock, createSetupRunnerScriptMock, getEffectiveHooksFromConfigMock, - shouldRunSetupForCreateMock + shouldRunSetupForCreateMock, + parseOrcaYamlMock, + resolveSetupRunnerShellMock, + getSshGitProviderMock, + getSshFilesystemProviderMock, + getActiveMultiplexerMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { createdWorktreeList } from './worktrees-test-fixtures' @@ -404,3 +409,316 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree SSH setup launch', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--git-path') { + return { + stdout: '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh\n', + stderr: '' + } + } + if (args[0] === 'rev-parse') { + throw new Error('missing local branch') + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/old-improve-dashboard', + head: 'old123', + branch: 'refs/heads/archive/improve-dashboard', + isBare: false, + isMainWorktree: false + }, + { + path: '/remote/repo-improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n', + isBinary: false + }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + parseOrcaYamlMock.mockReturnValue({ + scripts: { setup: 'pnpm install' }, + setupAgentStartupPolicy: 'wait-for-setup' + }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo-improve-dashboard/orca.yaml') + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + '/remote/repo-improve-dashboard' + ) + expect(fsProvider.createDir).toHaveBeenCalledWith( + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n' + ) + expect(result).toEqual( + expect.objectContaining({ + setup: { + runnerScriptPath: + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/remote/repo', + ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard' + }), + waitForAgentStartup: true + } + }) + ) + }) + + it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => { + const repo = { + id: 'repo-ssh', + path: 'C:\\remote\\repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--git-path') { + return { + stdout: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n', + stderr: '' + } + } + if (args[0] === 'rev-parse') { + throw new Error('missing local branch') + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: 'C:\\remote\\improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n setup: pnpm install\n', + isBinary: false + }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + terminalWindowsShell: 'git-bash', + workspaceDir: 'C:\\workspace' + }) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + 'C:\\remote\\improve-dashboard' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + 'pnpm install' + ) + expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled() + expect(result).toEqual( + expect.objectContaining({ + setup: { + runnerScriptPath: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\remote\\repo', + ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard' + }) + } + }) + ) + }) + + it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-sparse-dashboard', + head: 'abc123', + branch: 'refs/heads/sparse-dashboard', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-ssh', + name: 'App', + directories: ['apps/mobile', 'packages/shared'], + createdAt: 1, + updatedAt: 1 + } + ]) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'sparse-dashboard', + sparseCheckout: { + directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'], + presetId: 'preset-1' + } + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'sparse-dashboard', + '/remote/repo-sparse-dashboard', + { base: 'origin/main', noCheckout: true } + ) + expect(provider.exec).toHaveBeenCalledWith( + ['sparse-checkout', 'init', '--cone'], + '/remote/repo-sparse-dashboard' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], + '/remote/repo-sparse-dashboard' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['checkout', 'sparse-dashboard'], + '/remote/repo-sparse-dashboard' + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-ssh::/remote/repo-sparse-dashboard', + expect.objectContaining({ + sparseDirectories: ['apps/mobile', 'packages/shared'], + baseRef: 'refs/remotes/origin/main', + sparseBaseRef: 'refs/remotes/origin/main', + sparsePresetId: 'preset-1' + }) + ) + expect(result).toMatchObject({ + worktree: expect.objectContaining({ + isSparse: true, + sparseDirectories: ['apps/mobile', 'packages/shared'], + sparseBaseRef: 'refs/remotes/origin/main', + sparsePresetId: 'preset-1' + }) + }) + }) +}) diff --git a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts deleted file mode 100644 index 03e69583e99..00000000000 --- a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getSshGitProviderMock, - getSshFilesystemProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'abc123' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { - return { stdout: 'abc123\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { - return { stdout: 'abc123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi - .fn() - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo-fix-title-2', - head: 'abc123', - branch: 'refs/heads/feature/fix', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - stat: vi.fn().mockImplementation(async (pathValue: string) => { - if (pathValue === '/remote/repo-fix-title') { - return { size: 0, type: 'directory', mtime: 0 } - } - const error = new Error('missing') as Error & { code: string } - error.code = 'ENOENT' - throw error - }), - readFile: vi.fn().mockRejectedValue(new Error('missing')) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'fix-title', - baseBranch: 'abc123', - branchNameOverride: 'feature/fix' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/fix', - '/remote/repo-fix-title-2', - { checkoutExistingBranch: true } - ) - expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { - rootPath: '/remote/repo-fix-title-2' - }) - }) - - it('suffixes SSH worktree creation when the requested branch already exists on a remote', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'branch' && args.includes('feature/something')) { - return { stdout: '', stderr: '' } - } - if (args[0] === 'show-ref') { - const ref = 'refs/remotes/origin/feature/something' - if (args.includes(ref)) { - return { stdout: `abc ${ref}\n`, stderr: '' } - } - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { - throw new Error('missing local branch') - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { - throw new Error('missing local branch') - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-feature-something-2', - head: 'abc123', - branch: 'refs/heads/feature/something-2', - isBare: false, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'feature-something', - branchNameOverride: 'feature/something' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/something-2', - '/remote/repo-feature-something-2', - { base: 'origin/main' } - ) - }) - - it('suffixes SSH worktree creation when a slashed remote owns the requested branch', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\nfoo/bar\n', stderr: '' } - } - if (args[0] === 'show-ref') { - const ref = 'refs/remotes/foo/bar/feature/something' - if (args.includes(ref)) { - return { stdout: `abc ${ref}\n`, stderr: '' } - } - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { - throw new Error('missing local branch') - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { - throw new Error('missing local branch') - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-feature-something-2', - head: 'abc123', - branch: 'refs/heads/feature/something-2', - isBare: false, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'feature-something', - branchNameOverride: 'feature/something' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/something-2', - '/remote/repo-feature-something-2', - { base: 'origin/main' } - ) - }) -}) diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts index fe80219fa8f..19b1c903aad 100644 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts @@ -1,9 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { validateGitExecArgs } from '../../relay/git-exec-validator' -import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks' +import { + getSshGitProviderMock, + getActiveMultiplexerMock, + removeWorktreeMock, + forceDeleteLocalBranchMock, + gitExecFileAsyncMock +} from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote' import type { SshGitProvider } from '../providers/ssh-git-provider' +import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -225,3 +232,238 @@ describe('registerWorktreeHandlers', () => { // first sync. Coverage for all three moved with it to // worktree-remote-push-target-materialization.test.ts, which calls that function directly. }) + +describe('worktree preserved branch fork remotes', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('preserves the branch on remove for worktrees created from an existing local branch', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true })) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(removeWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + false, + expect.objectContaining({ + deleteBranch: false, + knownRemovedWorktree: expect.objectContaining({ + branch: 'feature', + head: 'feature', + path: '/workspace/feature-wt' + }) + }) + ) + }) + + it('force-deletes a branch that was preserved by safe worktree removal', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + branchName: 'feature/test', + expectedHead: 'def456' + }) + + expect(result).toMatchObject({ deleted: true }) + expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( + '/workspace/repo', + 'feature/test', + 'def456' + ) + }) + + it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const worktreeId = 'repo-ssh::/remote/feature-wt' + const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined } + const provider = { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: repo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'def456', + branch: 'feature/test', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepos.mockReturnValue([localRepo, repo]) + store.getRepo.mockReturnValue(localRepo) + store.getWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true }) + ) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() }) + + await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' }) + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId, + branchName: 'feature/test', + expectedHead: 'def456', + hostId: 'ssh:conn-1' + }) + + expect(result).toMatchObject({ deleted: true }) + expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( + '/remote/repo', + 'feature/test', + 'def456' + ) + expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() + }) + + it('rejects stale preserved-branch cleanup actions with an old head', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'new456' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + await expect( + handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + branchName: 'feature/test', + expectedHead: 'old123' + }) + ).rejects.toThrow('No preserved branch cleanup is pending') + expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() + }) + + it('removes an unused Orca-created fork remote after deleting its worktree', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }) + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + throw new Error('no branch config') + } + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { + cwd: '/workspace/repo' + }) + }) + + it('keeps an Orca-created fork remote while another worktree still uses it', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), + 'repo-1::/workspace/other-wt': makeWorktreeMeta({ + pushTarget: { + ...pushTarget, + branchName: 'other-branch' + } + }) + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['remote', 'remove', 'pr-contributor-orca'], + expect.any(Object) + ) + }) + + it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), + 'repo-2::/workspace/other-wt': makeWorktreeMeta({ + pushTarget: { + ...pushTarget, + branchName: 'other-branch' + } + }) + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + throw new Error('no branch config') + } + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { + cwd: '/workspace/repo' + }) + }) +}) diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index 8d4966dd187..034bd890bd1 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -4,7 +4,8 @@ import { getPullRequestPushTargetMock, gitExecFileAsyncMock, getSshGitProviderMock, - getActiveMultiplexerMock + getActiveMultiplexerMock, + getSshFilesystemProviderMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { ORIGIN_HEAD_COMPONENT, ORIGIN_REMOTE_URL } from './worktrees-test-fixtures' @@ -529,3 +530,248 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree SSH branch conflict suffixes', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'abc123' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi + .fn() + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/repo-fix-title-2', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + stat: vi.fn().mockImplementation(async (pathValue: string) => { + if (pathValue === '/remote/repo-fix-title') { + return { size: 0, type: 'directory', mtime: 0 } + } + const error = new Error('missing') as Error & { code: string } + error.code = 'ENOENT' + throw error + }), + readFile: vi.fn().mockRejectedValue(new Error('missing')) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/fix', + '/remote/repo-fix-title-2', + { checkoutExistingBranch: true } + ) + expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/remote/repo-fix-title-2' + }) + }) + + it('suffixes SSH worktree creation when the requested branch already exists on a remote', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'branch' && args.includes('feature/something')) { + return { stdout: '', stderr: '' } + } + if (args[0] === 'show-ref') { + const ref = 'refs/remotes/origin/feature/something' + if (args.includes(ref)) { + return { stdout: `abc ${ref}\n`, stderr: '' } + } + throw Object.assign(new Error('missing remote ref'), { code: 1 }) + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { + throw new Error('missing local branch') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-feature-something-2', + head: 'abc123', + branch: 'refs/heads/feature/something-2', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'feature-something', + branchNameOverride: 'feature/something' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/something-2', + '/remote/repo-feature-something-2', + { base: 'origin/main' } + ) + }) + + it('suffixes SSH worktree creation when a slashed remote owns the requested branch', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\nfoo/bar\n', stderr: '' } + } + if (args[0] === 'show-ref') { + const ref = 'refs/remotes/foo/bar/feature/something' + if (args.includes(ref)) { + return { stdout: `abc ${ref}\n`, stderr: '' } + } + throw Object.assign(new Error('missing remote ref'), { code: 1 }) + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { + throw new Error('missing local branch') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-feature-something-2', + head: 'abc123', + branch: 'refs/heads/feature/something-2', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'feature-something', + branchNameOverride: 'feature/something' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/something-2', + '/remote/repo-feature-something-2', + { base: 'origin/main' } + ) + }) +}) diff --git a/src/main/ipc/worktrees-ssh-setup-launch.test.ts b/src/main/ipc/worktrees-ssh-setup-launch.test.ts deleted file mode 100644 index fa98b5b62a1..00000000000 --- a/src/main/ipc/worktrees-ssh-setup-launch.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getEffectiveHooksFromConfigMock, - parseOrcaYamlMock, - shouldRunSetupForCreateMock, - resolveSetupRunnerShellMock, - getSshGitProviderMock, - getSshFilesystemProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--git-path') { - return { - stdout: '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh\n', - stderr: '' - } - } - if (args[0] === 'rev-parse') { - throw new Error('missing local branch') - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/old-improve-dashboard', - head: 'old123', - branch: 'refs/heads/archive/improve-dashboard', - isBare: false, - isMainWorktree: false - }, - { - path: '/remote/repo-improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n', - isBinary: false - }), - createDir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - parseOrcaYamlMock.mockReturnValue({ - scripts: { setup: 'pnpm install' }, - setupAgentStartupPolicy: 'wait-for-setup' - }) - getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - shouldRunSetupForCreateMock.mockReturnValue(true) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard', - setupDecision: 'run' - }) - - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo-improve-dashboard/orca.yaml') - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/setup-runner.sh'], - '/remote/repo-improve-dashboard' - ) - expect(fsProvider.createDir).toHaveBeenCalledWith( - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', - '#!/usr/bin/env bash\nset -e\npnpm install\n' - ) - expect(result).toEqual( - expect.objectContaining({ - setup: { - runnerScriptPath: - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', - envVars: expect.objectContaining({ - ORCA_ROOT_PATH: '/remote/repo', - ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard' - }), - waitForAgentStartup: true - } - }) - ) - }) - - it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => { - const repo = { - id: 'repo-ssh', - path: 'C:\\remote\\repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--git-path') { - return { - stdout: - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n', - stderr: '' - } - } - if (args[0] === 'rev-parse') { - throw new Error('missing local branch') - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: 'C:\\remote\\improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n setup: pnpm install\n', - isBinary: false - }), - createDir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined) - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getSettings.mockReturnValue({ - branchPrefix: 'none', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: false, - terminalWindowsShell: 'git-bash', - workspaceDir: 'C:\\workspace' - }) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - shouldRunSetupForCreateMock.mockReturnValue(true) - resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard', - setupDecision: 'run' - }) - - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], - 'C:\\remote\\improve-dashboard' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', - 'pnpm install' - ) - expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled() - expect(result).toEqual( - expect.objectContaining({ - setup: { - runnerScriptPath: - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', - envVars: expect.objectContaining({ - ORCA_ROOT_PATH: 'C:\\remote\\repo', - ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard' - }) - } - }) - ) - }) - - it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-sparse-dashboard', - head: 'abc123', - branch: 'refs/heads/sparse-dashboard', - isBare: false, - isSparse: true, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getSparsePresets.mockReturnValue([ - { - id: 'preset-1', - repoId: 'repo-ssh', - name: 'App', - directories: ['apps/mobile', 'packages/shared'], - createdAt: 1, - updatedAt: 1 - } - ]) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'sparse-dashboard', - sparseCheckout: { - directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'], - presetId: 'preset-1' - } - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'sparse-dashboard', - '/remote/repo-sparse-dashboard', - { base: 'origin/main', noCheckout: true } - ) - expect(provider.exec).toHaveBeenCalledWith( - ['sparse-checkout', 'init', '--cone'], - '/remote/repo-sparse-dashboard' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], - '/remote/repo-sparse-dashboard' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['checkout', 'sparse-dashboard'], - '/remote/repo-sparse-dashboard' - ) - expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-ssh::/remote/repo-sparse-dashboard', - expect.objectContaining({ - sparseDirectories: ['apps/mobile', 'packages/shared'], - baseRef: 'refs/remotes/origin/main', - sparseBaseRef: 'refs/remotes/origin/main', - sparsePresetId: 'preset-1' - }) - ) - expect(result).toMatchObject({ - worktree: expect.objectContaining({ - isSparse: true, - sparseDirectories: ['apps/mobile', 'packages/shared'], - sparseBaseRef: 'refs/remotes/origin/main', - sparsePresetId: 'preset-1' - }) - }) - }) -}) diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts index 845ebdfc8a2..133bc8eca9d 100644 --- a/src/main/runtime/antigravity-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -1,19 +1,26 @@ -/** - * Pins Antigravity readiness to captured transcripts instead of hand-written fixtures. - * - * Five detector attempts were tuned against a five-line screen someone typed from memory, and - * three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what - * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name - * themselves as skipped until someone can reach them. - * - * One case is pinned as a KNOWN DEFECT: the shipped detector refuses a ready screen whose retained - * tail ends on the error block. That asserts what it does, not what it should. - */ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { + finalReplayFrame, + readRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { + describeScreenRuledAgentTranscripts, + readsIdleComposer +} from './screen-ruled-agent-transcript-suite' +import { + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isQuietReadyScreenBody +} from './terminal-wait-detection' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -22,194 +29,341 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const FIXTURE_DIR = join(__dirname, '__fixtures__') -// Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to -// hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. -const READY_TIMEOUT_MS = 2_000 -const REFUSAL_TIMEOUT_MS = 600 -/** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */ -const ANTIGRAVITY_COMMAND = 'agy' -// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw -// control byte in source, which is unreadable and survives badly in diffs. -const ESC = String.fromCharCode(27) - -type TranscriptCase = { - /** Fixture basename; `.txt` under `__fixtures__/`. */ - name: string - /** Capture group identifier. */ - capture: string - what: string - /** What a correct detector must answer. Not what the shipped one answers. */ - expectReady: boolean +describe('Antigravity raw captured readiness', () => { /** - * Set where the shipped detector contradicts the transcript. The case then runs inverted, so - * CI pins the defect instead of going permanently red — and flips to failing the moment - * someone fixes it, which is exactly when these expectations need re-reading. + * Pins Antigravity readiness to captured transcripts instead of hand-written fixtures. + * + * Five detector attempts were tuned against a five-line screen someone typed from memory, and + * three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what + * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name + * themselves as skipped until someone can reach them. + * + * One case is pinned as a KNOWN DEFECT: the shipped detector refuses a ready screen whose retained + * tail ends on the error block. That asserts what it does, not what it should. */ - knownDefect?: string -} -const TRANSCRIPTS: readonly TranscriptCase[] = [ - { - name: 'antigravity-ready-api-key-gemini-model', - capture: 'B', - what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', - expectReady: true - }, - { - name: 'antigravity-ready-account-info-hidden', - capture: 'B', - what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', - expectReady: true - }, - { - name: 'antigravity-dialog-trust-workspace', - capture: 'C', - what: 'workspace trust dialog owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-model-picker', - capture: 'C', - what: 'model picker owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-command-palette', - capture: 'C', - what: 'slash-command palette owning the screen', - expectReady: false - }, - { - name: 'antigravity-busy-mid-turn', - capture: 'E', - what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt', - expectReady: false - }, - { - // Expected ready because the turn is over and the composer is back on screen. The captured - // turn ends in a backend error, which is the only ending this account's key can produce. - name: 'antigravity-busy-turn-ended', - capture: 'E', - what: 'the turn has ended and the composer has returned, process still alive', - expectReady: true, - knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' - }, - { - name: 'antigravity-dialog-dismissed', - capture: 'D', - what: 'the screen immediately after the model picker is dismissed', - expectReady: true - }, - // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and - // reaching the rest would mean signing the operator out or deleting their config. - { - name: 'antigravity-ready-business-non-gemini', - capture: 'A', - what: 'ready screen, Business account, non-Gemini model', - expectReady: true - }, - { - name: 'antigravity-dialog-sign-in', - capture: 'C', - what: 'sign-in dialog owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-theme-picker', - capture: 'C', - what: 'theme picker owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-privacy-notice', - capture: 'C', - what: 'privacy notice owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-update-banner', - capture: 'C', - what: 'update banner owning the screen', - expectReady: false + const FIXTURE_DIR = join(__dirname, '__fixtures__') + // Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to + // hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. + const READY_TIMEOUT_MS = 2_000 + const REFUSAL_TIMEOUT_MS = 600 + /** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */ + const ANTIGRAVITY_COMMAND = 'agy' + // String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw + // control byte in source, which is unreadable and survives badly in diffs. + const ESC = String.fromCharCode(27) + + type TranscriptCase = { + /** Fixture basename; `.txt` under `__fixtures__/`. */ + name: string + /** Capture group identifier. */ + capture: string + what: string + /** What a correct detector must answer. Not what the shipped one answers. */ + expectReady: boolean + /** + * Set where the shipped detector contradicts the transcript. The case then runs inverted, so + * CI pins the defect instead of going permanently red — and flips to failing the moment + * someone fixes it, which is exactly when these expectations need re-reading. + */ + knownDefect?: string } -] -function fixturePath(name: string): string { - return join(FIXTURE_DIR, `${name}.txt`) -} - -/** - * A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it - * resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever - * looked ready. The orchestrator treats the last two identically — no prompt is delivered — so - * they are both `ready: false` here. This is the shape `worker-start` sees. - */ -async function readinessVerdict( - transcript: string, - timeoutMs: number -): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> { - const { runtime, handle } = await createTranscriptPane({ - // Why the transcript's own title: every attempt guessed at Antigravity's title. A raw - // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. - paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, - foregroundProcess: ANTIGRAVITY_COMMAND, - data: transcript - }) - try { - const result = (await runtime.waitForTerminal(handle, { - condition: 'tui-idle', - timeoutMs - })) as { satisfied?: boolean; blockedReason?: unknown } - return { - ready: result.satisfied === true, - blockedReason: result.blockedReason ?? null, - outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied' + const TRANSCRIPTS: readonly TranscriptCase[] = [ + { + name: 'antigravity-ready-api-key-gemini-model', + capture: 'B', + what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', + expectReady: true + }, + { + name: 'antigravity-ready-account-info-hidden', + capture: 'B', + what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', + expectReady: true + }, + { + name: 'antigravity-dialog-trust-workspace', + capture: 'C', + what: 'workspace trust dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-model-picker', + capture: 'C', + what: 'model picker owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-command-palette', + capture: 'C', + what: 'slash-command palette owning the screen', + expectReady: false + }, + { + name: 'antigravity-busy-mid-turn', + capture: 'E', + what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt', + expectReady: false + }, + { + // Expected ready because the turn is over and the composer is back on screen. The captured + // turn ends in a backend error, which is the only ending this account's key can produce. + name: 'antigravity-busy-turn-ended', + capture: 'E', + what: 'the turn has ended and the composer has returned, process still alive', + expectReady: true, + knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' + }, + { + name: 'antigravity-dialog-dismissed', + capture: 'D', + what: 'the screen immediately after the model picker is dismissed', + expectReady: true + }, + // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and + // reaching the rest would mean signing the operator out or deleting their config. + { + name: 'antigravity-ready-business-non-gemini', + capture: 'A', + what: 'ready screen, Business account, non-Gemini model', + expectReady: true + }, + { + name: 'antigravity-dialog-sign-in', + capture: 'C', + what: 'sign-in dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-theme-picker', + capture: 'C', + what: 'theme picker owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-privacy-notice', + capture: 'C', + what: 'privacy notice owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-update-banner', + capture: 'C', + what: 'update banner owning the screen', + expectReady: false } - } catch (error) { - return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` } + ] + + function fixturePath(name: string): string { + return join(FIXTURE_DIR, `${name}.txt`) } -} -describe('Antigravity readiness, decided by captured transcripts', () => { - for (const transcript of TRANSCRIPTS) { - const path = fixturePath(transcript.name) - const captured = existsSync(path) - const label = `capture ${transcript.capture}: ${transcript.what}` + /** + * A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it + * resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever + * looked ready. The orchestrator treats the last two identically — no prompt is delivered — so + * they are both `ready: false` here. This is the shape `worker-start` sees. + */ + async function readinessVerdict( + transcript: string, + timeoutMs: number + ): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> { + const { runtime, handle } = await createTranscriptPane({ + // Why the transcript's own title: every attempt guessed at Antigravity's title. A raw + // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. + paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, + foregroundProcess: ANTIGRAVITY_COMMAND, + data: transcript + }) + try { + const result = (await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs + })) as { satisfied?: boolean; blockedReason?: unknown } + return { + ready: result.satisfied === true, + blockedReason: result.blockedReason ?? null, + outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied' + } + } catch (error) { + return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` } + } + } - // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently - // red; fixing the detector flips this case to failing, which is when these expectations - // need re-reading. The correct answer stays in `expectReady` and in the test's name. - const shipped = - transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady - const verdictName = - transcript.knownDefect === undefined - ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` - : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` + describe('Antigravity readiness, decided by captured transcripts', () => { + for (const transcript of TRANSCRIPTS) { + const path = fixturePath(transcript.name) + const captured = existsSync(path) + const label = `capture ${transcript.capture}: ${transcript.what}` - it.skipIf(!captured)( - verdictName, - async () => { - // A refusal only has to hold for one poll; a ready verdict has to survive the settle - // window. Keeping the refusal short keeps eleven transcripts off the suite's clock. - const verdict = await readinessVerdict( - readFileSync(path, 'utf8'), - transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS - ) - // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca - // does not call the pane ready and type a prompt into a dialog that owns the screen. - expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ - ready: shipped - }) - }, - READY_TIMEOUT_MS + 10_000 + // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently + // red; fixing the detector flips this case to failing, which is when these expectations + // need re-reading. The correct answer stays in `expectReady` and in the test's name. + const shipped = + transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady + const verdictName = + transcript.knownDefect === undefined + ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` + : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` + + it.skipIf(!captured)( + verdictName, + async () => { + // A refusal only has to hold for one poll; a ready verdict has to survive the settle + // window. Keeping the refusal short keeps eleven transcripts off the suite's clock. + const verdict = await readinessVerdict( + readFileSync(path, 'utf8'), + transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS + ) + // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca + // does not call the pane ready and type a prompt into a dialog that owns the screen. + expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ + ready: shipped + }) + }, + READY_TIMEOUT_MS + 10_000 + ) + + it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => { + const text = readFileSync(path, 'utf8') + // Why: a transcript with no escape bytes went through a terminal's renderer and a + // human's clipboard. It cannot answer what the caret or chrome looked like. + expect(text).toContain(ESC) + }) + } + }) +}) + +describe('Antigravity screen ruled readiness', () => { + // agy 1.2.14 on macOS, AGY_CLI_HIDE_ACCOUNT_INFO=1 (see each .meta.json); STA-8741. + const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) + const READY = [ + at120x40('antigravity-1-2-14-ready', 'settled startup'), + at120x40('antigravity-1-2-14-ready-accept-edits', '--mode accept-edits'), + at120x40('antigravity-1-2-14-ready-plan', '--mode plan'), + { name: 'antigravity-1-2-14-ready-80x24', what: 'settled startup', cols: 80, rows: 24 }, + at120x40('antigravity-1-2-14-picker-dismissed', '/model closed with Esc'), + at120x40('antigravity-1-2-14-turn-ended', 'a turn has ended') + ] + const NOT_READY = [ + at120x40('antigravity-1-2-14-model-picker', '/model picker open'), + at120x40('antigravity-1-2-14-command-palette', 'slash palette open'), + at120x40('antigravity-1-2-14-busy-thinking', 'spinner before the answer'), + at120x40('antigravity-1-2-14-busy-streaming', 'answer streaming'), + at120x40('antigravity-1-2-14-trust-dialog', 'workspace trust dialog'), + at120x40('antigravity-1-2-14-draft', 'unsent text in the composer') + ] + + describe('Antigravity 1.2.14 readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'antigravity', + foregroundProcess: 'agy', + ready: READY, + notReady: NOT_READY, + // Why these: the line-folded text rule reads only these ready screens. + readyWithoutScreen: [ + 'antigravity-1-2-14-ready', + 'antigravity-1-2-14-ready-80x24', + 'antigravity-1-2-14-picker-dismissed' + ] + }) + + it.each([ + 'antigravity-1-2-14-ready-accept-edits', + 'antigravity-1-2-14-ready-plan', + 'antigravity-1-2-14-turn-ended' + ])('%s: the line-folded text rule alone misses this ready screen', async (name) => { + const { waitText } = await finalReplayFrame(name, 120, 40) + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + + it('does not let the text rules overrule a screen that refused', async () => { + const { waitText } = await finalReplayFrame('antigravity-1-2-14-picker-dismissed', 120, 40) + const { ruledScreenLines } = await finalReplayFrame( + 'antigravity-1-2-14-model-picker', + 120, + 40 + ) + // Presence precondition: the text alone would say ready. + expect(isKnownReadyPromptPreview(waitText)).toBe(true) + expect(isQuietReadyScreenBody(waitText, 'antigravity', () => ruledScreenLines)).toBe(false) + expect(isKnownReadyPromptBody(waitText, 'antigravity', () => ruledScreenLines, false)).toBe( + false + ) + }) + + // Why a caret rule is not enough: agy keeps the bare composer caret painted through both. + it.each(['antigravity-1-2-14-busy-streaming', 'antigravity-1-2-14-model-picker'])( + '%s: the bare caret is still on screen', + async (name) => { + const { ruledScreenLines } = await finalReplayFrame(name, 120, 40) + expect(ruledScreenLines.some((line) => line.trim() === '>')).toBe(true) + } ) - it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => { - const text = readFileSync(path, 'utf8') - // Why: a transcript with no escape bytes went through a terminal's renderer and a - // human's clipboard. It cannot answer what the caret or chrome looked like. - expect(text).toContain(ESC) + // Why a clocked pane waits for quiet: the submit repaint clears the composer a moment before + // it swaps `? for shortcuts` for `esc to cancel`. + it('reads a submit repaint as ready for a moment mid-turn', async () => { + const data = readRuntimeFixture('antigravity-1-2-14-turn-ended') + let submitted = false + let answered = false + let readyMidTurn = 0 + for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) { + submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools')) + answered ||= ruledScreenLines.some((line) => line.trim() === 'ok') + if (submitted && !answered && readsIdleComposer('antigravity', ruledScreenLines)) { + readyMidTurn += 1 + } + } + expect(submitted && answered).toBe(true) + expect(readyMidTurn).toBeGreaterThan(0) }) - } + + // Why: a shell auto-title names the process; before the screen decided, that name-only idle + // title settled a pane whose picker carries no blocked wording. + it('does not settle an open model picker from a name-only `agy` title', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'agy', + foregroundProcess: 'agy', + launchAgent: 'antigravity', + data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/) + }, 15_000) + + // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. + it('trusts a reflowed grid again once a PTY resize off it repaints the TUI', async () => { + const turnEnded = readRuntimeFixture('antigravity-1-2-14-turn-ended') + const options = { + paneTitle: 'Terminal', + foregroundProcess: 'agy', + launchAgent: 'antigravity' as const, + data: turnEnded, + size: { cols: 100, rows: 30 } + } + const { runtime, handle } = await createTranscriptPane(options) + const resizePty = (cols: number, rows: number) => { + options.size = { cols, rows } + runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) + } + const settles = async () => + ( + await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ + satisfied: false + })) + ).satisfied + options.size = { cols: 120, rows: 40 } + runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) + await runtime.readTerminal(handle, { screen: true }) + expect(await settles()).toBe(false) + resizePty(121, 40) + resizePty(120, 40) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, turnEnded, Date.now()) + await runtime.readTerminal(handle, { screen: true }) + expect(await settles()).toBe(true) + }, 45_000) + }) }) diff --git a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts deleted file mode 100644 index 1735c102151..00000000000 --- a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createTranscriptPane, - TRANSCRIPT_PANE_PTY_ID, - waitForTranscriptIdle -} from './agent-transcript-pane-test-harness' -import { - finalReplayFrame, - readRuntimeFixture, - replayTranscript -} from './agent-transcript-replay-test-harness' -import { - describeScreenRuledAgentTranscripts, - readsIdleComposer -} from './screen-ruled-agent-transcript-suite' -import { - isKnownReadyPromptBody, - isKnownReadyPromptPreview, - isQuietReadyScreenBody -} from './terminal-wait-detection' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// agy 1.2.14 on macOS, AGY_CLI_HIDE_ACCOUNT_INFO=1 (see each .meta.json); STA-8741. -const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) -const READY = [ - at120x40('antigravity-1-2-14-ready', 'settled startup'), - at120x40('antigravity-1-2-14-ready-accept-edits', '--mode accept-edits'), - at120x40('antigravity-1-2-14-ready-plan', '--mode plan'), - { name: 'antigravity-1-2-14-ready-80x24', what: 'settled startup', cols: 80, rows: 24 }, - at120x40('antigravity-1-2-14-picker-dismissed', '/model closed with Esc'), - at120x40('antigravity-1-2-14-turn-ended', 'a turn has ended') -] -const NOT_READY = [ - at120x40('antigravity-1-2-14-model-picker', '/model picker open'), - at120x40('antigravity-1-2-14-command-palette', 'slash palette open'), - at120x40('antigravity-1-2-14-busy-thinking', 'spinner before the answer'), - at120x40('antigravity-1-2-14-busy-streaming', 'answer streaming'), - at120x40('antigravity-1-2-14-trust-dialog', 'workspace trust dialog'), - at120x40('antigravity-1-2-14-draft', 'unsent text in the composer') -] - -describe('Antigravity 1.2.14 readiness from captured bytes', () => { - describeScreenRuledAgentTranscripts({ - agent: 'antigravity', - foregroundProcess: 'agy', - ready: READY, - notReady: NOT_READY, - // Why these: the line-folded text rule reads only these ready screens. - readyWithoutScreen: [ - 'antigravity-1-2-14-ready', - 'antigravity-1-2-14-ready-80x24', - 'antigravity-1-2-14-picker-dismissed' - ] - }) - - it.each([ - 'antigravity-1-2-14-ready-accept-edits', - 'antigravity-1-2-14-ready-plan', - 'antigravity-1-2-14-turn-ended' - ])('%s: the line-folded text rule alone misses this ready screen', async (name) => { - const { waitText } = await finalReplayFrame(name, 120, 40) - expect(isKnownReadyPromptPreview(waitText)).toBe(false) - }) - - it('does not let the text rules overrule a screen that refused', async () => { - const { waitText } = await finalReplayFrame('antigravity-1-2-14-picker-dismissed', 120, 40) - const { ruledScreenLines } = await finalReplayFrame('antigravity-1-2-14-model-picker', 120, 40) - // Presence precondition: the text alone would say ready. - expect(isKnownReadyPromptPreview(waitText)).toBe(true) - expect(isQuietReadyScreenBody(waitText, 'antigravity', () => ruledScreenLines)).toBe(false) - expect(isKnownReadyPromptBody(waitText, 'antigravity', () => ruledScreenLines, false)).toBe( - false - ) - }) - - // Why a caret rule is not enough: agy keeps the bare composer caret painted through both. - it.each(['antigravity-1-2-14-busy-streaming', 'antigravity-1-2-14-model-picker'])( - '%s: the bare caret is still on screen', - async (name) => { - const { ruledScreenLines } = await finalReplayFrame(name, 120, 40) - expect(ruledScreenLines.some((line) => line.trim() === '>')).toBe(true) - } - ) - - // Why a clocked pane waits for quiet: the submit repaint clears the composer a moment before - // it swaps `? for shortcuts` for `esc to cancel`. - it('reads a submit repaint as ready for a moment mid-turn', async () => { - const data = readRuntimeFixture('antigravity-1-2-14-turn-ended') - let submitted = false - let answered = false - let readyMidTurn = 0 - for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) { - submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools')) - answered ||= ruledScreenLines.some((line) => line.trim() === 'ok') - if (submitted && !answered && readsIdleComposer('antigravity', ruledScreenLines)) { - readyMidTurn += 1 - } - } - expect(submitted && answered).toBe(true) - expect(readyMidTurn).toBeGreaterThan(0) - }) - - // Why: a shell auto-title names the process; before the screen decided, that name-only idle - // title settled a pane whose picker carries no blocked wording. - it('does not settle an open model picker from a name-only `agy` title', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'agy', - foregroundProcess: 'agy', - launchAgent: 'antigravity', - data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/) - }, 15_000) - - // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. - it('trusts a reflowed grid again once a PTY resize off it repaints the TUI', async () => { - const turnEnded = readRuntimeFixture('antigravity-1-2-14-turn-ended') - const options = { - paneTitle: 'Terminal', - foregroundProcess: 'agy', - launchAgent: 'antigravity' as const, - data: turnEnded, - size: { cols: 100, rows: 30 } - } - const { runtime, handle } = await createTranscriptPane(options) - const resizePty = (cols: number, rows: number) => { - options.size = { cols, rows } - runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) - } - const settles = async () => - (await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ satisfied: false }))) - .satisfied - options.size = { cols: 120, rows: 40 } - runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) - await runtime.readTerminal(handle, { screen: true }) - expect(await settles()).toBe(false) - resizePty(121, 40) - resizePty(120, 40) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, turnEnded, Date.now()) - await runtime.readTerminal(handle, { screen: true }) - expect(await settles()).toBe(true) - }, 45_000) -}) diff --git a/src/main/runtime/claude-task-wakeup-indexed-read.test.ts b/src/main/runtime/claude-task-wakeup-indexed-read.test.ts deleted file mode 100644 index f966c0dce51..00000000000 --- a/src/main/runtime/claude-task-wakeup-indexed-read.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import { - createTranscriptPane, - TRANSCRIPT_PANE_PTY_ID, - waitForTranscriptIdle -} from './agent-transcript-pane-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' -const ready = readFileSync( - join(import.meta.dirname, '__fixtures__/claude-ready-task-wakeup.txt'), - 'utf8' -) - -function owedRow(paneKey: string): AgentStatusIpcPayload { - const now = Date.now() - return { - paneKey, - state: 'working', - mainAgent: { state: 'done', stateStartedAt: now }, - agentType: 'claude', - prompt: '', - connectionId: null, - launchToken: 'transcript-launch', - claudeTaskWakeupPending: 'notification', - providerSession: { key: 'session_id', id: 'session-a' }, - observation: { - origin: 'hook', - authorityId: 'host-a', - incarnation: 1, - revision: 1, - observedAt: now - }, - receivedAt: now, - stateStartedAt: now - } -} - -afterEach(() => vi.useRealTimers()) - -describe('Claude readiness indexed status reads', () => { - it.each([true, false])( - 'reads only its pane with 512 unrelated agents, including an empty index: pending=%s', - async (pending) => { - const unrelated = Array.from({ length: 512 }, (_, index) => owedRow(`other:${index}`)) - const row = owedRow(PANE) - const full = vi.fn(() => [row, ...unrelated]) - const indexed = vi.fn((paneKey: string) => (pending && paneKey === PANE ? [row] : [])) - const pane = await createTranscriptPane( - { - paneTitle: 'Terminal', - foregroundProcess: 'claude', - data: ready, - launchAgent: 'claude' - }, - { getAgentStatusSnapshot: full, getAgentStatusSnapshotForPane: indexed } - ) - row.receivedAt = Date.now() - full.mockClear() - indexed.mockClear() - try { - const waiting = waitForTranscriptIdle(pane, 2_000) - await (pending - ? expect(waiting).rejects.toThrow(/timeout/) - : expect(waiting).resolves.toMatchObject({ satisfied: true })) - expect(indexed).toHaveBeenCalled() - expect(new Set(indexed.mock.calls.map(([paneKey]) => paneKey))).toEqual(new Set([PANE])) - expect(full).not.toHaveBeenCalled() - } finally { - pane.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - } - ) -}) diff --git a/src/main/runtime/cline-screen-readiness-transcripts.test.ts b/src/main/runtime/cline-screen-readiness-transcripts.test.ts index c2ff78bbd96..de631d4638b 100644 --- a/src/main/runtime/cline-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/cline-screen-readiness-transcripts.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + waitForTranscriptIdle, + TRANSCRIPT_PANE_PTY_ID +} from './agent-transcript-pane-test-harness' import { finalReadProjection, finalReplayFrame, @@ -11,6 +15,18 @@ import { readsIdleComposer } from './screen-ruled-agent-transcript-suite' import { RuntimeMachineName } from './runtime-machine-name' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { waitForWorkerStartComposer } from './launched-agent-composer-readiness' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + getSyntheticAgentTerminalTitle, + shouldDriveSyntheticAgentTitleFromHook +} from '../../shared/synthetic-agent-title' +import { hasExplicitIdleTitle } from './tui-idle-evidence' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -19,84 +35,428 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -let machineNameStartSpy: { mockRestore(): void } | undefined +describe('Cline captured screen readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) -}) - -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined -}) - -// cline 3.0.66 on macOS with an isolated config; 3.0.65 on Windows recorded for PR #23269 (see -// each .meta.json); STA-8741. -const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) -const READY = [ - at120x40('cline-3-0-66-ready', 'promo dismissed, startup composer'), - at120x40('cline-3-0-66-ready-plan', 'Plan mode composer'), - { name: 'cline-3-0-66-ready-80x24', what: 'startup composer', cols: 80, rows: 24 }, - at120x40('cline-3-0-66-turn-ended', 'a turn has ended'), - at120x40('cline-3-0-65-win32-startup', 'Windows startup composer') -] -const NOT_READY = [ - at120x40('cline-3-0-66-promo', 'Cline Desktop promo over the composer'), - at120x40('cline-3-0-66-permission', 'tool approval prompt'), - at120x40('cline-3-0-66-slash-menu', 'slash menu open'), - at120x40('cline-3-0-66-draft', 'unsent text in the composer') -] -const STREAMING = 'cline-3-0-66-busy-streaming' - -describe('Cline readiness from captured bytes', () => { - describeScreenRuledAgentTranscripts({ - agent: 'cline', - foregroundProcess: 'cline', - ready: READY, - notReady: NOT_READY, - // Why all: an idle Cline is quiet, so the quiet-process lane settles it. - readyWithoutScreen: READY.map(({ name }) => name) + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) }) - // Presence precondition for the restored-pane suite: the read projection blanks this placeholder. - it('reads the ended turn through a projection that blanks its placeholder', async () => { - const { lines, draft } = await finalReadProjection('cline-3-0-66-turn-ended', 120, 40) - expect(lines).toContain('❯') - expect(draft).toBe('Ask anything...') + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined }) - // Why only quiescence can refuse it: the streaming reply has scrolled its spinner away. - it('paints the same empty composer while a reply streams', async () => { - const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40) - expect(readsIdleComposer('cline', ruledScreenLines)).toBe(true) - }) + // cline 3.0.66 on macOS with an isolated config; 3.0.65 on Windows recorded for PR #23269 (see + // each .meta.json); STA-8741. + const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) + const READY = [ + at120x40('cline-3-0-66-ready', 'promo dismissed, startup composer'), + at120x40('cline-3-0-66-ready-plan', 'Plan mode composer'), + { name: 'cline-3-0-66-ready-80x24', what: 'startup composer', cols: 80, rows: 24 }, + at120x40('cline-3-0-66-turn-ended', 'a turn has ended'), + at120x40('cline-3-0-65-win32-startup', 'Windows startup composer') + ] + const NOT_READY = [ + at120x40('cline-3-0-66-promo', 'Cline Desktop promo over the composer'), + at120x40('cline-3-0-66-permission', 'tool approval prompt'), + at120x40('cline-3-0-66-slash-menu', 'slash menu open'), + at120x40('cline-3-0-66-draft', 'unsent text in the composer') + ] + const STREAMING = 'cline-3-0-66-busy-streaming' - it('refuses every frame whose spinner row is still on screen', async () => { - let spinnerFrames = 0 - for await (const { ruledScreenLines } of replayTranscript( - readRuntimeFixture(STREAMING), - 120, - 40 - )) { - if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) { - spinnerFrames += 1 - expect(readsIdleComposer('cline', ruledScreenLines)).toBe(false) + describe('Cline readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'cline', + foregroundProcess: 'cline', + ready: READY, + notReady: NOT_READY, + // Why all: an idle Cline is quiet, so the quiet-process lane settles it. + readyWithoutScreen: READY.map(({ name }) => name) + }) + + // Presence precondition for the restored-pane suite: the read projection blanks this placeholder. + it('reads the ended turn through a projection that blanks its placeholder', async () => { + const { lines, draft } = await finalReadProjection('cline-3-0-66-turn-ended', 120, 40) + expect(lines).toContain('❯') + expect(draft).toBe('Ask anything...') + }) + + // Why only quiescence can refuse it: the streaming reply has scrolled its spinner away. + it('paints the same empty composer while a reply streams', async () => { + const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40) + expect(readsIdleComposer('cline', ruledScreenLines)).toBe(true) + }) + + it('refuses every frame whose spinner row is still on screen', async () => { + let spinnerFrames = 0 + for await (const { ruledScreenLines } of replayTranscript( + readRuntimeFixture(STREAMING), + 120, + 40 + )) { + if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) { + spinnerFrames += 1 + expect(readsIdleComposer('cline', ruledScreenLines)).toBe(false) + } } - } - // Presence precondition: the thinking spinner was painted above the composer. - expect(spinnerFrames).toBeGreaterThan(0) - }) + // Presence precondition: the thinking spinner was painted above the composer. + expect(spinnerFrames).toBeGreaterThan(0) + }) - it('does not settle a streaming pane before it goes quiet', async () => { + it('does not settle a streaming pane before it goes quiet', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'cline', + launchAgent: 'cline', + data: readRuntimeFixture(STREAMING), + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + // Why 2.5s: inside the 3s quiescence window, past the 2s poll. + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) + }, 15_000) + }) +}) + +describe('Grok captured worker startup', () => { + /** + * A Grok worker start replayed through the runtime at its recorded read times. Grok draws its + * composer glyph at 0.6 s and then shimmers its logo until 9.9 s, and its only title is its bare + * name, so a wait that holds that title to quiet output answers ten seconds after the composer. + */ + + const COMPOSER_FRAME_MS = GROK_STARTUP_PTY_TRACE.find((chunk) => chunk.data?.includes('❯'))?.t + const LAST_FRAME_MS = GROK_STARTUP_PTY_TRACE.at(-1)?.t ?? 0 + + async function replayGrokWorkerStart(): Promise { const { runtime, handle } = await createTranscriptPane({ paneTitle: 'Terminal', - foregroundProcess: 'cline', - launchAgent: 'cline', - data: readRuntimeFixture(STREAMING), - size: { cols: 120, rows: 40 } + foregroundProcess: 'grok', + launchAgent: 'grok', + size: { cols: 120, rows: 30 }, + data: '' }) - await runtime.readTerminal(handle, { screen: true }) - // Why 2.5s: inside the 3s quiescence window, past the 2s poll. - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) - }, 15_000) + vi.useFakeTimers() + const startedAt = Date.now() + let settledAt: number | null = null + void waitForWorkerStartComposer(runtime, handle, 'grok', 60_000).then( + (wait) => { + settledAt = wait.satisfied ? Date.now() - startedAt : null + }, + () => {} + ) + for (const chunk of GROK_STARTUP_PTY_TRACE) { + await vi.advanceTimersByTimeAsync(Math.max(0, startedAt + chunk.t - Date.now())) + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + chunk.data ?? 'x'.repeat(chunk.bytes ?? 0), + Date.now() + ) + } + await vi.advanceTimersByTimeAsync(5_000) + return settledAt + } + + describe('a Grok worker start', () => { + afterEach(() => vi.useRealTimers()) + + it('is ready on its composer glyph, not once its logo stops animating', async () => { + expect(COMPOSER_FRAME_MS).toBeLessThan(1_000) + const settledAt = await replayGrokWorkerStart() + expect(settledAt).not.toBeNull() + expect(settledAt).toBeGreaterThanOrEqual(COMPOSER_FRAME_MS ?? 0) + // Within a second of the glyph: main's own wait answered on the title at once (2.2-2.8 s live). + expect(settledAt).toBeLessThan((COMPOSER_FRAME_MS ?? 0) + 1_000) + expect(settledAt).toBeLessThan(LAST_FRAME_MS) + }) + }) +}) + +describe('Muse captured workspace readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined + + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) + }) + + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined + }) + + describe('Muse readiness from captured terminal bytes', () => { + it('recognizes a ready folder workspace without a skills summary', async () => { + const data = readFileSync( + join(__dirname, '__fixtures__', 'muse-empty-folder-ready.txt'), + 'utf8' + ) + expect(data).toContain(String.fromCharCode(27)) + expect(data).not.toContain('Skills:') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'muse-first-class-workspace', + foregroundProcess: 'muse-bin-1.3.0-R3401.1', + launchAgent: 'muse', + data + }) + await runtime.readTerminal(handle, { screen: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 10_000)).resolves.toMatchObject({ + satisfied: true + }) + }, 15_000) + }) +}) + +describe('Qoder captured startup readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined + + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) + }) + + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined + }) + + describe('captured Qoder 1.1.64 startup', () => { + it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( + 'preserves Qoder identity in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(title).toContain(' | Ready') + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qodercli-1.1.64', + launchAgent: 'qoder', + data, + size: { cols: 100, rows: 32 } + }) + const shown = await runtime.showTerminal(handle) + expect(shown.agentIdentity).toBe('qoder') + if (fixture === 'qoder-trust-dialog') { + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 600).catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + if (fixture === 'qoder-ready') { + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 1500) + expect(readiness.satisfied).toBe(true) + } + } + ) + }) + + describe('captured Qoder China 1.1.65 startup', () => { + it.each(['qoder-cn-startup', 'qoder-cn-signin'])( + 'checks the composer in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI CN') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qoderclicn', + launchAgent: 'qoder-cn', + data, + size: { cols: 120, rows: 40 } + }) + expect((await runtime.showTerminal(handle)).agentIdentity).toBe('qoder-cn') + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 800).catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + ) + }) +}) + +describe('ZCode captured composer readiness', () => { + function readTranscript(): string { + return readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8') + } + + describe('ZCode readiness from captured terminal bytes', () => { + it('accepts a fresh composer after the renderer adopts the terminal handle', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'worker-zcode', + foregroundProcess: 'zcode', + launchAgent: 'zcode', + data: '\x1b[?1049h╭' + }) + await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + }) + + it('never emits an OSC title, so no title lane can settle its wait', () => { + const data = readTranscript() + expect(data).toContain(String.fromCharCode(27)) + // Why: this absence is the whole reason ZCode needs a body-evidence readiness lane. + expect(data).not.toMatch(new RegExp(`${String.fromCharCode(27)}\\][0-2];`)) + }) + + it('keeps repainting long after the composer mounts, so a quiet window never settles', () => { + const data = readTranscript() + const composerIndex = data.indexOf('╭') + expect(composerIndex).toBeGreaterThan(-1) + // Why: ~175KB of banner animation after the composer is up. A quiet-render window + // measured in hundreds of ms cannot fire anywhere in that span. + expect(data.length - composerIndex).toBeGreaterThan(100_000) + }) + + it('leaves no durable readiness evidence in the wait-text tail', async () => { + const data = readTranscript() + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'zcode-first-class-workspace', + foregroundProcess: 'zcode', + launchAgent: 'zcode', + data + }) + // Why this asserts a NEGATIVE: Orca's wait text is a line-folded tail, and ZCode paints + // its composer once and then repaints only the banner — so the composer scrolls out and + // no screen rule can settle the wait. This is the evidence for driving ZCode readiness + // from its synthetic hook title instead (see synthetic-agent-title.ts). + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).rejects.toThrow(/timeout/) + }, 15_000) + + it('settles a tui-idle wait from the synthetic hook title Orca owns for ZCode', () => { + expect(getSyntheticAgentTerminalTitle('zcode', 'done')).toBe('ZCode ready') + expect(getSyntheticAgentTerminalTitle('zcode', 'waiting')).toBe('ZCode - action required') + expect(shouldDriveSyntheticAgentTitleFromHook('zcode', 'working')).toBe(true) + expect( + hasExplicitIdleTitle({ lastAgentStatus: 'idle', lastOutputAt: Date.now() }, 'ZCode ready') + ).toBe(true) + }) + + it('fires the draft-paste signal at the composer mount, not at the hard timeout', () => { + const data = readTranscript() + const scanner = createDraftPasteReadyScanner('zcode-composer-prompt') + const composerIndex = data.indexOf('╭') + // Why: feeding the stream in PTY-sized chunks proves the marker survives chunk splits. + let readyAt: number | null = null + for (let offset = 0; offset < data.length; offset += 4096) { + const chunk = data.slice(offset, offset + 4096) + if (scanner.observe(chunk).ready) { + readyAt = offset + chunk.length + break + } + } + expect(readyAt).not.toBeNull() + // Ready lands on the chunk that carries the composer corner, not thousands of frames later. + expect(readyAt!).toBeGreaterThanOrEqual(composerIndex) + expect(readyAt!).toBeLessThan(composerIndex + 8192) + }) + }) +}) + +describe('Shell titles during agent startup', () => { + /** + * A shell that titles the command it is about to run (zsh preexec auto-title) writes the agent's + * bare name before the agent's TUI has mounted. For an agent whose only rest signal is that name, + * the title alone used to settle `tui-idle`, so a launch pasted its prompt into a TUI still + * booting, or into the shell. A launch readiness wait holds that title to a quiet stream. + */ + + const ESC = String.fromCharCode(27) + const BEL = String.fromCharCode(7) + const BOOT_FRAME = 'Loading…\r\n' + + /** The shell echoes the launch line, then its preexec hook titles the pane with the command. */ + async function launchedPaneTitledByShell(agent: 'gemini' | 'copilot') { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: agent, + launchAgent: agent, + data: `$ ${agent}\r\n${ESC}]2;${agent}${BEL}` + }) + } + + /** The agent is still painting its boot screen: output keeps arriving each second. */ + async function keepBooting( + runtime: Awaited>['runtime'], + seconds: number + ): Promise { + for (let second = 0; second < seconds; second += 1) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, BOOT_FRAME, Date.now()) + await vi.advanceTimersByTimeAsync(1000) + } + } + + describe('a launch waiting on an agent whose shell titled the pane with its name', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // Gemini's bare name was once normalized into its rest glyph; Copilot's name is its only signal. + it.each(['gemini', 'copilot'] as const)( + 'does not treat the shell’s auto-title as readiness while %s is still booting', + async (agent) => { + const { runtime, handle } = await launchedPaneTitledByShell(agent) + const settled = vi.fn() + runtime + .waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 60_000, + launchReadiness: true + }) + .then(settled, settled) + + await keepBooting(runtime, 10) + + expect(settled).not.toHaveBeenCalled() + } + ) + + it('settles once the agent’s stream goes quiet under that title', async () => { + const { runtime, handle } = await launchedPaneTitledByShell('gemini') + const settled = vi.fn() + runtime + .waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 60_000, + launchReadiness: true + }) + .then(settled, settled) + + await keepBooting(runtime, 4) + await vi.advanceTimersByTimeAsync(8_000) + + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) + }) + + it('keeps settling a later, non-launch wait on the name alone, as agents that never quiet need', async () => { + const { runtime, handle } = await launchedPaneTitledByShell('gemini') + const settled = vi.fn() + runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + .then(settled, settled) + + await keepBooting(runtime, 10) + + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) + }) + }) }) diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts index 7ebc7a5d2b8..491e974e229 100644 --- a/src/main/runtime/codex-header-readiness-transcripts.test.ts +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -15,6 +15,8 @@ import { isKnownReadyPromptSettled, isQuietReadyScreenBody } from './terminal-wait-detection' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' +import { clearWorkingIndicators } from '../../shared/agent-title-status' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -23,300 +25,587 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// codex-cli 0.157.1 recordings at 120x40 (see each .meta.json); STA-8628. -const PLAIN = 'codex-0157-plain-ready' -const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' -const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' -const NO_DAEMON = 'codex-0157-no-daemon-effort-override' -// Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. -const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' -const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] +describe('Codex captured header readiness', () => { + // codex-cli 0.157.1 recordings at 120x40 (see each .meta.json); STA-8628. + const PLAIN = 'codex-0157-plain-ready' + const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' + const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' + const NO_DAEMON = 'codex-0157-no-daemon-effort-override' + // Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. + const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' + const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] -async function finalFrame( - name: string, - cols: number, - rows: number -): Promise { - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { - last = frame + async function finalFrame( + name: string, + cols: number, + rows: number + ): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { + last = frame + } + if (!last) { + throw new Error(`empty fixture ${name}`) + } + return last } - if (!last) { - throw new Error(`empty fixture ${name}`) + + function screenShowsLoadingHeader(screenLines: string[]): boolean { + const screen = screenLines.join('\n').toLowerCase() + return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) } - return last -} -function screenShowsLoadingHeader(screenLines: string[]): boolean { - const screen = screenLines.join('\n').toLowerCase() - return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) -} + // Codex's default status row opens with ` ·`; only the live chat paints it. + const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ -// Codex's default status row opens with ` ·`; only the live chat paints it. -const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ - -function screenShowsProvisionalStartup(screenLines: string[]): boolean { - return ( - screenShowsLoadingHeader(screenLines) && - !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) - ) -} - -describe('Codex 0.157 header readiness from captured bytes', () => { - it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( - '%s: the line-folded wait text never shows a ready header', - async (name) => { - const { waitText } = await finalFrame(name, 120, 40) - // Why: the cell-diff repaint folds to `dirctory:` — the STA-8628 timeout. - expect(waitText.toLowerCase()).toContain('dirctory:') - expect(isKnownReadyPromptPreview(waitText)).toBe(false) - } - ) - - it.each(ALL_FIXTURES)( - '%s: the screen never adds readiness while loading, and is ready at the final screen', - async (name) => { - let sawLoadingHeader = false - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - if (screenShowsLoadingHeader(frame.screenLines)) { - sawLoadingHeader = true - expect(isQuietReadyScreenBody('', 'codex', () => frame.screenLines)).toBe(false) - } - last = frame - } - // Presence precondition: a loading frame was actually exercised. - expect(sawLoadingHeader).toBe(true) - expect(last).not.toBeNull() - expect(isQuietReadyScreenBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) - } - ) - - // Why: 0.157 discards input typed during its daemon start, behind the provisional header. - it.each(ALL_FIXTURES)( - '%s: never ready while the screen shows the provisional `model: loading` startup screen', - async (name) => { - let sawTextOnlyReadiness = false - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - if (screenShowsProvisionalStartup(frame.screenLines)) { - sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - false - ) - expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).toBe( - false - ) - } - } - // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. - if (name === PLAIN || name === FRESH_HOME) { - expect(sawTextOnlyReadiness).toBe(true) - } - } - ) - - // Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475). - describe.each([ - [120, 40], - [80, 24], - [30, 50], - [108, 30], - [60, 5] - ])('at %ix%i the screen never takes a settled header away from the text rules', (cols, rows) => { - it.each(ALL_FIXTURES)('%s', async (name) => { - let settledFrames = 0 - for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { - if (isKnownReadyPromptSettled(frame.waitText)) { - settledFrames += 1 - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - true - ) - } - } - // Presence precondition: the text-copy fixtures reach a settled header. - if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { - expect(settledFrames).toBeGreaterThan(0) - } - }) - }) - - it('keeps the text rules when there is no live screen', async () => { - const { waitText } = await finalFrame(PLAIN, 120, 40) - expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe( - isKnownReadyPromptPreview(waitText) + function screenShowsProvisionalStartup(screenLines: string[]): boolean { + return ( + screenShowsLoadingHeader(screenLines) && + !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) ) - expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(true) - }) + } - it('does not read a mid-turn composer as ready', () => { - const screenLines = [ - '› Summarize the repository layout', - '• Working (12s • esc to interrupt)', - '› Ask Codex to do anything', - ' GPT-6-Sol high · ~/repo/app' - ] - expect(isQuietReadyScreenBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) - }) - - it('does not settle when a blocking dialog is painted below the header', () => { - const screenLines = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │', - 'Do you trust the contents of this directory?', - 'Press enter to continue' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(false) - }) - - it('reads only the header box, not chat below it that mentions Codex', () => { - const screenLines = [ - '╭──────────────────────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────────────────────╯', - '› Why does OpenAI Codex print model: loading at startup?' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - }) - - it('never reads a non-codex screen, even one showing the Codex header', () => { - const screenLines = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │' - ] - const readScreenLines = vi.fn(() => screenLines) - expect(isQuietReadyScreenBody('', 'claude', readScreenLines)).toBe(false) - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) - - describe('at the 80x24 default grid the header garbles and today’s answer stands', () => { - it.each(ALL_FIXTURES)('%s', async (name) => { - const { screenLines, waitText } = await finalFrame(name, 80, 24) - expect(isQuietReadyScreenBody(waitText, 'codex', () => screenLines)).toBe( - isKnownReadyPromptPreview(waitText) - ) - }) - }) - - describe('through the runtime', () => { - async function codexPane(name: string, size?: { cols: number; rows: number }) { - const created = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: readRuntimeFixture(name), - size - }) - await created.runtime.readTerminal(created.handle, { screen: true }) - return created - } + describe('Codex 0.157 header readiness from captured bytes', () => { + it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( + '%s: the line-folded wait text never shows a ready header', + async (name) => { + const { waitText } = await finalFrame(name, 120, 40) + // Why: the cell-diff repaint folds to `dirctory:` — the STA-8628 timeout. + expect(waitText.toLowerCase()).toContain('dirctory:') + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + } + ) it.each(ALL_FIXTURES)( - '%s: a tui-idle wait settles from the live screen', + '%s: the screen never adds readiness while loading, and is ready at the final screen', async (name) => { - const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) - // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + let sawLoadingHeader = false + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsLoadingHeader(frame.screenLines)) { + sawLoadingHeader = true + expect(isQuietReadyScreenBody('', 'codex', () => frame.screenLines)).toBe(false) + } + last = frame + } + // Presence precondition: a loading frame was actually exercised. + expect(sawLoadingHeader).toBe(true) + expect(last).not.toBeNull() + expect(isQuietReadyScreenBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) + } + ) + + // Why: 0.157 discards input typed during its daemon start, behind the provisional header. + it.each(ALL_FIXTURES)( + '%s: never ready while the screen shows the provisional `model: loading` startup screen', + async (name) => { + let sawTextOnlyReadiness = false + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsProvisionalStartup(frame.screenLines)) { + sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + expect( + isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true) + ).toBe(false) + } + } + // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. + if (name === PLAIN || name === FRESH_HOME) { + expect(sawTextOnlyReadiness).toBe(true) + } + } + ) + + // Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475). + describe.each([ + [120, 40], + [80, 24], + [30, 50], + [108, 30], + [60, 5] + ])( + 'at %ix%i the screen never takes a settled header away from the text rules', + (cols, rows) => { + it.each(ALL_FIXTURES)('%s', async (name) => { + let settledFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { + if (isKnownReadyPromptSettled(frame.waitText)) { + settledFrames += 1 + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + true + ) + } + } + // Presence precondition: the text-copy fixtures reach a settled header. + if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { + expect(settledFrames).toBeGreaterThan(0) + } + }) + } + ) + + it('keeps the text rules when there is no live screen', async () => { + const { waitText } = await finalFrame(PLAIN, 120, 40) + expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe( + isKnownReadyPromptPreview(waitText) + ) + expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(true) + }) + + it('does not read a mid-turn composer as ready', () => { + const screenLines = [ + '› Summarize the repository layout', + '• Working (12s • esc to interrupt)', + '› Ask Codex to do anything', + ' GPT-6-Sol high · ~/repo/app' + ] + expect(isQuietReadyScreenBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) + }) + + it('does not settle when a blocking dialog is painted below the header', () => { + const screenLines = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │', + 'Do you trust the contents of this directory?', + 'Press enter to continue' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(false) + }) + + it('reads only the header box, not chat below it that mentions Codex', () => { + const screenLines = [ + '╭──────────────────────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────────────────────╯', + '› Why does OpenAI Codex print model: loading at startup?' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + }) + + it('never reads a non-codex screen, even one showing the Codex header', () => { + const screenLines = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │' + ] + const readScreenLines = vi.fn(() => screenLines) + expect(isQuietReadyScreenBody('', 'claude', readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) + + describe('at the 80x24 default grid the header garbles and today’s answer stands', () => { + it.each(ALL_FIXTURES)('%s', async (name) => { + const { screenLines, waitText } = await finalFrame(name, 80, 24) + expect(isQuietReadyScreenBody(waitText, 'codex', () => screenLines)).toBe( + isKnownReadyPromptPreview(waitText) + ) + }) + }) + + describe('through the runtime', () => { + async function codexPane(name: string, size?: { cols: number; rows: number }) { + const created = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size + }) + await created.runtime.readTerminal(created.handle, { screen: true }) + return created + } + + it.each(ALL_FIXTURES)( + '%s: a tui-idle wait settles from the live screen', + async (name) => { + const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, + 15_000 + ) + + it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { + const data = readRuntimeFixture(FRESH_HOME) + const install = data.indexOf('Installing daemon') + // Presence precondition: the cut keeps the provisional header and stops before the live chat. + expect(install).toBeGreaterThan(0) + const provisional = data.slice(0, data.indexOf('\n', install) + 1) + expect(provisional).toMatch(/model:.*loading/) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: provisional, + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + // Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds. + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) + + // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the + // runtime asks for a visible-screen read instead of its own text copy. + it('does not settle from a visible-screen read of the provisional screen', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '', + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.0) │', + '│ model: loading /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' ? for shortcuts' + ], + truncated: false, + nextCursor: null, + source: 'screen' + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) + // Presence precondition: the visible-screen probe actually ran. + expect(readVisibleScreen).toHaveBeenCalled() + }, 15_000) + + // Why: restored bytes set no lastOutputAt, so the quiet lane has no clock to wait out. + it('settles a restored Codex pane from its header, as main did', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '' + }) + await runtime.readTerminal(handle, { screen: true }) + runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, { + text: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: gpt-6-sol high /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' gpt-6-sol high · ~/repo/app' + ].join('\r\n') + }) + // Why no screen: the visible-screen probe must not be what settles the wait. + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [], + truncated: false, + nextCursor: null, + source: 'screen-unavailable' + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) - }, - 15_000 - ) + }, 15_000) - it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { - const data = readRuntimeFixture(FRESH_HOME) - const install = data.indexOf('Installing daemon') - // Presence precondition: the cut keeps the provisional header and stops before the live chat. - expect(install).toBeGreaterThan(0) - const provisional = data.slice(0, data.indexOf('\n', install) + 1) - expect(provisional).toMatch(/model:.*loading/) - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: provisional, - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - // Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds. - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) - }, 15_000) + it('keeps timing out on the garbled 80x24 default grid, as before', async () => { + const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) + }) + }) +}) - // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the - // runtime asks for a visible-screen read instead of its own text copy. - it('does not settle from a visible-screen read of the provisional screen', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: '', - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle, - status: 'running', - tail: [ - '╭──────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.0) │', - '│ model: loading /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────╯', - '› Ask Codex to do anything', - ' ? for shortcuts' - ], - truncated: false, - nextCursor: null, - source: 'screen' - }) - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) - // Presence precondition: the visible-screen probe actually ran. - expect(readVisibleScreen).toHaveBeenCalled() - }, 15_000) +describe('Codex launched composer readiness', () => { + // A source-control button's multi-line prompt reaches Codex only through this wait, so a Codex + // release that the launch wait cannot read ready reports "prompt wasn't sent". + async function launchedCodexPane(data: string) { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data, + size: { cols: 120, rows: 40 } + }) + } - // Why: restored bytes set no lastOutputAt, so the quiet lane has no clock to wait out. - it('settles a restored Codex pane from its header, as main did', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: '' - }) - await runtime.readTerminal(handle, { screen: true }) - runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, { - text: [ - '╭──────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: gpt-6-sol high /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────╯', - '› Ask Codex to do anything', - ' gpt-6-sol high · ~/repo/app' - ].join('\r\n') - }) - // Why no screen: the visible-screen probe must not be what settles the wait. - vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle, - status: 'running', - tail: [], - truncated: false, - nextCursor: null, - source: 'screen-unavailable' - }) - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).resolves.toMatchObject({ + async function waitForCodexComposer( + pane: Awaited>, + timeoutMs: number, + streamedData?: string + ) { + // Pane creation uses real timers; only the readiness budget advances virtually. + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + try { + const ready = waitForLaunchedAgentComposer(pane.runtime, pane.handle, 'codex', timeoutMs) + void ready.catch(() => {}) + if (streamedData !== undefined) { + pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, streamedData, Date.now()) + } + await vi.advanceTimersByTimeAsync(timeoutMs + 1) + return await ready + } finally { + vi.useRealTimers() + } + } + + // Why cut: on an Astra model 0.158 sparkles braille stars in the empty composer, redrawing every + // 150 ms, then repaints it clean after 15 s. The capture ends on the first star frame, so replayed + // whole it holds a starred composer still, which the live stream never does. + function greetingBeforeStarfield(): string { + const data = readRuntimeFixture('codex-0158-fresh-home-greeting') + const firstStar = data.search(/38;2;\d+;\d+;\d+;48;2;30;30;30m[⠀-⣿]/) + return data.slice(0, data.lastIndexOf('\x1b[?2026h', firstStar)) + } + + describe('launch readiness for a freshly launched Codex', () => { + it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => { + const data = greetingBeforeStarfield() + // Presence precondition: the cut keeps the live status row and drops every star. + expect(data).toContain('GPT-6-Astra default') + expect(data).not.toMatch(/48;2;30;30;30m[⠀-⣿]/) + const pane = await launchedCodexPane(data) + await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) }, 15_000) - it('keeps timing out on the garbled 80x24 default grid, as before', async () => { - const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => { + const pane = await launchedCodexPane(readRuntimeFixture('codex-0157-plain-ready')) + await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it.each([ + ['codex-0158-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], + ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], + ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] + ])( + '%s: stops at the dialog as %s instead of pasting into it', + async (fixture, reason) => { + const pane = await launchedCodexPane(readRuntimeFixture(fixture)) + await expect(waitForCodexComposer(pane, 2_500)).resolves.toMatchObject({ + satisfied: false, + blockedReason: reason + }) + }, + 15_000 + ) + + // Why streamed: live, the dialog's `›` selector arrives after bracketed paste and fires the + // composer signal, so only the screen check keeps it from pasting; it hands the dialog to + // `tui-idle` to report, well inside the desktop paste's budget. + it.each([ + ['codex-0157-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], + ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], + ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] + ])( + '%s streamed in after the launch: reports the dialog as %s, never as the composer', + async (fixture, reason) => { + const data = readRuntimeFixture(fixture) + // Presence precondition: the dialog draws Codex's composer glyph after bracketed paste. + expect(data.slice(data.indexOf('\x1b[?2004h'))).toContain('›') + const pane = await launchedCodexPane('') + await expect(waitForCodexComposer(pane, 2_500, data)).resolves.toMatchObject({ + satisfied: false, + blockedReason: reason + }) + }, + 15_000 + ) + + // Why: 0.157 discards input typed behind its provisional `model: loading` screen. + it('codex-0157-fresh-home-daemon-install: does not read the provisional screen as ready', async () => { + const full = readRuntimeFixture('codex-0157-fresh-home-daemon-install') + const install = full.indexOf('Installing daemon') + // Presence precondition: the cut keeps the provisional header and stops before the live chat. + expect(install).toBeGreaterThan(0) + const provisional = full.slice(0, full.indexOf('\n', install) + 1) + expect(provisional).toMatch(/model:.*loading/) + const pane = await launchedCodexPane(provisional) + await expect(waitForCodexComposer(pane, 5_000)).rejects.toThrow(/timeout/) }, 15_000) }) }) + +describe('Display title clearing and live agent presence', () => { + // Synthetic protocol cases: they test which title readiness, display and presence each read, + // not what a particular CLI paints. The 3 s timer is the stale-working-title clear. + describe('the stale-working title clear is display-only', () => { + it.each([ + ['claude', '. claude', '* claude'], + ['codex', '⠋ Codex working', 'Codex ready'], + ['gemini', '✦ Gemini CLI', '◇ Gemini CLI'], + ['opencode', '⠋ OC | task', 'OC | task'], + ['pi', 'π : project', 'π - project'] + ] as const)( + '%s does not become ready when its display title clears', + async (agent, busy, idle) => { + vi.useFakeTimers() + try { + const titleChanges: string[] = [] + const { runtime, handle } = await createTranscriptPane( + { + paneTitle: busy, + foregroundProcess: agent, + data: '', + launchAgent: agent + }, + { + onTerminalSideEffects: (batch) => { + for (const fact of batch.facts) { + if (fact.kind === 'title') { + titleChanges.push(fact.normalizedTitle) + } + } + } + } + ) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${busy}\x07`, Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + expect(titleChanges.at(-1)).toBe(clearWorkingIndicators(busy)) + // A renderer graph publish can carry the display-only cleared title back to main. + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: clearWorkingIndicators(busy), + activeLeafId: '11111111-1111-4111-8111-111111111111', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneRuntimeId: 1, + ptyId: TRANSCRIPT_PANE_PTY_ID, + paneTitle: clearWorkingIndicators(busy) + } + ] + }) + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) + const assertion = expect(waiting).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(200)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${idle}\x07`, Date.now()) + const ready = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + const readyAssertion = expect(ready).resolves.toMatchObject({ satisfied: true }) + await Promise.all([readyAssertion, vi.advanceTimersByTimeAsync(2_000)]) + } finally { + vi.useRealTimers() + } + } + ) + + // A name-shaped title (`⠋ Codex working`) clears to `Codex`, which still names an agent, as on main. + it.each([['⠋ repo']])( + 'an agent that exited behind %s does not pass for a running agent', + async (title) => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'zsh', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${title}\x07`, Date.now()) + // The agent exits and the shell prints its prompt: output, but no title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + } + ) + + it('a live agent behind a cleared title is still found by its process', async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("the agent's next genuine title proves presence again", async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠙ repo\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + } finally { + vi.useRealTimers() + } + }) + + it('a live Cursor spinner title proves presence when no foreground read can answer', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + }) + }) +}) diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index db6077a723e..032f89908a5 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -21,6 +21,7 @@ import { isTuiIdleReadyVerdict, type TuiIdleEvaluationInput } from './tui-idle-evidence' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -29,331 +30,178 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const QUIESCENCE_MS = 3000 +describe('Codex quiet ready screen', () => { + const QUIESCENCE_MS = 3000 -// codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn, -// and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's. -const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn'] -const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES] -const DIALOG_FIXTURES = [ - 'codex-0-157-1-update-dialog', - 'codex-0-158-0-approval', - 'codex-0-158-0-trustprompt' -] -const STA_8628_FIXTURES = [ - 'codex-0157-plain-ready', - 'codex-0157-effort-override-embedded-warning', - 'codex-0157-config-override-embedded-warning', - 'codex-0157-no-daemon-effort-override' -] - -const BUSY_STATUS_RE = /to interrupt\)/ -const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m -const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m -const DIALOG_RE = - /update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/ - -function screenOf(frame: TranscriptReplayFrame): string { - return frame.screenLines.join('\n').toLowerCase() -} - -async function collectFrames( - data: string | readonly string[], - cols = 120, - rows = 40 -): Promise { - const frames: TranscriptReplayFrame[] = [] - for await (const frame of replayTranscript(data, cols, rows)) { - frames.push(frame) - } - return frames -} - -describe('Codex composer ready screen, frame by frame', () => { - it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])( - '%s: never ready while loading, mid-turn, or under a drawn dialog', - async (name) => { - const seen = { loading: 0, busy: 0, dialog: 0 } - for (const frame of await collectFrames(readRuntimeFixture(name))) { - const screen = screenOf(frame) - // Why only once the composer is gone: a half-drawn dialog keeps it for a few frames, - // which quiescence absorbs; a drawn dialog replaces it. - const kind = HEADER_LOADING_RE.test(screen) - ? 'loading' - : BUSY_STATUS_RE.test(screen) - ? 'busy' - : DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen) - ? 'dialog' - : null - if (kind) { - seen[kind] += 1 - expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({ - kind, - ready: false - }) - } - } - // Presence preconditions: each fixture exercises the states it was recorded for. - expect(seen.loading).toBeGreaterThan(0) - if (DIALOG_FIXTURES.includes(name)) { - expect(seen.dialog).toBeGreaterThan(0) - } else { - expect(seen.busy).toBeGreaterThan(0) - } - }, - 30_000 - ) - - it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])( - '%s: ready on the final idle screen', - async (name) => { - const frames = await collectFrames(readRuntimeFixture(name)) - expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true) - } - ) - - it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => { - const frames = await collectFrames(readRuntimeFixture(name)) - expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false) - }) - - it('is not ready without a live screen', () => { - expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false) - }) - - it('reads the placeholder only as the composer line, not quoted mid-line', () => { - const quoted = ['$ grep placeholder notes.txt', 'the box says › Ask Codex to do anything', '$'] - expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false) - }) - - it('reads the busy row only above the composer, not a status row quoted in the answer', () => { - const screenLines = [ - '>_ OpenAI Codex (v0.158.0)', - ' ~/repo', - '', - '› what does the status row look like mid-turn?', - '', - '• It reads like this:', - '', - ' • Working (0s • esc to interrupt)', - '', - ' The timer counts up until the turn ends, and the row', - ' disappears once the answer is complete.', - '', - '› Ask Codex to do anything', - '', - ' gpt-5.6-sol medium · ~/repo', - ' ? for shortcuts' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - const composer = screenLines.indexOf('› Ask Codex to do anything') - const busy = [ - ...screenLines.slice(0, composer), - '• Working (3s • esc to interrupt)', - ' └ Tip: start a fresh idea with /new', - '', - ...screenLines.slice(composer) - ] - expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false) - }) - - it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => { - // Live 0.158 screen after a worker answered with a status row verbatim (STA-8834). - const screenLines = [ - ' === TASK ===', - ' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.', - '• • Working (0s • esc to interrupt)', - '• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…', - ' └ Sent msg_ff7be3f25d7e', - ' + Show details', - '• • Working (0s • esc to interrupt)', - ' 11:15 PM', - '› Ask Codex to do anything', - ' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome', - ' ? for shortcuts' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - }) - - it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => { - const screenLines = [ - '>_ OpenAI Codex (v0.158.0)', - ' ~/repo', - '', - '› tidy the README', - '', - '• Done. Would you like to proceed with the changelog too?', - '', - '› Ask Codex to do anything', - '', - ' ? for shortcuts' - ] - const waitText = screenLines.join('\n') - const verdict = evaluateTuiIdle({ - record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(waitText, 'codex', () => screenLines), - agent: 'codex', - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS - }) - expect(verdict.kind).toBe('ready-strong') - }) -}) - -type Timing = { promptSentAtMs: number; chunks: [number, number][] } - -function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { - const data = readRuntimeFixture(name) - const timing: Timing = JSON.parse( - readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') - ) - const chunks: string[] = [] - let offset = 0 - for (const [, length] of timing.chunks) { - chunks.push(data.slice(offset, offset + length)) - offset += length - } - expect(offset).toBe(data.length) - return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } -} - -describe('the quiet lane over recorded chunk timing (default animations)', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it.each(TIMED_FIXTURES)( - '%s: never settles mid-turn, and settles once the finished turn is quiet', - async (name) => { - const { chunks, times, promptAt } = readTimedFixture(name) - const frames = await collectFrames(chunks) - // Why after the replay: the emulator's write flush runs on real timers. - vi.useFakeTimers() - const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) - const firstTurnChunk = times.findIndex((at) => at >= promptAt) - expect(lastBusy).toBeGreaterThan(firstTurnChunk) - // The latest moment each frame stays on screen: just before the next chunk lands. - const settlesAt = (index: number, now: number): boolean => { - vi.setSystemTime(now) - return hasQuietReadyScreen( - { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, - 'codex', - () => - isQuietReadyScreenBody( - frames[index]!.waitText, - 'codex', - () => frames[index]!.screenLines - ), - QUIESCENCE_MS - ) - } - let readyBodyMidTurn = 0 - for (let index = firstTurnChunk; index <= lastBusy; index += 1) { - if (isCodexComposerReadyScreen(screenOf(frames[index]!))) { - readyBodyMidTurn += 1 - } - expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({ - index, - settles: false - }) - } - // Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing. - expect(readyBodyMidTurn).toBeGreaterThan(0) - const last = frames.length - 1 - // Why +1: the fake clock keeps whole milliseconds; the recorded times do not. - expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true) - }, - 120_000 - ) -}) - -describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it.each(['codex-0-155-1-timed-turn', 'codex-0-157-1-timed-sleep-turn'])( - '%s: never settles tui-idle mid-turn, and settles once the finished turn is quiet', - async (name) => { - const { chunks, times, promptAt } = readTimedFixture(name) - const frames = await collectFrames(chunks) - vi.useFakeTimers() - const verdictAt = (index: number, now: number) => { - vi.setSystemTime(now) - const { waitText, screenLines } = frames[index]! - return evaluateTuiIdle({ - record: { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(waitText, 'codex', () => screenLines), - agent: 'codex', - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS - }) - } - const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) - const firstTurnChunk = times.findIndex((at) => at >= promptAt) - let headerMidTurn = 0 - for (let index = firstTurnChunk; index <= lastBusy; index += 1) { - if (isKnownReadyPromptPreview(frames[index]!.waitText)) { - headerMidTurn += 1 - } - const settles = isTuiIdleReadyVerdict(verdictAt(index, times[index + 1]! - 1)) - expect({ index, settles }).toEqual({ index, settles: false }) - } - // Presence precondition: the ready header is in the tail mid-turn, so the fix is load-bearing. - expect(headerMidTurn).toBeGreaterThan(0) - const last = frames.length - 1 - expect(verdictAt(last, times[last]!).kind).toBe('pending') - expect(verdictAt(last, times[last]! + QUIESCENCE_MS + 1).kind).toBe('ready-strong') - }, - 120_000 - ) - - const header = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │' + // codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn, + // and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's. + const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn'] + const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES] + const DIALOG_FIXTURES = [ + 'codex-0-157-1-update-dialog', + 'codex-0-158-0-approval', + 'codex-0-158-0-trustprompt' + ] + const STA_8628_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override' ] - it('reads the header as tier-1 evidence only for a pane with no output clock', () => { - const waitText = header.join('\n') - expect(isKnownReadyPromptBody(waitText, 'codex', () => header, true)).toBe(false) - expect(isKnownReadyPromptBody(waitText, 'codex', () => header, false)).toBe(true) - expect(isKnownReadyPromptBody(waitText, null, () => header, true)).toBe(true) - }) + const BUSY_STATUS_RE = /to interrupt\)/ + const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m + const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m + const DIALOG_RE = + /update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/ - // Why: a restored or reattached pane has no lastOutputAt, so the quiet lane can never fire. - const NOW = 60_000 - it.each([ - [null, 'ready-strong'], - [0, 'ready-strong'], - [NOW - 1_000, 'pending'] - ] as const)( - 'a codex pane whose lastOutputAt is %s reads its header as %s', - (lastOutputAt, kind) => { - vi.useFakeTimers() - vi.setSystemTime(NOW) - const waitText = header.join('\n') - const record = { lastAgentStatus: null, lastOutputAt, lastOscTitle: null } + function screenOf(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n').toLowerCase() + } + + async function collectFrames( + data: string | readonly string[], + cols = 120, + rows = 40 + ): Promise { + const frames: TranscriptReplayFrame[] = [] + for await (const frame of replayTranscript(data, cols, rows)) { + frames.push(frame) + } + return frames + } + + describe('Codex composer ready screen, frame by frame', () => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])( + '%s: never ready while loading, mid-turn, or under a drawn dialog', + async (name) => { + const seen = { loading: 0, busy: 0, dialog: 0 } + for (const frame of await collectFrames(readRuntimeFixture(name))) { + const screen = screenOf(frame) + // Why only once the composer is gone: a half-drawn dialog keeps it for a few frames, + // which quiescence absorbs; a drawn dialog replaces it. + const kind = HEADER_LOADING_RE.test(screen) + ? 'loading' + : BUSY_STATUS_RE.test(screen) + ? 'busy' + : DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen) + ? 'dialog' + : null + if (kind) { + seen[kind] += 1 + expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({ + kind, + ready: false + }) + } + } + // Presence preconditions: each fixture exercises the states it was recorded for. + expect(seen.loading).toBeGreaterThan(0) + if (DIALOG_FIXTURES.includes(name)) { + expect(seen.dialog).toBeGreaterThan(0) + } else { + expect(seen.busy).toBeGreaterThan(0) + } + }, + 30_000 + ) + + it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])( + '%s: ready on the final idle screen', + async (name) => { + const frames = await collectFrames(readRuntimeFixture(name)) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true) + } + ) + + it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => { + const frames = await collectFrames(readRuntimeFixture(name)) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false) + }) + + it('is not ready without a live screen', () => { + expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false) + }) + + it('reads the placeholder only as the composer line, not quoted mid-line', () => { + const quoted = [ + '$ grep placeholder notes.txt', + 'the box says › Ask Codex to do anything', + '$' + ] + expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false) + }) + + it('reads the busy row only above the composer, not a status row quoted in the answer', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› what does the status row look like mid-turn?', + '', + '• It reads like this:', + '', + ' • Working (0s • esc to interrupt)', + '', + ' The timer counts up until the turn ends, and the row', + ' disappears once the answer is complete.', + '', + '› Ask Codex to do anything', + '', + ' gpt-5.6-sol medium · ~/repo', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + const composer = screenLines.indexOf('› Ask Codex to do anything') + const busy = [ + ...screenLines.slice(0, composer), + '• Working (3s • esc to interrupt)', + ' └ Tip: start a fresh idea with /new', + '', + ...screenLines.slice(composer) + ] + expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false) + }) + + it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => { + // Live 0.158 screen after a worker answered with a status row verbatim (STA-8834). + const screenLines = [ + ' === TASK ===', + ' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.', + '• • Working (0s • esc to interrupt)', + '• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…', + ' └ Sent msg_ff7be3f25d7e', + ' + Show details', + '• • Working (0s • esc to interrupt)', + ' 11:15 PM', + '› Ask Codex to do anything', + ' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + }) + + it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› tidy the README', + '', + '• Done. Would you like to proceed with the changelog too?', + '', + '› Ask Codex to do anything', + '', + ' ? for shortcuts' + ] + const waitText = screenLines.join('\n') const verdict = evaluateTuiIdle({ - record, + record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), - readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), + isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', readAgentRuleVerdict: () => null, readScreenInputVeto: () => null, @@ -361,113 +209,463 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) - expect(verdict.kind).toBe(kind) - } - ) -}) + expect(verdict.kind).toBe('ready-strong') + }) + }) -describe('reading the live screen never removes quiet-lane readiness', () => { - const records = [ - { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, - { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, - { lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' } - ] - describe.each([ - [120, 40], - [80, 24] - ])('at %ix%i', (cols, rows) => { - it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])( - '%s', + type Timing = { promptSentAtMs: number; chunks: [number, number][] } + + function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { + const data = readRuntimeFixture(name) + const timing: Timing = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') + ) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + expect(offset).toBe(data.length) + return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } + } + + describe('the quiet lane over recorded chunk timing (default animations)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(TIMED_FIXTURES)( + '%s: never settles mid-turn, and settles once the finished turn is quiet', async (name) => { - for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) { - for (const agent of ['codex', null] as const) { - const base = { - readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), - agent, - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS - } satisfies Omit - for (const record of records) { - const before = evaluateTuiIdle({ - ...base, - record, - // Why a withheld screen: isolates what the live screen adds to the quiet lane. - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(frame.waitText, agent, () => null) - }) - const after = evaluateTuiIdle({ - ...base, - record, - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines) - }) - if (isTuiIdleReadyVerdict(before)) { - expect(isTuiIdleReadyVerdict(after)).toBe(true) + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + // Why after the replay: the emulator's write flush runs on real timers. + vi.useFakeTimers() + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + expect(lastBusy).toBeGreaterThan(firstTurnChunk) + // The latest moment each frame stays on screen: just before the next chunk lands. + const settlesAt = (index: number, now: number): boolean => { + vi.setSystemTime(now) + return hasQuietReadyScreen( + { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + 'codex', + () => + isQuietReadyScreenBody( + frames[index]!.waitText, + 'codex', + () => frames[index]!.screenLines + ), + QUIESCENCE_MS + ) + } + let readyBodyMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isCodexComposerReadyScreen(screenOf(frames[index]!))) { + readyBodyMidTurn += 1 + } + expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({ + index, + settles: false + }) + } + // Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing. + expect(readyBodyMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + // Why +1: the fake clock keeps whole milliseconds; the recorded times do not. + expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true) + }, + 120_000 + ) + }) + + describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(['codex-0-155-1-timed-turn', 'codex-0-157-1-timed-sleep-turn'])( + '%s: never settles tui-idle mid-turn, and settles once the finished turn is quiet', + async (name) => { + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + vi.useFakeTimers() + const verdictAt = (index: number, now: number) => { + vi.setSystemTime(now) + const { waitText, screenLines } = frames[index]! + return evaluateTuiIdle({ + record: { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + agent: 'codex', + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) + } + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + let headerMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isKnownReadyPromptPreview(frames[index]!.waitText)) { + headerMidTurn += 1 + } + const settles = isTuiIdleReadyVerdict(verdictAt(index, times[index + 1]! - 1)) + expect({ index, settles }).toEqual({ index, settles: false }) + } + // Presence precondition: the ready header is in the tail mid-turn, so the fix is load-bearing. + expect(headerMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + expect(verdictAt(last, times[last]!).kind).toBe('pending') + expect(verdictAt(last, times[last]! + QUIESCENCE_MS + 1).kind).toBe('ready-strong') + }, + 120_000 + ) + + const header = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │' + ] + + it('reads the header as tier-1 evidence only for a pane with no output clock', () => { + const waitText = header.join('\n') + expect(isKnownReadyPromptBody(waitText, 'codex', () => header, true)).toBe(false) + expect(isKnownReadyPromptBody(waitText, 'codex', () => header, false)).toBe(true) + expect(isKnownReadyPromptBody(waitText, null, () => header, true)).toBe(true) + }) + + // Why: a restored or reattached pane has no lastOutputAt, so the quiet lane can never fire. + const NOW = 60_000 + it.each([ + [null, 'ready-strong'], + [0, 'ready-strong'], + [NOW - 1_000, 'pending'] + ] as const)( + 'a codex pane whose lastOutputAt is %s reads its header as %s', + (lastOutputAt, kind) => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const waitText = header.join('\n') + const record = { lastAgentStatus: null, lastOutputAt, lastOscTitle: null } + const verdict = evaluateTuiIdle({ + record, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), + agent: 'codex', + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) + expect(verdict.kind).toBe(kind) + } + ) + }) + + describe('reading the live screen never removes quiet-lane readiness', () => { + const records = [ + { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, + { lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' } + ] + describe.each([ + [120, 40], + [80, 24] + ])('at %ix%i', (cols, rows) => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])( + '%s', + async (name) => { + for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) { + for (const agent of ['codex', null] as const) { + const base = { + readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), + agent, + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + } satisfies Omit + for (const record of records) { + const before = evaluateTuiIdle({ + ...base, + record, + // Why a withheld screen: isolates what the live screen adds to the quiet lane. + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => null) + }) + const after = evaluateTuiIdle({ + ...base, + record, + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines) + }) + if (isTuiIdleReadyVerdict(before)) { + expect(isTuiIdleReadyVerdict(after)).toBe(true) + } } } } - } - }, - 60_000 - ) - }) -}) - -describe('agent gate', () => { - const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts'] - - it("never reads another agent's screen, even one showing Codex's placeholder", () => { - const readScreenLines = vi.fn(() => placeholderScreen) - for (const agent of ['claude', 'muse', 'gemini'] as const) { - expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false) - } - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) - - it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => { - const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ '] - const readScreenLines = vi.fn(() => catted) - expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false) - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) -}) - -describe('through the runtime', () => { - // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. - const CODEX_0158 = 'codex-0-158-0-timed-turn' - async function pane(name: string, launchAgent: TuiAgent, size?: { cols: number; rows: number }) { - const created = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: launchAgent, - launchAgent, - data: readRuntimeFixture(name), - size + }, + 60_000 + ) }) - await created.runtime.readTerminal(created.handle, { screen: true }) - return created + }) + + describe('agent gate', () => { + const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts'] + + it("never reads another agent's screen, even one showing Codex's placeholder", () => { + const readScreenLines = vi.fn(() => placeholderScreen) + for (const agent of ['claude', 'muse', 'gemini'] as const) { + expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false) + } + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) + + it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => { + const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ '] + const readScreenLines = vi.fn(() => catted) + expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) + }) + + describe('through the runtime', () => { + // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. + const CODEX_0158 = 'codex-0-158-0-timed-turn' + async function pane( + name: string, + launchAgent: TuiAgent, + size?: { cols: number; rows: number } + ) { + const created = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: launchAgent, + launchAgent, + data: readRuntimeFixture(name), + size + }) + await created.runtime.readTerminal(created.handle, { screen: true }) + return created + } + + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. + it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it('keeps a Claude pane showing the same screen pending', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'claude', { + cols: 120, + rows: 40 + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) + }) +}) + +describe('Codex startup dialogs', () => { + type StartupDialog = { + name: string + reason: RuntimeTerminalWaitBlockedReason + heading: string + keyRow: string } - // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. - it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { - const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) + // codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). + // Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. + const DIALOGS: StartupDialog[] = [ + { + // Recorded separately on 0.157.1 (STA-8834). + name: 'codex-0-157-1-update-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0158-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0158-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0157-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer available', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer offered', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-announcement-dialog', + reason: 'codex-model-migration-prompt', + heading: 'Try new model', + keyRow: 'enter/esc confirm · ctrl+c quit' + } + ] + const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) + const LIVE_CHAT_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override', + 'codex-0157-fresh-home-daemon-install', + 'codex-0158-fresh-home-greeting' + ] - it('keeps a Claude pane showing the same screen pending', async () => { - const { runtime, handle } = await pane(CODEX_0158, 'claude', { - cols: 120, - rows: 40 + function screenText(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n') + } + + async function lastFrame(data: string): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(data, 120, 40)) { + last = frame + } + return last + } + + // Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup + // dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. + function answered(name: string): string { + const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') + return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) + } + + describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { + it.each(DIALOGS)( + '$name: reports $reason from its key row on, and never reads ready while it is up', + async ({ name, reason, heading, keyRow }) => { + let headingFrames = 0 + let keyRowFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + const screen = screenText(frame) + if (screen.includes(heading)) { + headingFrames += 1 + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + expect( + isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true) + ).toBe(false) + } + if (keyRowFrames > 0 || screen.includes(keyRow)) { + keyRowFrames += 1 + expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) + } + } + // Presence precondition: the dialog and its key row were painted, not just parsed. + expect(headingFrames).toBeGreaterThan(0) + expect(keyRowFrames).toBeGreaterThan(0) + } + ) + + it.each(DIALOGS_0158)( + '$name: stops reporting once Codex repaints its chat after it', + async ({ name, heading }) => { + const last = await lastFrame(answered(name)) + // Presence precondition: the answered dialog is still in the text copy. + expect(last?.waitText).toContain(heading.replace(' ·', '')) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() + } + ) + + it.each(DIALOGS)( + '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', + async ({ name, reason }) => { + // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. + const dialog = readRuntimeFixture(name) + const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) + expect( + isQuietReadyScreenBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) + ).toBe(false) + } + ) + + it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { + const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. + const chat = [ + '› is there an update available, and do my hooks need review?', + '✨ Update available! 0.158.0 -> 0.159.0', + 'Run npm install -g @openai/codex to update.', + ' gpt-6-astra default · ~/repo', + ' ← for agents · ? for shortcuts' + ].join('\n') + expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() }) - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) - }, 15_000) + + it('does not name a mid-session Codex popup a hooks review', () => { + // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. + const popup = [ + ' Use this reset?', + ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', + '› 2. No, go back Choose a different reset', + ' enter confirm · esc back' + ].join('\n') + expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') + }) + + describe('through the runtime', () => { + it.each(DIALOGS)( + '$name: stops a tui-idle wait as $reason instead of typing into it', + async ({ name, reason }) => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) + }, + 15_000 + ) + }) + }) }) diff --git a/src/main/runtime/codex-startup-dialog-transcripts.test.ts b/src/main/runtime/codex-startup-dialog-transcripts.test.ts deleted file mode 100644 index ac636eb8a05..00000000000 --- a/src/main/runtime/codex-startup-dialog-transcripts.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' -import { - readRuntimeFixture, - replayTranscript, - type TranscriptReplayFrame -} from './agent-transcript-replay-test-harness' -import { - detectTerminalWaitBlockedReason, - isKnownReadyPromptBody, - isQuietReadyScreenBody -} from './terminal-wait-detection' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -type StartupDialog = { - name: string - reason: RuntimeTerminalWaitBlockedReason - heading: string - keyRow: string -} - -// codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). -// Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. -const DIALOGS: StartupDialog[] = [ - { - // Recorded separately on 0.157.1 (STA-8834). - name: 'codex-0-157-1-update-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0157-update-available-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0158-update-available-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0157-hooks-review-dialog', - reason: 'agent-hooks-review-prompt', - heading: 'Hooks need review', - keyRow: 'enter confirm · esc skip' - }, - { - name: 'codex-0158-hooks-review-dialog', - reason: 'agent-hooks-review-prompt', - heading: 'Hooks need review', - keyRow: 'enter confirm · esc skip' - }, - { - name: 'codex-0157-model-retired-dialog', - reason: 'codex-model-migration-prompt', - heading: 'is no longer available', - keyRow: 'enter/esc continue · ctrl+c quit' - }, - { - name: 'codex-0158-model-retired-dialog', - reason: 'codex-model-migration-prompt', - heading: 'is no longer offered', - keyRow: 'enter/esc continue · ctrl+c quit' - }, - { - name: 'codex-0158-model-announcement-dialog', - reason: 'codex-model-migration-prompt', - heading: 'Try new model', - keyRow: 'enter/esc confirm · ctrl+c quit' - } -] -const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) -const LIVE_CHAT_FIXTURES = [ - 'codex-0157-plain-ready', - 'codex-0157-effort-override-embedded-warning', - 'codex-0157-config-override-embedded-warning', - 'codex-0157-no-daemon-effort-override', - 'codex-0157-fresh-home-daemon-install', - 'codex-0158-fresh-home-greeting' -] - -function screenText(frame: TranscriptReplayFrame): string { - return frame.screenLines.join('\n') -} - -async function lastFrame(data: string): Promise { - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(data, 120, 40)) { - last = frame - } - return last -} - -// Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup -// dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. -function answered(name: string): string { - const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') - return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) -} - -describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { - it.each(DIALOGS)( - '$name: reports $reason from its key row on, and never reads ready while it is up', - async ({ name, reason, heading, keyRow }) => { - let headingFrames = 0 - let keyRowFrames = 0 - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - const screen = screenText(frame) - if (screen.includes(heading)) { - headingFrames += 1 - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - false - ) - expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).toBe( - false - ) - } - if (keyRowFrames > 0 || screen.includes(keyRow)) { - keyRowFrames += 1 - expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) - } - } - // Presence precondition: the dialog and its key row were painted, not just parsed. - expect(headingFrames).toBeGreaterThan(0) - expect(keyRowFrames).toBeGreaterThan(0) - } - ) - - it.each(DIALOGS_0158)( - '$name: stops reporting once Codex repaints its chat after it', - async ({ name, heading }) => { - const last = await lastFrame(answered(name)) - // Presence precondition: the answered dialog is still in the text copy. - expect(last?.waitText).toContain(heading.replace(' ·', '')) - expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() - } - ) - - it.each(DIALOGS)( - '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', - async ({ name, reason }) => { - // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. - const dialog = readRuntimeFixture(name) - const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) - expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) - expect( - isQuietReadyScreenBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) - ).toBe(false) - } - ) - - it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { - const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' - expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() - // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. - const chat = [ - '› is there an update available, and do my hooks need review?', - '✨ Update available! 0.158.0 -> 0.159.0', - 'Run npm install -g @openai/codex to update.', - ' gpt-6-astra default · ~/repo', - ' ← for agents · ? for shortcuts' - ].join('\n') - expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() - }) - - it('does not name a mid-session Codex popup a hooks review', () => { - // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. - const popup = [ - ' Use this reset?', - ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', - '› 2. No, go back Choose a different reset', - ' enter confirm · esc back' - ].join('\n') - expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') - }) - - describe('through the runtime', () => { - it.each(DIALOGS)( - '$name: stops a tui-idle wait as $reason instead of typing into it', - async ({ name, reason }) => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: readRuntimeFixture(name), - size: { cols: 120, rows: 40 } - }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) - ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) - }, - 15_000 - ) - }) -}) diff --git a/src/main/runtime/freebuff-status-transcript.test.ts b/src/main/runtime/freebuff-status-transcript.test.ts deleted file mode 100644 index 04aa1e0a2ab..00000000000 --- a/src/main/runtime/freebuff-status-transcript.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const transcript = readFileSync( - join(import.meta.dirname, '__fixtures__/freebuff-lifecycle.txt'), - 'utf8' -) - -describe('Freebuff execution-host status', () => { - it('publishes real running, question, and settled screens into the canonical store', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - const states: string[] = [] - const questions: string[] = [] - try { - // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. - for (const frame of transcript.split(/(?<=\x1b\[\?2026l)/)) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, frame, Date.now()) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - const row = wiring.statusStore.getStatusSnapshot()[0] - if (row && states.at(-1) !== row.state) { - states.push(row.state) - } - if (row?.interactivePrompt) { - questions.push(row.interactivePrompt) - } - } - expect(states).toContain('working') - expect(states).toContain('waiting') - expect(states.at(-1)).toBe('done') - expect(questions.join('\n')).toMatch(/blue|green/i) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - it.each([ - { name: 'login', cols: 100, rows: 32, input: 'Sign in to Freebuff' }, - { name: 'trust', cols: 120, rows: 40, input: 'Trust repository agent files? [y/N]' } - ])('publishes captured $name startup as blocked', async ({ name, cols, rows, input }) => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols, rows } - }, - wiring.deps - ) - try { - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), - Date.now() - ) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ - state: 'blocked', - toolInput: input, - agentType: 'freebuff' - }) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - - it('clears startup blocks and starts a fresh session after an earlier completed turn', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - try { - for (const name of ['trust', 'ready', 'lifecycle', 'ready']) { - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), - Date.now() - ) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()[0]?.state).toBe( - name === 'trust' ? 'blocked' : 'done' - ) - if (name === 'ready') { - expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ - sessionBoundary: true, - prompt: '' - }) - } - if (name === 'lifecycle') { - expect(wiring.statusStore.getStatusSnapshot()[0]?.sessionBoundary).not.toBe(true) - } - } - expect(wiring.statusStore.getStatusSnapshot()[0]?.toolInput).toBeUndefined() - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - - it('does not infer remote status from the client screen', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - connectionId: 'ssh-test', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - try { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, transcript, Date.now()) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()).toEqual([]) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) -}) diff --git a/src/main/runtime/hermes-readiness-transcript.test.ts b/src/main/runtime/hermes-readiness-transcript.test.ts deleted file mode 100644 index 5605f69f91e..00000000000 --- a/src/main/runtime/hermes-readiness-transcript.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' -import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8') - -/** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the - * same normalizer the hook relay uses — so a regression in the event mapping fails here too, - * not only in the provider's own unit test. */ -const status = (eventName: string, payload: Record = {}): string => { - const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload) - expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull() - return `\x1b]9999;${JSON.stringify(parsed)}\x07` -} - -const readyPane = async () => - createTranscriptPane({ - paneTitle: 'Hermes Agent', - foregroundProcess: 'hermes', - launchAgent: 'hermes', - size: { cols: 120, rows: 31 }, - data: captured - }) - -describe('Hermes TUI readiness from a captured PTY', () => { - it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => { - const { runtime, handle } = await readyPane() - // What Orca's own managed plugin sends for `on_session_start`. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - - const read = vi.spyOn(runtime, 'readTerminal') - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) - ).resolves.toMatchObject({ satisfied: true }) - // The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it. - expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([]) - }, 5_000) - - it('settles through a leaf handle whose retained tail never showed the ready screen', async () => { - const { runtime } = await readyPane() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression. - const internals = runtime as unknown as { - leaves: Map - issueHandle: (leaf: unknown) => string - } - const leaf = internals.leaves.values().next().value - expect(leaf).toBeDefined() - expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i) - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(internals.issueHandle(leaf), { - condition: 'tui-idle', - timeoutMs: 2_000 - }) - ).resolves.toMatchObject({ satisfied: true }) - }, 5_000) - - it('does not settle while a turn the user started is still running', async () => { - const { runtime, handle } = await readyPane() - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - // `pre_llm_call` — the first event that means a turn actually began. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('pre_llm_call', { user_message: 'hi' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) - - it('does not settle on a turn-end done row, only on a session boundary', async () => { - const { runtime, handle } = await readyPane() - // `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('post_llm_call', { session_id: 's1' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) - - it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => { - const { runtime, handle } = await readyPane() - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - // `pre_approval_request` supersedes the boundary row the session opened with. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('pre_approval_request', { command: 'rm -rf build' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) -}) diff --git a/src/main/runtime/launched-agent-composer-readiness-codex.test.ts b/src/main/runtime/launched-agent-composer-readiness-codex.test.ts deleted file mode 100644 index 1821d4c3a28..00000000000 --- a/src/main/runtime/launched-agent-composer-readiness-codex.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { readRuntimeFixture } from './agent-transcript-replay-test-harness' -import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// A source-control button's multi-line prompt reaches Codex only through this wait, so a Codex -// release that the launch wait cannot read ready reports "prompt wasn't sent". -async function launchedCodexPane(data: string) { - return createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data, - size: { cols: 120, rows: 40 } - }) -} - -async function waitForCodexComposer( - pane: Awaited>, - timeoutMs: number, - streamedData?: string -) { - // Pane creation uses real timers; only the readiness budget advances virtually. - vi.useFakeTimers({ - toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] - }) - try { - const ready = waitForLaunchedAgentComposer(pane.runtime, pane.handle, 'codex', timeoutMs) - void ready.catch(() => {}) - if (streamedData !== undefined) { - pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, streamedData, Date.now()) - } - await vi.advanceTimersByTimeAsync(timeoutMs + 1) - return await ready - } finally { - vi.useRealTimers() - } -} - -// Why cut: on an Astra model 0.158 sparkles braille stars in the empty composer, redrawing every -// 150 ms, then repaints it clean after 15 s. The capture ends on the first star frame, so replayed -// whole it holds a starred composer still, which the live stream never does. -function greetingBeforeStarfield(): string { - const data = readRuntimeFixture('codex-0158-fresh-home-greeting') - const firstStar = data.search(/38;2;\d+;\d+;\d+;48;2;30;30;30m[⠀-⣿]/) - return data.slice(0, data.lastIndexOf('\x1b[?2026h', firstStar)) -} - -describe('launch readiness for a freshly launched Codex', () => { - it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => { - const data = greetingBeforeStarfield() - // Presence precondition: the cut keeps the live status row and drops every star. - expect(data).toContain('GPT-6-Astra default') - expect(data).not.toMatch(/48;2;30;30;30m[⠀-⣿]/) - const pane = await launchedCodexPane(data) - await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) - - it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => { - const pane = await launchedCodexPane(readRuntimeFixture('codex-0157-plain-ready')) - await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) - - it.each([ - ['codex-0158-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], - ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], - ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] - ])( - '%s: stops at the dialog as %s instead of pasting into it', - async (fixture, reason) => { - const pane = await launchedCodexPane(readRuntimeFixture(fixture)) - await expect(waitForCodexComposer(pane, 2_500)).resolves.toMatchObject({ - satisfied: false, - blockedReason: reason - }) - }, - 15_000 - ) - - // Why streamed: live, the dialog's `›` selector arrives after bracketed paste and fires the - // composer signal, so only the screen check keeps it from pasting; it hands the dialog to - // `tui-idle` to report, well inside the desktop paste's budget. - it.each([ - ['codex-0157-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], - ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], - ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] - ])( - '%s streamed in after the launch: reports the dialog as %s, never as the composer', - async (fixture, reason) => { - const data = readRuntimeFixture(fixture) - // Presence precondition: the dialog draws Codex's composer glyph after bracketed paste. - expect(data.slice(data.indexOf('\x1b[?2004h'))).toContain('›') - const pane = await launchedCodexPane('') - await expect(waitForCodexComposer(pane, 2_500, data)).resolves.toMatchObject({ - satisfied: false, - blockedReason: reason - }) - }, - 15_000 - ) - - // Why: 0.157 discards input typed behind its provisional `model: loading` screen. - it('codex-0157-fresh-home-daemon-install: does not read the provisional screen as ready', async () => { - const full = readRuntimeFixture('codex-0157-fresh-home-daemon-install') - const install = full.indexOf('Installing daemon') - // Presence precondition: the cut keeps the provisional header and stops before the live chat. - expect(install).toBeGreaterThan(0) - const provisional = full.slice(0, full.indexOf('\n', install) + 1) - expect(provisional).toMatch(/model:.*loading/) - const pane = await launchedCodexPane(provisional) - await expect(waitForCodexComposer(pane, 5_000)).rejects.toThrow(/timeout/) - }, 15_000) -}) diff --git a/src/main/runtime/launched-agent-worker-start-grok.test.ts b/src/main/runtime/launched-agent-worker-start-grok.test.ts deleted file mode 100644 index cde8cb8d2cd..00000000000 --- a/src/main/runtime/launched-agent-worker-start-grok.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * A Grok worker start replayed through the runtime at its recorded read times. Grok draws its - * composer glyph at 0.6 s and then shimmers its logo until 9.9 s, and its only title is its bare - * name, so a wait that holds that title to quiet output answers ten seconds after the composer. - */ - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { waitForWorkerStartComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const COMPOSER_FRAME_MS = GROK_STARTUP_PTY_TRACE.find((chunk) => chunk.data?.includes('❯'))?.t -const LAST_FRAME_MS = GROK_STARTUP_PTY_TRACE.at(-1)?.t ?? 0 - -async function replayGrokWorkerStart(): Promise { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'grok', - launchAgent: 'grok', - size: { cols: 120, rows: 30 }, - data: '' - }) - vi.useFakeTimers() - const startedAt = Date.now() - let settledAt: number | null = null - void waitForWorkerStartComposer(runtime, handle, 'grok', 60_000).then( - (wait) => { - settledAt = wait.satisfied ? Date.now() - startedAt : null - }, - () => {} - ) - for (const chunk of GROK_STARTUP_PTY_TRACE) { - await vi.advanceTimersByTimeAsync(Math.max(0, startedAt + chunk.t - Date.now())) - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - chunk.data ?? 'x'.repeat(chunk.bytes ?? 0), - Date.now() - ) - } - await vi.advanceTimersByTimeAsync(5_000) - return settledAt -} - -describe('a Grok worker start', () => { - afterEach(() => vi.useRealTimers()) - - it('is ready on its composer glyph, not once its logo stops animating', async () => { - expect(COMPOSER_FRAME_MS).toBeLessThan(1_000) - const settledAt = await replayGrokWorkerStart() - expect(settledAt).not.toBeNull() - expect(settledAt).toBeGreaterThanOrEqual(COMPOSER_FRAME_MS ?? 0) - // Within a second of the glyph: main's own wait answered on the title at once (2.2-2.8 s live). - expect(settledAt).toBeLessThan((COMPOSER_FRAME_MS ?? 0) + 1_000) - expect(settledAt).toBeLessThan(LAST_FRAME_MS) - }) -}) diff --git a/src/main/runtime/muse-readiness-transcript.test.ts b/src/main/runtime/muse-readiness-transcript.test.ts deleted file mode 100644 index 705eda71df5..00000000000 --- a/src/main/runtime/muse-readiness-transcript.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { RuntimeMachineName } from './runtime-machine-name' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -let machineNameStartSpy: { mockRestore(): void } | undefined - -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) -}) - -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined -}) - -describe('Muse readiness from captured terminal bytes', () => { - it('recognizes a ready folder workspace without a skills summary', async () => { - const data = readFileSync( - join(__dirname, '__fixtures__', 'muse-empty-folder-ready.txt'), - 'utf8' - ) - expect(data).toContain(String.fromCharCode(27)) - expect(data).not.toContain('Skills:') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'muse-first-class-workspace', - foregroundProcess: 'muse-bin-1.3.0-R3401.1', - launchAgent: 'muse', - data - }) - await runtime.readTerminal(handle, { screen: true }) - await expect(waitForTranscriptIdle({ runtime, handle }, 10_000)).resolves.toMatchObject({ - satisfied: true - }) - }, 15_000) -}) diff --git a/src/main/runtime/omp-readiness-transcripts.test.ts b/src/main/runtime/omp-readiness-transcripts.test.ts index ecfd65e7cc3..6cab4aa1fde 100644 --- a/src/main/runtime/omp-readiness-transcripts.test.ts +++ b/src/main/runtime/omp-readiness-transcripts.test.ts @@ -4,6 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' import { extractLastOscTitle } from '../../shared/osc-title-extraction' import type { TuiAgent } from '../../shared/tui-agent' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' +import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -12,220 +17,519 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -function transcript(name: string): string { - return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') -} - -// Synthetic repaint of the composer's bottom row (row 21 of the 120x40 capture): no capture -// exists of a post-turn, resumed or non-default-glyph composer, whose effort hint is gone or -// differs. -const HINTLESS_COMPOSER_ROW = '\x1b[21;1H\x1b[2K╰─' -const ASCII_HINT_COMPOSER_ROW = `\x1b[21;1H\x1b[2K╰─${' '.repeat(82)}Shift+Tab to change thinking effort` - -async function waitsReady( - options: { - name: string - title: string - launchAgent?: TuiAgent - repaint?: string - size?: { cols: number; rows: number } | null - busyFirst?: boolean - keepalive?: boolean - }, - timeoutMs = 5_000 -): Promise { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: options.launchAgent ? 'omp' : 'bun', - data: transcript(options.name), - ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), - ...(options.size === null ? {} : { size: options.size ?? { cols: 120, rows: 40 } }) - }) - if (options.busyFirst) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) +describe('OMP captured composer and title readiness', () => { + function transcript(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } - if (options.repaint) { - // Why the wait: the grid ingests writes asynchronously, and the repaint precedes the title. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.repaint, Date.now()) - await vi.advanceTimersByTimeAsync(50) - } - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${options.title}\x07`, Date.now()) - // OMP 18.4.5 re-asserts bracketed paste every second once a terminal answers its DECRQM probe, - // as xterm and main's query authority do; the capture tool answered nothing. - const keepalive = options.keepalive - ? setInterval(() => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[?2004h', Date.now()), 1_000) - : null - const settled = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }).then( - (result) => result.satisfied === true, - () => false - ) - await vi.advanceTimersByTimeAsync(timeoutMs) - if (keepalive) { - clearInterval(keepalive) - } - return settled -} -describe('OMP 18.4.5 captured readiness', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) + // Synthetic repaint of the composer's bottom row (row 21 of the 120x40 capture): no capture + // exists of a post-turn, resumed or non-default-glyph composer, whose effort hint is gone or + // differs. + const HINTLESS_COMPOSER_ROW = '\x1b[21;1H\x1b[2K╰─' + const ASCII_HINT_COMPOSER_ROW = `\x1b[21;1H\x1b[2K╰─${' '.repeat(82)}Shift+Tab to change thinking effort` - it.each([ - ['omp-18-setup', 120, 40, false], - ['omp-18-composer', 120, 40, true], - ['omp-18-composer-narrow', 60, 24, true] - ] as const)('%s at %sx%s has readiness %s', async (name, cols, rows, ready) => { - const data = transcript(name) - expect(data).toContain('\x1b[') - expect(data).toContain('\r') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: extractLastOscTitle(data) ?? 'OMP', - foregroundProcess: 'omp', - data, - launchAgent: 'omp', - size: { cols, rows } - }) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = ready - ? expect(result).resolves.toMatchObject({ satisfied: true }) - : expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - }) - - it('does not accept the composer while its native title still says working', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'OMP', - foregroundProcess: 'omp', - data: transcript('omp-18-composer'), - launchAgent: 'omp', - size: { cols: 120, rows: 40 } - }) - // Synthetic busy transition isolates the timer while preserving the captured composer grid. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', Date.now()) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) - const idle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const idleAssertion = expect(idle).resolves.toMatchObject({ satisfied: true }) - await Promise.all([idleAssertion, vi.advanceTimersByTimeAsync(5_000)]) - }) - - it.each([ - ['hintless', HINTLESS_COMPOSER_ROW], - ['ascii-glyph', ASCII_HINT_COMPOSER_ROW] - ])('accepts a %s composer: the effort hint retires and its glyphs vary', async (_, repaint) => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π > capture-cwd', - launchAgent: 'omp', - repaint - }) - ).toBe(true) - }) - - it("accepts the titlebar extension's post-turn title on a hintless composer", async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', - launchAgent: 'omp', - repaint: HINTLESS_COMPOSER_ROW, - busyFirst: true - }) - ).toBe(true) - }) - - it('accepts an OMP 17-style `π:` title through the quiet name-only lane', async () => { - expect( - await waitsReady({ name: 'omp-18-composer', title: 'π: capture-cwd', launchAgent: 'omp' }) - ).toBe(true) - }) - - it("refuses the wizard's splash, which has no step heading yet", async () => { - const setup = transcript('omp-18-setup') - const splash = setup.slice(0, setup.indexOf('Setup step')) - expect(splash).toContain('\x1b[?1049h') + async function waitsReady( + options: { + name: string + title: string + launchAgent?: TuiAgent + repaint?: string + size?: { cols: number; rows: number } | null + busyFirst?: boolean + keepalive?: boolean + }, + timeoutMs = 5_000 + ): Promise { const { runtime, handle } = await createTranscriptPane({ paneTitle: 'Terminal', - foregroundProcess: 'omp', - data: splash, - launchAgent: 'omp', - size: { cols: 120, rows: 40 } + foregroundProcess: options.launchAgent ? 'omp' : 'bun', + data: transcript(options.name), + ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), + ...(options.size === null ? {} : { size: options.size ?? { cols: 120, rows: 40 } }) }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - }) - - it.each([ - ['omp-18-composer', true], - ['omp-18-setup', false] - ] as const)('%s under the bracketed-paste keepalive has readiness %s', async (name, ready) => { - expect( - await waitsReady( - { name, title: 'π > capture-cwd', launchAgent: 'omp', keepalive: true }, - 10_000 - ) - ).toBe(ready) - }) - - it('accepts a post-turn answer that mentions a setup step on the normal screen', async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', - launchAgent: 'omp', - repaint: '\x1b[12;1H\x1b[2K Setup step 2 of 4: install the dependencies', - busyFirst: true - }) - ).toBe(true) - }) - - it.each(['π - capture-cwd', 'π: capture-cwd'])( - 'refuses %s while the setup wizard is on screen', - async (title) => { - expect(await waitsReady({ name: 'omp-18-setup', title, launchAgent: 'omp' })).toBe(false) + if (options.busyFirst) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) } - ) - - it('refuses `π >` with no trustworthy screen to rule the setup wizard out', async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π > capture-cwd', - launchAgent: 'omp', - size: null - }) - ).toBe(false) - // A post-turn title cannot come from setup, so it needs no screen. - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', - launchAgent: 'omp', - size: null - }) - ).toBe(true) - }) - - it.each([['pi' as const], [undefined]])( - 'does not make the setup wizard ready at once for agent identity %s', - async (launchAgent) => { - // Identities other than omp keep main's quiet name-only lane, which cannot settle this soon. - expect( - await waitsReady( - { - name: 'omp-18-setup', - title: 'π > capture-cwd', - ...(launchAgent ? { launchAgent } : {}) - }, + if (options.repaint) { + // Why the wait: the grid ingests writes asynchronously, and the repaint precedes the title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.repaint, Date.now()) + await vi.advanceTimersByTimeAsync(50) + } + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${options.title}\x07`, Date.now()) + // OMP 18.4.5 re-asserts bracketed paste every second once a terminal answers its DECRQM probe, + // as xterm and main's query authority do; the capture tool answered nothing. + const keepalive = options.keepalive + ? setInterval( + () => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[?2004h', Date.now()), 1_000 ) - ).toBe(false) + : null + const settled = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }).then( + (result) => result.satisfied === true, + () => false + ) + await vi.advanceTimersByTimeAsync(timeoutMs) + if (keepalive) { + clearInterval(keepalive) } + return settled + } + + describe('OMP 18.4.5 captured readiness', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it.each([ + ['omp-18-setup', 120, 40, false], + ['omp-18-composer', 120, 40, true], + ['omp-18-composer-narrow', 60, 24, true] + ] as const)('%s at %sx%s has readiness %s', async (name, cols, rows, ready) => { + const data = transcript(name) + expect(data).toContain('\x1b[') + expect(data).toContain('\r') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: extractLastOscTitle(data) ?? 'OMP', + foregroundProcess: 'omp', + data, + launchAgent: 'omp', + size: { cols, rows } + }) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = ready + ? expect(result).resolves.toMatchObject({ satisfied: true }) + : expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it('does not accept the composer while its native title still says working', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'OMP', + foregroundProcess: 'omp', + data: transcript('omp-18-composer'), + launchAgent: 'omp', + size: { cols: 120, rows: 40 } + }) + // Synthetic busy transition isolates the timer while preserving the captured composer grid. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const idle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const idleAssertion = expect(idle).resolves.toMatchObject({ satisfied: true }) + await Promise.all([idleAssertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it.each([ + ['hintless', HINTLESS_COMPOSER_ROW], + ['ascii-glyph', ASCII_HINT_COMPOSER_ROW] + ])('accepts a %s composer: the effort hint retires and its glyphs vary', async (_, repaint) => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + repaint + }) + ).toBe(true) + }) + + it("accepts the titlebar extension's post-turn title on a hintless composer", async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: HINTLESS_COMPOSER_ROW, + busyFirst: true + }) + ).toBe(true) + }) + + it('accepts an OMP 17-style `π:` title through the quiet name-only lane', async () => { + expect( + await waitsReady({ name: 'omp-18-composer', title: 'π: capture-cwd', launchAgent: 'omp' }) + ).toBe(true) + }) + + it("refuses the wizard's splash, which has no step heading yet", async () => { + const setup = transcript('omp-18-setup') + const splash = setup.slice(0, setup.indexOf('Setup step')) + expect(splash).toContain('\x1b[?1049h') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'omp', + data: splash, + launchAgent: 'omp', + size: { cols: 120, rows: 40 } + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it.each([ + ['omp-18-composer', true], + ['omp-18-setup', false] + ] as const)('%s under the bracketed-paste keepalive has readiness %s', async (name, ready) => { + expect( + await waitsReady( + { name, title: 'π > capture-cwd', launchAgent: 'omp', keepalive: true }, + 10_000 + ) + ).toBe(ready) + }) + + it('accepts a post-turn answer that mentions a setup step on the normal screen', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: '\x1b[12;1H\x1b[2K Setup step 2 of 4: install the dependencies', + busyFirst: true + }) + ).toBe(true) + }) + + it.each(['π - capture-cwd', 'π: capture-cwd'])( + 'refuses %s while the setup wizard is on screen', + async (title) => { + expect(await waitsReady({ name: 'omp-18-setup', title, launchAgent: 'omp' })).toBe(false) + } + ) + + it('refuses `π >` with no trustworthy screen to rule the setup wizard out', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + size: null + }) + ).toBe(false) + // A post-turn title cannot come from setup, so it needs no screen. + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + size: null + }) + ).toBe(true) + }) + + it.each([['pi' as const], [undefined]])( + 'does not make the setup wizard ready at once for agent identity %s', + async (launchAgent) => { + // Identities other than omp keep main's quiet name-only lane, which cannot settle this soon. + expect( + await waitsReady( + { + name: 'omp-18-setup', + title: 'π > capture-cwd', + ...(launchAgent ? { launchAgent } : {}) + }, + 1_000 + ) + ).toBe(false) + } + ) + }) +}) + +describe('OpenCode captured worker composer', () => { + /** + * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first + * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), + * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. + */ + + // Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title + // and the launcher's cursor toggle are synthetic. + const ZSH_LAUNCH = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + const OPENCODE_PLACEHOLDER = 'Ask anything' + const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + + const RUNS: [string, TuiAgent][] = [ + ['opencode-1-18-32-timed-boot-slow', 'opencode'], + ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], + ['opencode-1-18-32-timed-first-launch', 'opencode'], + ['opencode-cmd-2-0-21-timed-warm-server', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'], + ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] + ] + const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) + const AGENT_ROW = /\u00b7 \S/ + const BOX_BOTTOM_LEFT = '\u2579' + + /** The read that ends the synchronized update drawing OpenCode's input box. */ + function boxRead(chunks: string[]): number { + const data = chunks.join('') + const boxEnd = + data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + + SYNCHRONIZED_UPDATE_END.length + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) + } + + /** The first read whose screen shows `· ` on the box's last line, above its corner. */ + async function agentRowRead(name: string, chunks: string[]): Promise { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') + ) + let read = 0 + for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) { + const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) + if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) { + return read + } + read += 1 + } + return -1 + } + + async function replay(name: string, agent: TuiAgent) { + const { chunks, times } = readTimedRuntimeFixture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'opencode', + launchAgent: agent, + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.useFakeTimers() + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) + const settledAt: { + composer: number | null + composerMs: number | null + tuiIdle: number | null + } = { composer: null, composerMs: null, tuiIdle: null } + let read = -1 + const startedAt = Date.now() + const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) + void composer.then(() => { + settledAt.composer ??= read + settledAt.composerMs ??= Date.now() - startedAt + }) + const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + void tuiIdle.then( + () => (settledAt.tuiIdle ??= read), + () => {} + ) + let now = 0 + await vi.advanceTimersByTimeAsync(0) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) + now = times[index] + read = index + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + await vi.advanceTimersByTimeAsync(0) + } + return { + settledAt, + composer, + boxRead: boxRead(chunks), + agentRowRead: await agentRowRead(name, chunks) + } + } + + describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(RUNS)( + '%s: the worker-start lane settles once the box and the agent row are both painted', + async (name, agent) => { + const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + // OpenCode 1 paints the row inside the box's frame, so the box read is the later one there. + expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead)) + } + ) + + it.each(OPENCODE_1_RUNS)( + '%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.composer).toBe(boxRead) + } + ) + + it('never settles on the box while a slow agent list leaves the row unpainted', async () => { + const { settledAt, boxRead } = await replay( + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode2' + ) + expect(settledAt.composer).toBeGreaterThan(boxRead) + }) + + it('takes the box after the grace in a pane too narrow for the agent row', async () => { + const name = 'opencode-2-0-21-timed-narrow-pane' + const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) + const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)] + const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2') + expect(agentRowRead).toBe(-1) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + // Fake timers drop the recorded times' fraction of a millisecond. + expect( + Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS)) + ).toBeLessThan(1) + // The recorder pasted at the grace and pressed Enter after it, and OpenCode took it. + expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!) + }) + + it.each(RUNS)( + '%s: the bare-name tui-idle wait main used would have settled before the box', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.tuiIdle).not.toBeNull() + expect(settledAt.tuiIdle!).toBeLessThan(boxRead) + } + ) + }) +}) + +describe('Freebuff captured canonical status delivery', () => { + const transcript = readFileSync( + join(import.meta.dirname, '__fixtures__/freebuff-lifecycle.txt'), + 'utf8' ) + + describe('Freebuff execution-host status', () => { + it('publishes real running, question, and settled screens into the canonical store', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + const states: string[] = [] + const questions: string[] = [] + try { + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + for (const frame of transcript.split(/(?<=\x1b\[\?2026l)/)) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, frame, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + const row = wiring.statusStore.getStatusSnapshot()[0] + if (row && states.at(-1) !== row.state) { + states.push(row.state) + } + if (row?.interactivePrompt) { + questions.push(row.interactivePrompt) + } + } + expect(states).toContain('working') + expect(states).toContain('waiting') + expect(states.at(-1)).toBe('done') + expect(questions.join('\n')).toMatch(/blue|green/i) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + it.each([ + { name: 'login', cols: 100, rows: 32, input: 'Sign in to Freebuff' }, + { name: 'trust', cols: 120, rows: 40, input: 'Trust repository agent files? [y/N]' } + ])('publishes captured $name startup as blocked', async ({ name, cols, rows, input }) => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols, rows } + }, + wiring.deps + ) + try { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + state: 'blocked', + toolInput: input, + agentType: 'freebuff' + }) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('clears startup blocks and starts a fresh session after an earlier completed turn', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + for (const name of ['trust', 'ready', 'lifecycle', 'ready']) { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]?.state).toBe( + name === 'trust' ? 'blocked' : 'done' + ) + if (name === 'ready') { + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + sessionBoundary: true, + prompt: '' + }) + } + if (name === 'lifecycle') { + expect(wiring.statusStore.getStatusSnapshot()[0]?.sessionBoundary).not.toBe(true) + } + } + expect(wiring.statusStore.getStatusSnapshot()[0]?.toolInput).toBeUndefined() + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('does not infer remote status from the client screen', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + connectionId: 'ssh-test', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, transcript, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()).toEqual([]) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + }) }) diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts deleted file mode 100644 index e49254defdd..00000000000 --- a/src/main/runtime/opencode-worker-composer-transcript.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first - * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), - * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. - */ - -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' -import type { TuiAgent } from '../../shared/tui-agent' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' -import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title -// and the launcher's cursor toggle are synthetic. -const ZSH_LAUNCH = - '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' -const OPENCODE_PLACEHOLDER = 'Ask anything' -const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' - -const RUNS: [string, TuiAgent][] = [ - ['opencode-1-18-32-timed-boot-slow', 'opencode'], - ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], - ['opencode-1-18-32-timed-first-launch', 'opencode'], - ['opencode-cmd-2-0-21-timed-warm-server', 'opencode'], - ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], - ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], - ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], - ['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'], - ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] -] -const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) -const AGENT_ROW = /\u00b7 \S/ -const BOX_BOTTOM_LEFT = '\u2579' - -/** The read that ends the synchronized update drawing OpenCode's input box. */ -function boxRead(chunks: string[]): number { - const data = chunks.join('') - const boxEnd = - data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + - SYNCHRONIZED_UPDATE_END.length - let end = 0 - return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) -} - -/** The first read whose screen shows `· ` on the box's last line, above its corner. */ -async function agentRowRead(name: string, chunks: string[]): Promise { - const meta: { cols: number; rows: number } = JSON.parse( - readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') - ) - let read = 0 - for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) { - const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) - if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) { - return read - } - read += 1 - } - return -1 -} - -async function replay(name: string, agent: TuiAgent) { - const { chunks, times } = readTimedRuntimeFixture(name) - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'opencode', - launchAgent: agent, - size: { cols: 120, rows: 40 }, - data: '' - }) - vi.useFakeTimers() - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) - const settledAt: { composer: number | null; composerMs: number | null; tuiIdle: number | null } = - { composer: null, composerMs: null, tuiIdle: null } - let read = -1 - const startedAt = Date.now() - const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) - void composer.then(() => { - settledAt.composer ??= read - settledAt.composerMs ??= Date.now() - startedAt - }) - const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) - void tuiIdle.then( - () => (settledAt.tuiIdle ??= read), - () => {} - ) - let now = 0 - await vi.advanceTimersByTimeAsync(0) - for (const [index, chunk] of chunks.entries()) { - await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) - now = times[index] - read = index - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) - await vi.advanceTimersByTimeAsync(0) - } - return { - settledAt, - composer, - boxRead: boxRead(chunks), - agentRowRead: await agentRowRead(name, chunks) - } -} - -describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it.each(RUNS)( - '%s: the worker-start lane settles once the box and the agent row are both painted', - async (name, agent) => { - const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent) - await expect(composer).resolves.toMatchObject({ satisfied: true }) - // OpenCode 1 paints the row inside the box's frame, so the box read is the later one there. - expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead)) - } - ) - - it.each(OPENCODE_1_RUNS)( - '%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before', - async (name, agent) => { - const { settledAt, boxRead } = await replay(name, agent) - expect(settledAt.composer).toBe(boxRead) - } - ) - - it('never settles on the box while a slow agent list leaves the row unpainted', async () => { - const { settledAt, boxRead } = await replay( - 'opencode-2-0-21-timed-natural-load-enter-dropped', - 'opencode2' - ) - expect(settledAt.composer).toBeGreaterThan(boxRead) - }) - - it('takes the box after the grace in a pane too narrow for the agent row', async () => { - const name = 'opencode-2-0-21-timed-narrow-pane' - const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) - const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') - const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)] - const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2') - expect(agentRowRead).toBe(-1) - await expect(composer).resolves.toMatchObject({ satisfied: true }) - // Fake timers drop the recorded times' fraction of a millisecond. - expect( - Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS)) - ).toBeLessThan(1) - // The recorder pasted at the grace and pressed Enter after it, and OpenCode took it. - expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!) - }) - - it.each(RUNS)( - '%s: the bare-name tui-idle wait main used would have settled before the box', - async (name, agent) => { - const { settledAt, boxRead } = await replay(name, agent) - expect(settledAt.tuiIdle).not.toBeNull() - expect(settledAt.tuiIdle!).toBeLessThan(boxRead) - } - ) -}) diff --git a/src/main/runtime/qoder-transcripts.test.ts b/src/main/runtime/qoder-transcripts.test.ts deleted file mode 100644 index 9b3452bd823..00000000000 --- a/src/main/runtime/qoder-transcripts.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { extractLastOscTitle } from '../../shared/osc-title-extraction' -import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection' -import { RuntimeMachineName } from './runtime-machine-name' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -let machineNameStartSpy: { mockRestore(): void } | undefined - -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) -}) - -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined -}) - -describe('captured Qoder 1.1.64 startup', () => { - it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( - 'preserves Qoder identity in %s', - async (fixture) => { - const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') - // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. - const title = extractLastOscTitle( - data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') - ) - expect(title).toContain(' | Ready') - expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: title ?? '', - foregroundProcess: 'qodercli-1.1.64', - launchAgent: 'qoder', - data, - size: { cols: 100, rows: 32 } - }) - const shown = await runtime.showTerminal(handle) - expect(shown.agentIdentity).toBe('qoder') - if (fixture === 'qoder-trust-dialog') { - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 600).catch(() => null) - expect(readiness?.satisfied ?? false).toBe(false) - } - if (fixture === 'qoder-ready') { - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 1500) - expect(readiness.satisfied).toBe(true) - } - } - ) -}) - -describe('captured Qoder China 1.1.65 startup', () => { - it.each(['qoder-cn-startup', 'qoder-cn-signin'])('checks the composer in %s', async (fixture) => { - const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') - const title = extractLastOscTitle( - data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') - ) - expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI CN') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: title ?? '', - foregroundProcess: 'qoderclicn', - launchAgent: 'qoder-cn', - data, - size: { cols: 120, rows: 40 } - }) - expect((await runtime.showTerminal(handle)).agentIdentity).toBe('qoder-cn') - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 800).catch(() => null) - expect(readiness?.satisfied ?? false).toBe(false) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts deleted file mode 100644 index 75f69c06ee5..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('Run delivery history', () => { - const h = createOrchestrationRpcHarness() - afterEach(() => h.cleanup()) - - it('does not label filtered history as an acknowledgeable delivery', async () => { - const { db, ctx, activeRunId } = h.setup() - const params = { terminal: 'term_coord', run: activeRunId, all: true } - db.insertMessage({ - from: 'worker', - to: `run:${activeRunId}`, - runId: activeRunId, - subject: 'waiting' - }) - expect(await h.call('orchestration.check', params, ctx)).toMatchObject({ - count: 1 - }) - expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(false) - const delivery = db.getOrCreateRunDelivery({ - runId: activeRunId!, - consumerGeneration: db.getRun(activeRunId!)!.consumer_generation - })! - db.insertMessage({ - from: 'worker', - to: `run:${activeRunId}`, - runId: activeRunId, - subject: 'later completion', - type: 'worker_done' - }) - const history = await h.call( - 'orchestration.check', - { - ...params, - format: true, - types: 'worker_done' - }, - ctx - ) - expect(history).toMatchObject({ count: 1, messages: [{ subject: 'later completion' }] }) - expect(history).not.toHaveProperty('deliveryId') - expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(true) - expect(db.getMessageById(delivery.messages[0].id)?.read).toBe(0) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts deleted file mode 100644 index 9287cc0c1dc..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const PANE_OLD = 'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc' -const PANE_NEW = 'tab_new:dddddddd-dddd-4ddd-8ddd-dddddddddddd' - -type CheckResult = { messages: { subject: string }[]; count: number } - -/** - * worker-abandon + worker-start --retry-of moves the Task to another terminal, but the old worker - * keeps polling. Its check used to fall through to the direct mailbox and answer `count: 0`, which - * the worker contract reads as "checkpoint, not a failure" — so it kept editing the new owner's files. - */ -describe('orchestration.check from a terminal whose Attempt was superseded', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let ctx: RpcContext - - afterEach(() => { - h.cleanup() - }) - - function check(handle: string, paneKey: string, params: Record = {}) { - return h.call( - 'orchestration.check', - { terminal: handle, terminalPaneKey: paneKey, ...params }, - ctx - ) as Promise - } - - function startWorker(taskId: string, handle: string, paneKey: string, retryOf?: string): string { - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId, - retryOf, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle, - paneKey, - processIncarnation: `runtime:${handle}:1`, - worktreeId: 'repo::local', - setupState: 'not_applicable', - effects: [] - }) - return started.dispatch.id - } - - function retriedOntoAnotherTerminal(): string { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that moves terminals' }) - const abandoned = startWorker(task.id, 'term_old', PANE_OLD) - db.abandonWorkerDispatch(abandoned, 'epoch_test') - startWorker(task.id, 'term_new', PANE_NEW, abandoned) - return abandoned - } - - it('tells the old worker it lost the Dispatch instead of answering "no mail"', async () => { - retriedOntoAnotherTerminal() - - await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ - code: 'consumer_fenced', - message: expect.stringContaining('no longer owns its Dispatch') - }) - }) - - // The direct mailbox is the old terminal's own, so inspection stays open; only the consuming - // read that a worker treats as a checkpoint is refused. - it('still lets the old worker inspect its direct mailbox with --peek and --all', async () => { - retriedOntoAnotherTerminal() - db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'stand down' }) - - const peeked = await check('term_old', PANE_OLD, { peek: true }) - const history = await check('term_old', PANE_OLD, { all: true }) - - expect(peeked.count).toBe(1) - expect(history.count).toBe(1) - expect(db.getUnreadMessages('term_old')).toHaveLength(1) - }) - - it('fences a terminal whose Attempt failed with no successor', async () => { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that failed outright' }) - const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) - db.failDispatch(dispatch.id, 'worker terminal closed') - - await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ code: 'consumer_fenced' }) - }) - - // A superseded worker whose pane is gone cannot run-use either; the stop signal outranks the - // rebind advice, and a caller with no settled Attempt still gets the rebind advice. - it('fences a paneless caller whose Attempt was superseded, and only that caller', async () => { - retriedOntoAnotherTerminal() - - await expect( - h.call('orchestration.check', { terminal: 'term_old' }, ctx) - ).rejects.toMatchObject({ code: 'consumer_fenced' }) - await expect( - h.call('orchestration.check', { terminal: 'term_never_dispatched' }, ctx) - ).rejects.toMatchObject({ code: 'stable_pane_required' }) - }) - - it('keeps serving direct mail to a terminal whose Attempt completed normally', async () => { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that finished' }) - const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) - db.completeDispatch(dispatch.id) - db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'one more thing' }) - - const result = await check('term_old', PANE_OLD) - - expect(result.messages.map((message) => message.subject)).toEqual(['one more thing']) - expect(db.getUnreadMessages('term_old')).toEqual([]) - }) - - it('serves the new owner its Dispatch mailbox as usual', async () => { - const abandoned = retriedOntoAnotherTerminal() - const current = db.getDispatchContext(db.getDispatchContextById(abandoned)!.task_id)! - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${current.id}`, - subject: 'carry on', - runId: current.run_id - }) - - const result = await check('term_new', PANE_NEW) - - expect(result.messages.map((message) => message.subject)).toEqual(['carry on']) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts index 54c64feed62..d161cd9172e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts @@ -1,7 +1,9 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../../../core' import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration-compatibility-evidence' +import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' import { createRootDispatch, reattachDispatchConsumer @@ -231,3 +233,383 @@ describe('orchestration.check on a re-attached Dispatch', () => { expect(result.messages.map((message) => message.subject)).toEqual(['do the work']) }) }) + +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const OTHER_WORKER_PANE = 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const NON_PARTY_PANE = 'tab_teammate:dddddddd-dddd-4ddd-8ddd-dddddddddddd' +const WORKER_PROCESS = 'runtime_test:term_worker:1' + +describe('worker report authority without a Dispatch capability', () => { + const harness = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let panes: Record + + afterEach(() => harness.cleanup()) + + function setup(): void { + ;({ db, runtime } = harness.setup()) + ctx = { runtime } + panes = { + term_coord: harness.coordinatorPaneKey, + term_worker: WORKER_PANE, + term_other: OTHER_WORKER_PANE, + term_teammate: NON_PARTY_PANE + } + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) + vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( + (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null + ) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') + } + + function startWorker(name: string, paneKey: string): { taskId: string; dispatchId: string } { + const task = db.createTask({ spec: `${name} work` }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: `term_${name}`, + paneKey, + processIncarnation: `runtime_test:term_${name}:1`, + worktreeId: `repo::${name}`, + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.dispatch.id) + return { taskId: task.id, dispatchId: started.dispatch.id } + } + + function workerDone( + worker: { taskId: string; dispatchId: string }, + evidence?: OrchestrationCompatibilityEvidence, + outcome = 'succeeded' + ) { + return harness.call( + 'orchestration.send', + { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ ...worker, outcome }) + }, + { ...ctx, orchestrationCompatibilityEvidence: evidence } + ) + } + + function ask(evidence?: OrchestrationCompatibilityEvidence) { + return harness.call( + 'orchestration.ask', + { from: 'term_worker', question: 'Proceed?', timeoutMs: 1 }, + { ...ctx, orchestrationCompatibilityEvidence: evidence } + ) + } + + describe('caller fence', () => { + it.each([ + ['the Run coordinator', harness.coordinatorPaneKey], + ['another Dispatch worker', OTHER_WORKER_PANE] + ])('refuses a report sent from %s terminal and records nothing', async (_party, paneKey) => { + setup() + startWorker('other', OTHER_WORKER_PANE) + const worker = startWorker('worker', WORKER_PANE) + + await expect(workerDone(worker, { paneKey })).rejects.toMatchObject({ + code: 'consumer_fenced', + data: { effectsApplied: false } + }) + await expect(ask({ paneKey })).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect( + db.db + .prepare("SELECT COUNT(*) AS count FROM messages WHERE from_handle = 'term_worker'") + .get() + ).toEqual({ count: 0 }) + expect(db.getTask(worker.taskId)?.status).toBe('dispatched') + }) + + it.each<[string, OrchestrationCompatibilityEvidence | undefined]>([ + ['no identity env (old CLI, scrubbed env)', undefined], + ['its own pane', { paneKey: WORKER_PANE, terminalHandle: 'term_stale_after_remint' }], + ["another Orca's pane", { paneKey: 'tab_elsewhere:eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }], + ['a stale handle', { terminalHandle: 'term_gone' }], + ['a live pane that is no orchestration party', { paneKey: NON_PARTY_PANE }] + ])('accepts a report whose env names %s', async (_case, evidence) => { + setup() + const worker = startWorker('worker', WORKER_PANE) + + expect(await workerDone(worker, evidence)).toMatchObject({ + lifecycle: { action: 'completed' } + }) + expect(db.getTask(worker.taskId)?.status).toBe('completed') + }) + + it('passes the fence for a stale --from handle and leaves refusal to the process check', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + panes = { ...panes, term_worker_reminted: WORKER_PANE } + delete panes.term_worker + + expect(await workerDone(worker, { paneKey: WORKER_PANE })).toMatchObject({ + lifecycle: { action: 'rejected', code: 'worker_identity_changed' } + }) + }) + }) + + describe('worker states', () => { + it('refuses reports and questions while a stop is in flight', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + + expect(await workerDone(worker)).toMatchObject({ + lifecycle: { action: 'rejected', code: 'dispatch_inactive' } + }) + await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(db.getWorkerDispatch(worker.dispatchId)?.state).toBe('stopping') + }) + + it.each([ + ['succeeded', 'completed', 'succeeded'], + ['failed', 'failed', 'failed'] + ])( + 'settles a %s report after a stop whose outcome is unknown', + async (outcome, taskStatus, workerState) => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') + + expect(await workerDone(worker, undefined, outcome)).toMatchObject({ + lifecycle: { action: taskStatus } + }) + expect(db.getTask(worker.taskId)?.status).toBe(taskStatus) + expect(db.getWorkerDispatch(worker.dispatchId)).toMatchObject({ + state: workerState, + ...(outcome === 'succeeded' ? { last_error: null } : {}) + }) + } + ) + + it('refuses a stale process in the worker pane after an unknown stop', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') + + expect(await workerDone(worker)).toMatchObject({ + lifecycle: { code: 'worker_identity_changed' } + }) + await expect(ask()).rejects.toMatchObject({ code: 'worker_identity_changed' }) + }) + }) + + describe('remote worker states', () => { + function startRemoteWorker(): string { + const dispatchId = 'ctx_remote_worker' + db.createRemoteDispatchAttachment({ + runId: 'run-home', + dispatchId, + taskId: 'task_remote_worker', + homePeerFingerprint: 'run-home-device', + protocolVersion: 1, + runtimeEpoch: 'epoch_worker_host', + mutationReceipt: { + callerFingerprint: 'run-home-device', + requestId: 'remote_attach', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_attach_payload' + } + }) + db.prepareRemoteAttachmentAuthority({ + dispatchId, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS, + worktreeId: 'repo::remote', + terminalHandle: 'term_worker', + setupState: 'completed', + effects: [] + }) + return dispatchId + } + + it.each([ + [ + 'stop_unknown', + (dispatchId: string) => { + db.markRemoteAttachmentReady(dispatchId) + db.beginRemoteAttachmentStop(dispatchId) + db.markRemoteAttachmentStopUnknown(dispatchId, 'tab not owned by Orca') + } + ], + [ + 'start_unknown', + (dispatchId: string) => + db.failRemoteAttachment(dispatchId, 'agent_readiness', 'connection lost', true) + ] + ])('settles a remote report from %s', async (state, reachState) => { + setup() + const dispatchId = startRemoteWorker() + reachState(dispatchId) + expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe(state) + + expect(await workerDone({ taskId: 'task_remote_worker', dispatchId })).toMatchObject({ + lifecycle: { action: 'completed' } + }) + expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe('succeeded') + }) + + it('refuses a remote report while the stop is in flight', async () => { + setup() + const dispatchId = startRemoteWorker() + db.markRemoteAttachmentReady(dispatchId) + db.beginRemoteAttachmentStop(dispatchId) + + await expect(workerDone({ taskId: 'task_remote_worker', dispatchId })).rejects.toMatchObject({ + code: 'dispatch_inactive' + }) + await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(db.listPendingFederationRelay(dispatchId, 'to_home')).toEqual([]) + }) + }) +}) + +const PANE = 'tab_lead:22222222-2222-4222-9222-222222222222' +const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' +const SESSION = testOrcaSessionId('4bd46b4a-035b-41dd-a122-a9c29122ff11') + +describe.each([false, true])('Dispatch recipient identity (settled=%s)', (settled) => { + const h = createOrchestrationRpcHarness() + let state: ReturnType + let dispatch: ReturnType + + beforeEach(() => { + state = h.setup() + const task = state.db.createTask({ spec: 'lead' }) + dispatch = createRootDispatch(state.db, task.id, 'term_lead', PANE) + if (settled) { + state.db.completeDispatch(dispatch.id) + } + vi.spyOn(state.runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + handle === 'term_lead' ? PANE : h.coordinatorPaneKey + ) + }) + afterEach(() => h.cleanup()) + + function send() { + return h.call( + 'orchestration.send', + { from: 'term_coord', to: `dispatch:${dispatch.id}`, subject: 'follow up' }, + state.ctx + ) + } + function sessionRun() { + state.db.db + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run(SESSION, dispatch.id) + return state.db.createRun({ + objective: 'session lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION + }) + } + async function expectRun(runId: string) { + if (settled) { + await expect(send()).rejects.toMatchObject({ + code: 'dispatch_inactive', + message: expect.stringContaining(`Send to run:${runId} instead`) + }) + expect(state.db.getInbox()).toEqual([]) + } else { + expect(await send()).toMatchObject({ message: { to_handle: `run:${runId}`, run_id: runId } }) + } + } + async function expectNoRedirect(unrelatedRun: string) { + if (settled) { + await expect(send()).rejects.toMatchObject({ + code: 'dispatch_inactive', + message: expect.not.stringContaining(unrelatedRun) + }) + expect(state.db.getInbox()).toEqual([]) + } else { + expect(await send()).toMatchObject({ + message: { to_handle: `dispatch:${dispatch.id}`, run_id: dispatch.run_id } + }) + } + } + + it('uses a durable session binding without a live pane', async () => { + const run = sessionRun() + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) + await expectRun(run.id) + }) + + it('uses the recorded session instead of an unrelated Run now occupying the saved pane', async () => { + const run = sessionRun() + state.db.createRun({ + objective: 'new occupant', + coordinatorHandle: 'term_other', + coordinatorPaneKey: PANE + }) + await expectRun(run.id) + }) + + it('ignores an old session column left behind by an older binary rebind', async () => { + const run = sessionRun() + state.db.db + .prepare( + 'UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, consumer_generation = consumer_generation + 1 WHERE id = ?' + ) + .run('term_other', OTHER, run.id) + await expectNoRedirect(run.id) + }) + + it('does not follow a closed handle to another occupant of its old pane', async () => { + const run = state.db.createRun({ + objective: 'new occupant', + coordinatorHandle: 'term_other', + coordinatorPaneKey: PANE + }) + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) + await expectNoRedirect(run.id) + }) + + it.each(['replacement:pty:2', null])( + 'does not redirect with a replaced or unverifiable process: %s', + async (process) => { + const run = state.db.createRun({ + objective: 'pane run', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: PANE + }) + state.db.db + .prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?') + .run('original:pty:1', dispatch.id) + vi.mocked(state.runtime.getTerminalProcessIncarnation).mockReturnValue(process) + await expectNoRedirect(run.id) + expect(state.db.getDispatchContextById(dispatch.id)?.status).toBe( + settled ? 'completed' : dispatch.status + ) + } + ) + + it('accepts a reminted tab half with the same pane leaf and process', async () => { + const run = state.db.createRun({ + objective: 'same pane', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: PANE + }) + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue( + PANE.replace('tab_lead', 'tab_restored') + ) + await expectRun(run.id) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts deleted file mode 100644 index 4ab4b6555a0..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const PANE = 'tab_lead:22222222-2222-4222-9222-222222222222' -const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' -const SESSION = testOrcaSessionId('4bd46b4a-035b-41dd-a122-a9c29122ff11') - -describe.each([false, true])('Dispatch recipient identity (settled=%s)', (settled) => { - const h = createOrchestrationRpcHarness() - let state: ReturnType - let dispatch: ReturnType - - beforeEach(() => { - state = h.setup() - const task = state.db.createTask({ spec: 'lead' }) - dispatch = createRootDispatch(state.db, task.id, 'term_lead', PANE) - if (settled) { - state.db.completeDispatch(dispatch.id) - } - vi.spyOn(state.runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => - handle === 'term_lead' ? PANE : h.coordinatorPaneKey - ) - }) - afterEach(() => h.cleanup()) - - function send() { - return h.call( - 'orchestration.send', - { from: 'term_coord', to: `dispatch:${dispatch.id}`, subject: 'follow up' }, - state.ctx - ) - } - function sessionRun() { - state.db.db - .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') - .run(SESSION, dispatch.id) - return state.db.createRun({ - objective: 'session lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: null, - coordinatorOrcaSessionId: SESSION - }) - } - async function expectRun(runId: string) { - if (settled) { - await expect(send()).rejects.toMatchObject({ - code: 'dispatch_inactive', - message: expect.stringContaining(`Send to run:${runId} instead`) - }) - expect(state.db.getInbox()).toEqual([]) - } else { - expect(await send()).toMatchObject({ message: { to_handle: `run:${runId}`, run_id: runId } }) - } - } - async function expectNoRedirect(unrelatedRun: string) { - if (settled) { - await expect(send()).rejects.toMatchObject({ - code: 'dispatch_inactive', - message: expect.not.stringContaining(unrelatedRun) - }) - expect(state.db.getInbox()).toEqual([]) - } else { - expect(await send()).toMatchObject({ - message: { to_handle: `dispatch:${dispatch.id}`, run_id: dispatch.run_id } - }) - } - } - - it('uses a durable session binding without a live pane', async () => { - const run = sessionRun() - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) - await expectRun(run.id) - }) - - it('uses the recorded session instead of an unrelated Run now occupying the saved pane', async () => { - const run = sessionRun() - state.db.createRun({ - objective: 'new occupant', - coordinatorHandle: 'term_other', - coordinatorPaneKey: PANE - }) - await expectRun(run.id) - }) - - it('ignores an old session column left behind by an older binary rebind', async () => { - const run = sessionRun() - state.db.db - .prepare( - 'UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, consumer_generation = consumer_generation + 1 WHERE id = ?' - ) - .run('term_other', OTHER, run.id) - await expectNoRedirect(run.id) - }) - - it('does not follow a closed handle to another occupant of its old pane', async () => { - const run = state.db.createRun({ - objective: 'new occupant', - coordinatorHandle: 'term_other', - coordinatorPaneKey: PANE - }) - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) - await expectNoRedirect(run.id) - }) - - it.each(['replacement:pty:2', null])( - 'does not redirect with a replaced or unverifiable process: %s', - async (process) => { - const run = state.db.createRun({ - objective: 'pane run', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: PANE - }) - state.db.db - .prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?') - .run('original:pty:1', dispatch.id) - vi.mocked(state.runtime.getTerminalProcessIncarnation).mockReturnValue(process) - await expectNoRedirect(run.id) - expect(state.db.getDispatchContextById(dispatch.id)?.status).toBe( - settled ? 'completed' : dispatch.status - ) - } - ) - - it('accepts a reminted tab half with the same pane leaf and process', async () => { - const run = state.db.createRun({ - objective: 'same pane', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: PANE - }) - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue( - PANE.replace('tab_lead', 'tab_restored') - ) - await expectRun(run.id) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts index 3f759cc3d15..c7c4edf2362 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts @@ -1,10 +1,370 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../../../core' +import type { OrchestrationDb, RunRow } from '../../../../orchestration/db' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { DispatchContextRow } from '../../../../orchestration/types' import { createRootDispatch, reattachDispatchConsumer } from '../../../../orchestration/db/root-dispatch-test-fixture' import { createOrchestrationRpcHarness } from '../rpc-test-harness' +describe('Run delivery history', () => { + const h = createOrchestrationRpcHarness() + afterEach(() => h.cleanup()) + + it('does not label filtered history as an acknowledgeable delivery', async () => { + const { db, ctx, activeRunId } = h.setup() + const params = { terminal: 'term_coord', run: activeRunId, all: true } + db.insertMessage({ + from: 'worker', + to: `run:${activeRunId}`, + runId: activeRunId, + subject: 'waiting' + }) + expect(await h.call('orchestration.check', params, ctx)).toMatchObject({ + count: 1 + }) + expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(false) + const delivery = db.getOrCreateRunDelivery({ + runId: activeRunId!, + consumerGeneration: db.getRun(activeRunId!)!.consumer_generation + })! + db.insertMessage({ + from: 'worker', + to: `run:${activeRunId}`, + runId: activeRunId, + subject: 'later completion', + type: 'worker_done' + }) + const history = await h.call( + 'orchestration.check', + { + ...params, + format: true, + types: 'worker_done' + }, + ctx + ) + expect(history).toMatchObject({ count: 1, messages: [{ subject: 'later completion' }] }) + expect(history).not.toHaveProperty('deliveryId') + expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(true) + expect(db.getMessageById(delivery.messages[0].id)?.read).toBe(0) + }) +}) + +const PANE_OLD = 'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const PANE_NEW = 'tab_new:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +type CheckResult = { messages: { subject: string }[]; count: number } + +/** + * worker-abandon + worker-start --retry-of moves the Task to another terminal, but the old worker + * keeps polling. Its check used to fall through to the direct mailbox and answer `count: 0`, which + * the worker contract reads as "checkpoint, not a failure" — so it kept editing the new owner's files. + */ +describe('orchestration.check from a terminal whose Attempt was superseded', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let ctx: RpcContext + + afterEach(() => { + h.cleanup() + }) + + function check(handle: string, paneKey: string, params: Record = {}) { + return h.call( + 'orchestration.check', + { terminal: handle, terminalPaneKey: paneKey, ...params }, + ctx + ) as Promise + } + + function startWorker(taskId: string, handle: string, paneKey: string, retryOf?: string): string { + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + retryOf, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey, + processIncarnation: `runtime:${handle}:1`, + worktreeId: 'repo::local', + setupState: 'not_applicable', + effects: [] + }) + return started.dispatch.id + } + + function retriedOntoAnotherTerminal(): string { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that moves terminals' }) + const abandoned = startWorker(task.id, 'term_old', PANE_OLD) + db.abandonWorkerDispatch(abandoned, 'epoch_test') + startWorker(task.id, 'term_new', PANE_NEW, abandoned) + return abandoned + } + + it('tells the old worker it lost the Dispatch instead of answering "no mail"', async () => { + retriedOntoAnotherTerminal() + + await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ + code: 'consumer_fenced', + message: expect.stringContaining('no longer owns its Dispatch') + }) + }) + + // The direct mailbox is the old terminal's own, so inspection stays open; only the consuming + // read that a worker treats as a checkpoint is refused. + it('still lets the old worker inspect its direct mailbox with --peek and --all', async () => { + retriedOntoAnotherTerminal() + db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'stand down' }) + + const peeked = await check('term_old', PANE_OLD, { peek: true }) + const history = await check('term_old', PANE_OLD, { all: true }) + + expect(peeked.count).toBe(1) + expect(history.count).toBe(1) + expect(db.getUnreadMessages('term_old')).toHaveLength(1) + }) + + it('fences a terminal whose Attempt failed with no successor', async () => { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that failed outright' }) + const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) + db.failDispatch(dispatch.id, 'worker terminal closed') + + await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ code: 'consumer_fenced' }) + }) + + // A superseded worker whose pane is gone cannot run-use either; the stop signal outranks the + // rebind advice, and a caller with no settled Attempt still gets the rebind advice. + it('fences a paneless caller whose Attempt was superseded, and only that caller', async () => { + retriedOntoAnotherTerminal() + + await expect( + h.call('orchestration.check', { terminal: 'term_old' }, ctx) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + await expect( + h.call('orchestration.check', { terminal: 'term_never_dispatched' }, ctx) + ).rejects.toMatchObject({ code: 'stable_pane_required' }) + }) + + it('keeps serving direct mail to a terminal whose Attempt completed normally', async () => { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that finished' }) + const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) + db.completeDispatch(dispatch.id) + db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'one more thing' }) + + const result = await check('term_old', PANE_OLD) + + expect(result.messages.map((message) => message.subject)).toEqual(['one more thing']) + expect(db.getUnreadMessages('term_old')).toEqual([]) + }) + + it('serves the new owner its Dispatch mailbox as usual', async () => { + const abandoned = retriedOntoAnotherTerminal() + const current = db.getDispatchContext(db.getDispatchContextById(abandoned)!.task_id)! + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${current.id}`, + subject: 'carry on', + runId: current.run_id + }) + + const result = await check('term_new', PANE_NEW) + + expect(result.messages.map((message) => message.subject)).toEqual(['carry on']) + }) +}) + +// A lead is dispatched by a root coordinator and then coordinates its own Run from the same pane. +// That pane's `check` reads its own Run mailbox, so mail meant for it must land there. +describe('mail for a lead whose pane coordinates its own Run', () => { + const h = createOrchestrationRpcHarness() + const coordPane = 'tab_coord:11111111-1111-4111-8111-111111111111' + const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let rootRun: RunRow + let dispatch: DispatchContextRow + + afterEach(() => { + h.cleanup() + }) + + function setup(): void { + ;({ db, runtime, ctx } = h.setup(false)) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? coordPane : handle === 'term_lead' ? leadPane : null + ) + rootRun = db.createRun({ + objective: 'root', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: coordPane + }) + const task = db.createTask({ spec: 'lead the sub-project', runId: rootRun.id }) + dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) + } + + function bindLeadRun(): RunRow { + return db.createRun({ + objective: 'lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: leadPane + }) + } + + async function call(name: string, params: Record) { + return h.call(name, params, ctx) + } + + async function leadInbox(params: Record = {}): Promise { + return call('orchestration.check', { terminal: 'term_lead', ...params }) + } + + function deliveryIdOf(result: unknown): string { + if ( + typeof result === 'object' && + result !== null && + 'deliveryId' in result && + typeof result.deliveryId === 'string' + ) { + return result.deliveryId + } + throw new Error('check returned no delivery') + } + + it('routes dispatch: mail to the Run the assignee pane now coordinates', async () => { + setup() + const leadRun = bindLeadRun() + + const result = await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Follow-up for the lead' + }) + + expect(result).toMatchObject({ + message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id }, + warnings: [{ code: 'recipient_run_bound_redirect' }] + }) + expect(await leadInbox()).toMatchObject({ messages: [{ subject: 'Follow-up for the lead' }] }) + }) + + it('still reads dispatch mail that arrived before the pane bound its own Run', async () => { + setup() + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Sent before the lead bound a Run', + runId: rootRun.id + }) + const leadRun = bindLeadRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${leadRun.id}`, + subject: 'Sub-worker report', + runId: leadRun.id + }) + + const first = await leadInbox() + expect(first).toMatchObject({ messages: [{ subject: 'Sent before the lead bound a Run' }] }) + + const second = await leadInbox({ ack: deliveryIdOf(first) }) + expect(second).toMatchObject({ messages: [{ subject: 'Sub-worker report' }] }) + }) + + it('keeps the --types wake condition when older Dispatch mail does not match it', async () => { + setup() + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Older status note', + type: 'status', + runId: rootRun.id + }) + const leadRun = bindLeadRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${leadRun.id}`, + subject: 'Sub-worker finished', + type: 'worker_done', + runId: leadRun.id + }) + + const woke = await leadInbox({ wait: true, types: 'worker_done', timeoutMs: 500 }) + + expect(woke).toMatchObject({ + runId: leadRun.id, + messages: [{ subject: 'Sub-worker finished' }] + }) + }) + + it('delivers a reply to a Run-bound sender and wakes its waiting check', async () => { + setup() + const leadRun = bindLeadRun() + const report = db.insertMessage({ + from: 'term_lead', + to: `run:${rootRun.id}`, + subject: 'Lead report', + runId: rootRun.id + }) + + const waiting = leadInbox({ wait: true, timeoutMs: 2_000 }) + const reply = await call('orchestration.reply', { + id: report.id, + from: 'term_coord', + body: 'Decision' + }) + + expect(reply).toMatchObject({ + message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id } + }) + expect(await waiting).toMatchObject({ + timedOut: false, + messages: [{ subject: 'Re: Lead report' }] + }) + }) + + it('keeps dispatch: mail on the Dispatch mailbox while the assignee has no Run', async () => { + setup() + + const result = await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Plain worker follow-up' + }) + + expect(result).toMatchObject({ message: { to_handle: `dispatch:${dispatch.id}` } }) + expect(result).not.toHaveProperty('warnings') + }) + + it('keeps a reply on the raw handle when the sender has no Run or live pane', async () => { + setup() + const note = db.insertMessage({ + from: 'term_offline', + to: `run:${rootRun.id}`, + subject: 'Offline note', + runId: rootRun.id + }) + + const reply = await call('orchestration.reply', { + id: note.id, + from: 'term_coord', + body: 'Ack' + }) + + expect(reply).toMatchObject({ message: { to_handle: 'term_offline', run_id: rootRun.id } }) + }) +}) + const LEAD = 'tab_lead:22222222-2222-4222-9222-222222222222' const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts deleted file mode 100644 index bb6c7041cee..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb, RunRow } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { DispatchContextRow } from '../../../../orchestration/types' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -// A lead is dispatched by a root coordinator and then coordinates its own Run from the same pane. -// That pane's `check` reads its own Run mailbox, so mail meant for it must land there. -describe('mail for a lead whose pane coordinates its own Run', () => { - const h = createOrchestrationRpcHarness() - const coordPane = 'tab_coord:11111111-1111-4111-8111-111111111111' - const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let rootRun: RunRow - let dispatch: DispatchContextRow - - afterEach(() => { - h.cleanup() - }) - - function setup(): void { - ;({ db, runtime, ctx } = h.setup(false)) - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_coord' ? coordPane : handle === 'term_lead' ? leadPane : null - ) - rootRun = db.createRun({ - objective: 'root', - coordinatorHandle: 'term_coord', - coordinatorPaneKey: coordPane - }) - const task = db.createTask({ spec: 'lead the sub-project', runId: rootRun.id }) - dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) - } - - function bindLeadRun(): RunRow { - return db.createRun({ - objective: 'lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: leadPane - }) - } - - async function call(name: string, params: Record) { - return h.call(name, params, ctx) - } - - async function leadInbox(params: Record = {}): Promise { - return call('orchestration.check', { terminal: 'term_lead', ...params }) - } - - function deliveryIdOf(result: unknown): string { - if ( - typeof result === 'object' && - result !== null && - 'deliveryId' in result && - typeof result.deliveryId === 'string' - ) { - return result.deliveryId - } - throw new Error('check returned no delivery') - } - - it('routes dispatch: mail to the Run the assignee pane now coordinates', async () => { - setup() - const leadRun = bindLeadRun() - - const result = await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Follow-up for the lead' - }) - - expect(result).toMatchObject({ - message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id }, - warnings: [{ code: 'recipient_run_bound_redirect' }] - }) - expect(await leadInbox()).toMatchObject({ messages: [{ subject: 'Follow-up for the lead' }] }) - }) - - it('still reads dispatch mail that arrived before the pane bound its own Run', async () => { - setup() - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Sent before the lead bound a Run', - runId: rootRun.id - }) - const leadRun = bindLeadRun() - db.insertMessage({ - from: 'term_worker', - to: `run:${leadRun.id}`, - subject: 'Sub-worker report', - runId: leadRun.id - }) - - const first = await leadInbox() - expect(first).toMatchObject({ messages: [{ subject: 'Sent before the lead bound a Run' }] }) - - const second = await leadInbox({ ack: deliveryIdOf(first) }) - expect(second).toMatchObject({ messages: [{ subject: 'Sub-worker report' }] }) - }) - - it('keeps the --types wake condition when older Dispatch mail does not match it', async () => { - setup() - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Older status note', - type: 'status', - runId: rootRun.id - }) - const leadRun = bindLeadRun() - db.insertMessage({ - from: 'term_worker', - to: `run:${leadRun.id}`, - subject: 'Sub-worker finished', - type: 'worker_done', - runId: leadRun.id - }) - - const woke = await leadInbox({ wait: true, types: 'worker_done', timeoutMs: 500 }) - - expect(woke).toMatchObject({ - runId: leadRun.id, - messages: [{ subject: 'Sub-worker finished' }] - }) - }) - - it('delivers a reply to a Run-bound sender and wakes its waiting check', async () => { - setup() - const leadRun = bindLeadRun() - const report = db.insertMessage({ - from: 'term_lead', - to: `run:${rootRun.id}`, - subject: 'Lead report', - runId: rootRun.id - }) - - const waiting = leadInbox({ wait: true, timeoutMs: 2_000 }) - const reply = await call('orchestration.reply', { - id: report.id, - from: 'term_coord', - body: 'Decision' - }) - - expect(reply).toMatchObject({ - message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id } - }) - expect(await waiting).toMatchObject({ - timedOut: false, - messages: [{ subject: 'Re: Lead report' }] - }) - }) - - it('keeps dispatch: mail on the Dispatch mailbox while the assignee has no Run', async () => { - setup() - - const result = await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Plain worker follow-up' - }) - - expect(result).toMatchObject({ message: { to_handle: `dispatch:${dispatch.id}` } }) - expect(result).not.toHaveProperty('warnings') - }) - - it('keeps a reply on the raw handle when the sender has no Run or live pane', async () => { - setup() - const note = db.insertMessage({ - from: 'term_offline', - to: `run:${rootRun.id}`, - subject: 'Offline note', - runId: rootRun.id - }) - - const reply = await call('orchestration.reply', { - id: note.id, - from: 'term_coord', - body: 'Ack' - }) - - expect(reply).toMatchObject({ message: { to_handle: 'term_offline', run_id: rootRun.id } }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts index 9f57957a10a..c3f6d2aec2f 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../../../core' import type { OrchestrationDb } from '../../../../orchestration/db' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' import { openDecisionGateFromMessage } from '../../../../orchestration/coordinator-decision-gates' import { applyEscalationToDispatch } from '../../../../orchestration/coordinator-escalation-triage' import { createOrchestrationRpcHarness } from '../rpc-test-harness' @@ -143,3 +144,251 @@ describe('orchestration.send Dispatch authority', () => { } ) }) + +// The same delivery plumbing `check` already strips; a send/reply receipt is the same mailbox row. +const INTERNAL_COLUMNS = [ + 'read', + 'sequence', + 'sender_pane_key', + 'pointer_enter_pending', + 'pointer_pty_id', + 'pointer_process_incarnation' +] + +function terminalSummary(handle: string): RuntimeTerminalSummary { + return { + handle, + ptyId: `pty_${handle}`, + worktreeId: 'wt_default', + worktreePath: '/tmp/wt', + branch: 'main', + tabId: 'tab_1', + leafId: handle, + title: null, + connected: true, + writable: true, + lastOutputAt: null, + preview: '' + } +} + +describe('orchestration send and reply receipts', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string | undefined + + afterEach(() => h.cleanup()) + + function setup(): void { + ;({ db, runtime, ctx, activeRunId } = h.setup()) + } + + it('keeps delivery plumbing out of a point-to-point send receipt', async () => { + setup() + + const result = (await h.call( + 'orchestration.send', + { from: 'term_coord', to: `run:${activeRunId}`, subject: 'plumbing' }, + ctx + )) as { message: Record } + + expect(result.message).toMatchObject({ subject: 'plumbing' }) + for (const column of INTERNAL_COLUMNS) { + expect(result.message).not.toHaveProperty(column) + } + }) + + it('keeps delivery plumbing out of a group send receipt', async () => { + setup() + const terminals = [terminalSummary('term_coord'), terminalSummary('term_worker')] + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals, + totalCount: terminals.length, + truncated: false + }) + createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker') + + const result = (await h.call( + 'orchestration.send', + { from: 'term_coord', to: '@all', subject: 'group plumbing' }, + ctx + )) as { messages: Record[] } + + expect(result.messages).toHaveLength(1) + for (const message of result.messages) { + for (const column of INTERNAL_COLUMNS) { + expect(message).not.toHaveProperty(column) + } + } + }) + + it('keeps delivery plumbing out of a reply receipt', async () => { + setup() + const original = db.insertMessage({ + from: 'term_worker', + to: `run:${activeRunId}`, + subject: 'Need an answer' + }) + + const result = (await h.call( + 'orchestration.reply', + { id: original.id, body: 'One durable answer', from: 'term_coord' }, + ctx + )) as { message: Record } + + expect(result.message).toMatchObject({ subject: 'Re: Need an answer' }) + for (const column of INTERNAL_COLUMNS) { + expect(result.message).not.toHaveProperty(column) + } + }) +}) + +describe('orchestration.send between terminals in no Run', () => { + const h = createOrchestrationRpcHarness() + afterEach(() => h.cleanup()) + + it('delivers terminal-to-terminal mail when neither terminal is in a Run', async () => { + // Two plain panes and `send --to `: the first command the guide teaches. #19542 + // refused this with a bare "Run is required"; it files under the unbound Run instead. + const { db, runtime, ctx } = h.setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_a' ? 'tab_a:leaf_a' : handle === 'term_b' ? 'tab_b:leaf_b' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + const result = (await h.call( + 'orchestration.send', + { from: 'term_a', to: 'term_b', subject: 'hello from no Run' }, + ctx + )) as { message: { id: string; run_id: string; to_handle: string } } + + expect(result.message).toMatchObject({ run_id: 'run_unbound', to_handle: 'term_b' }) + expect(db.getRun('run_unbound')).toMatchObject({ legacy: 0 }) + expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([result.message.id]) + const checked = (await h.call('orchestration.check', { terminal: 'term_b' }, ctx)) as { + messages: { id: string }[] + } + expect(checked.messages.map((row) => row.id)).toEqual([result.message.id]) + }) +}) + +describe('orchestration.send to a settled Dispatch mailbox', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let ctx: RpcContext + + afterEach(() => { + h.cleanup() + }) + + function setup(): void { + ;({ db, ctx } = h.setup()) + } + + async function call(name: string, params: Record) { + return h.call(name, params, ctx) + } + + it.each([ + ['completed', (settledDb: OrchestrationDb, id: string) => settledDb.completeDispatch(id)], + [ + 'failed', + (settledDb: OrchestrationDb, id: string) => + settledDb.failDispatch(id, 'worker terminal closed') + ] + ])('rejects mail to a %s Dispatch instead of reporting success', async (_status, settle) => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + settle(db, dispatch.id) + + await expect( + call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + ).rejects.toMatchObject({ code: 'dispatch_inactive' }) + }) + + it('names the Run mailbox that is still reachable', async () => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + db.completeDispatch(dispatch.id) + + await expect( + call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + ).rejects.toThrow(new RegExp(`run:${dispatch.run_id}`)) + }) + + it('names the Run a settled assignee now coordinates, not the sender Run', async () => { + setup() + const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' + vi.spyOn(ctx.runtime, 'getLiveTerminalPaneKey').mockReturnValue(leadPane) + const task = db.createTask({ spec: 'lead that settled and kept coordinating' }) + const dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) + db.completeDispatch(dispatch.id) + const leadRun = db.createRun({ + objective: 'lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: leadPane + }) + + const rejection = call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + + await expect(rejection).rejects.toMatchObject({ code: 'dispatch_inactive' }) + await expect(rejection).rejects.toThrow(new RegExp(`run:${leadRun.id}`)) + await expect(rejection).rejects.not.toThrow(new RegExp(`run:${dispatch.run_id}`)) + expect(db.getInbox()).toEqual([]) + await expect( + call('orchestration.check', { terminal: 'term_coord', run: leadRun.id }) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + // Run addresses are already visible to callers; a hint grants no consuming authority. + expect(await call('orchestration.runShow', { id: leadRun.id })).toMatchObject({ + run: { id: leadRun.id } + }) + }) + + it('does not write an undeliverable message row', async () => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + db.completeDispatch(dispatch.id) + + await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }).catch(() => undefined) + + const stranded = db.db + .prepare('SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?') + .get(`dispatch:${dispatch.id}`) as { count: number } + expect(stranded.count).toBe(0) + }) + + it('still delivers to an active Dispatch mailbox', async () => { + setup() + const task = db.createTask({ spec: 'worker still running' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Pause after this step' + })) as { message: { to_handle: string } } + + expect(result.message.to_handle).toBe(`dispatch:${dispatch.id}`) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts deleted file mode 100644 index 232a5992a87..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' - -// The same delivery plumbing `check` already strips; a send/reply receipt is the same mailbox row. -const INTERNAL_COLUMNS = [ - 'read', - 'sequence', - 'sender_pane_key', - 'pointer_enter_pending', - 'pointer_pty_id', - 'pointer_process_incarnation' -] - -function terminalSummary(handle: string): RuntimeTerminalSummary { - return { - handle, - ptyId: `pty_${handle}`, - worktreeId: 'wt_default', - worktreePath: '/tmp/wt', - branch: 'main', - tabId: 'tab_1', - leafId: handle, - title: null, - connected: true, - writable: true, - lastOutputAt: null, - preview: '' - } -} - -describe('orchestration send and reply receipts', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let activeRunId: string | undefined - - afterEach(() => h.cleanup()) - - function setup(): void { - ;({ db, runtime, ctx, activeRunId } = h.setup()) - } - - it('keeps delivery plumbing out of a point-to-point send receipt', async () => { - setup() - - const result = (await h.call( - 'orchestration.send', - { from: 'term_coord', to: `run:${activeRunId}`, subject: 'plumbing' }, - ctx - )) as { message: Record } - - expect(result.message).toMatchObject({ subject: 'plumbing' }) - for (const column of INTERNAL_COLUMNS) { - expect(result.message).not.toHaveProperty(column) - } - }) - - it('keeps delivery plumbing out of a group send receipt', async () => { - setup() - const terminals = [terminalSummary('term_coord'), terminalSummary('term_worker')] - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals, - totalCount: terminals.length, - truncated: false - }) - createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker') - - const result = (await h.call( - 'orchestration.send', - { from: 'term_coord', to: '@all', subject: 'group plumbing' }, - ctx - )) as { messages: Record[] } - - expect(result.messages).toHaveLength(1) - for (const message of result.messages) { - for (const column of INTERNAL_COLUMNS) { - expect(message).not.toHaveProperty(column) - } - } - }) - - it('keeps delivery plumbing out of a reply receipt', async () => { - setup() - const original = db.insertMessage({ - from: 'term_worker', - to: `run:${activeRunId}`, - subject: 'Need an answer' - }) - - const result = (await h.call( - 'orchestration.reply', - { id: original.id, body: 'One durable answer', from: 'term_coord' }, - ctx - )) as { message: Record } - - expect(result.message).toMatchObject({ subject: 'Re: Need an answer' }) - for (const column of INTERNAL_COLUMNS) { - expect(result.message).not.toHaveProperty(column) - } - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts deleted file mode 100644 index 84cb250a19e..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('orchestration.send between terminals in no Run', () => { - const h = createOrchestrationRpcHarness() - afterEach(() => h.cleanup()) - - it('delivers terminal-to-terminal mail when neither terminal is in a Run', async () => { - // Two plain panes and `send --to `: the first command the guide teaches. #19542 - // refused this with a bare "Run is required"; it files under the unbound Run instead. - const { db, runtime, ctx } = h.setup(false) - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_a' ? 'tab_a:leaf_a' : handle === 'term_b' ? 'tab_b:leaf_b' : null - ) - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) - - const result = (await h.call( - 'orchestration.send', - { from: 'term_a', to: 'term_b', subject: 'hello from no Run' }, - ctx - )) as { message: { id: string; run_id: string; to_handle: string } } - - expect(result.message).toMatchObject({ run_id: 'run_unbound', to_handle: 'term_b' }) - expect(db.getRun('run_unbound')).toMatchObject({ legacy: 0 }) - expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([result.message.id]) - const checked = (await h.call('orchestration.check', { terminal: 'term_b' }, ctx)) as { - messages: { id: string }[] - } - expect(checked.messages.map((row) => row.id)).toEqual([result.message.id]) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts deleted file mode 100644 index ddffed46890..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('orchestration.send to a settled Dispatch mailbox', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let ctx: RpcContext - - afterEach(() => { - h.cleanup() - }) - - function setup(): void { - ;({ db, ctx } = h.setup()) - } - - async function call(name: string, params: Record) { - return h.call(name, params, ctx) - } - - it.each([ - ['completed', (settledDb: OrchestrationDb, id: string) => settledDb.completeDispatch(id)], - [ - 'failed', - (settledDb: OrchestrationDb, id: string) => - settledDb.failDispatch(id, 'worker terminal closed') - ] - ])('rejects mail to a %s Dispatch instead of reporting success', async (_status, settle) => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - settle(db, dispatch.id) - - await expect( - call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - ).rejects.toMatchObject({ code: 'dispatch_inactive' }) - }) - - it('names the Run mailbox that is still reachable', async () => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - db.completeDispatch(dispatch.id) - - await expect( - call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - ).rejects.toThrow(new RegExp(`run:${dispatch.run_id}`)) - }) - - it('names the Run a settled assignee now coordinates, not the sender Run', async () => { - setup() - const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' - vi.spyOn(ctx.runtime, 'getLiveTerminalPaneKey').mockReturnValue(leadPane) - const task = db.createTask({ spec: 'lead that settled and kept coordinating' }) - const dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) - db.completeDispatch(dispatch.id) - const leadRun = db.createRun({ - objective: 'lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: leadPane - }) - - const rejection = call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - - await expect(rejection).rejects.toMatchObject({ code: 'dispatch_inactive' }) - await expect(rejection).rejects.toThrow(new RegExp(`run:${leadRun.id}`)) - await expect(rejection).rejects.not.toThrow(new RegExp(`run:${dispatch.run_id}`)) - expect(db.getInbox()).toEqual([]) - await expect( - call('orchestration.check', { terminal: 'term_coord', run: leadRun.id }) - ).rejects.toMatchObject({ code: 'consumer_fenced' }) - // Run addresses are already visible to callers; a hint grants no consuming authority. - expect(await call('orchestration.runShow', { id: leadRun.id })).toMatchObject({ - run: { id: leadRun.id } - }) - }) - - it('does not write an undeliverable message row', async () => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - db.completeDispatch(dispatch.id) - - await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }).catch(() => undefined) - - const stranded = db.db - .prepare('SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?') - .get(`dispatch:${dispatch.id}`) as { count: number } - expect(stranded.count).toBe(0) - }) - - it('still delivers to an active Dispatch mailbox', async () => { - setup() - const task = db.createTask({ spec: 'worker still running' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - - const result = (await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Pause after this step' - })) as { message: { to_handle: string } } - - expect(result.message.to_handle).toBe(`dispatch:${dispatch.id}`) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts deleted file mode 100644 index 6c311baf84d..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration-compatibility-evidence' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const OTHER_WORKER_PANE = 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' -const NON_PARTY_PANE = 'tab_teammate:dddddddd-dddd-4ddd-8ddd-dddddddddddd' -const WORKER_PROCESS = 'runtime_test:term_worker:1' - -describe('worker report authority without a Dispatch capability', () => { - const harness = createOrchestrationRpcHarness() - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let panes: Record - - afterEach(() => harness.cleanup()) - - function setup(): void { - ;({ db, runtime } = harness.setup()) - ctx = { runtime } - panes = { - term_coord: harness.coordinatorPaneKey, - term_worker: WORKER_PANE, - term_other: OTHER_WORKER_PANE, - term_teammate: NON_PARTY_PANE - } - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) - vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( - (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null - ) - vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') - } - - function startWorker(name: string, paneKey: string): { taskId: string; dispatchId: string } { - const task = db.createTask({ spec: `${name} work` }) - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: `term_${name}`, - paneKey, - processIncarnation: `runtime_test:term_${name}:1`, - worktreeId: `repo::${name}`, - effects: [], - setupState: 'not_applicable', - terminalOwnership: 'created' - }) - db.markWorkerDispatchReady(started.dispatch.id) - return { taskId: task.id, dispatchId: started.dispatch.id } - } - - function workerDone( - worker: { taskId: string; dispatchId: string }, - evidence?: OrchestrationCompatibilityEvidence, - outcome = 'succeeded' - ) { - return harness.call( - 'orchestration.send', - { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ ...worker, outcome }) - }, - { ...ctx, orchestrationCompatibilityEvidence: evidence } - ) - } - - function ask(evidence?: OrchestrationCompatibilityEvidence) { - return harness.call( - 'orchestration.ask', - { from: 'term_worker', question: 'Proceed?', timeoutMs: 1 }, - { ...ctx, orchestrationCompatibilityEvidence: evidence } - ) - } - - describe('caller fence', () => { - it.each([ - ['the Run coordinator', harness.coordinatorPaneKey], - ['another Dispatch worker', OTHER_WORKER_PANE] - ])('refuses a report sent from %s terminal and records nothing', async (_party, paneKey) => { - setup() - startWorker('other', OTHER_WORKER_PANE) - const worker = startWorker('worker', WORKER_PANE) - - await expect(workerDone(worker, { paneKey })).rejects.toMatchObject({ - code: 'consumer_fenced', - data: { effectsApplied: false } - }) - await expect(ask({ paneKey })).rejects.toMatchObject({ code: 'consumer_fenced' }) - expect( - db.db - .prepare("SELECT COUNT(*) AS count FROM messages WHERE from_handle = 'term_worker'") - .get() - ).toEqual({ count: 0 }) - expect(db.getTask(worker.taskId)?.status).toBe('dispatched') - }) - - it.each<[string, OrchestrationCompatibilityEvidence | undefined]>([ - ['no identity env (old CLI, scrubbed env)', undefined], - ['its own pane', { paneKey: WORKER_PANE, terminalHandle: 'term_stale_after_remint' }], - ["another Orca's pane", { paneKey: 'tab_elsewhere:eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }], - ['a stale handle', { terminalHandle: 'term_gone' }], - ['a live pane that is no orchestration party', { paneKey: NON_PARTY_PANE }] - ])('accepts a report whose env names %s', async (_case, evidence) => { - setup() - const worker = startWorker('worker', WORKER_PANE) - - expect(await workerDone(worker, evidence)).toMatchObject({ - lifecycle: { action: 'completed' } - }) - expect(db.getTask(worker.taskId)?.status).toBe('completed') - }) - - it('passes the fence for a stale --from handle and leaves refusal to the process check', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - panes = { ...panes, term_worker_reminted: WORKER_PANE } - delete panes.term_worker - - expect(await workerDone(worker, { paneKey: WORKER_PANE })).toMatchObject({ - lifecycle: { action: 'rejected', code: 'worker_identity_changed' } - }) - }) - }) - - describe('worker states', () => { - it('refuses reports and questions while a stop is in flight', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - - expect(await workerDone(worker)).toMatchObject({ - lifecycle: { action: 'rejected', code: 'dispatch_inactive' } - }) - await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) - expect(db.getWorkerDispatch(worker.dispatchId)?.state).toBe('stopping') - }) - - it.each([ - ['succeeded', 'completed', 'succeeded'], - ['failed', 'failed', 'failed'] - ])( - 'settles a %s report after a stop whose outcome is unknown', - async (outcome, taskStatus, workerState) => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') - - expect(await workerDone(worker, undefined, outcome)).toMatchObject({ - lifecycle: { action: taskStatus } - }) - expect(db.getTask(worker.taskId)?.status).toBe(taskStatus) - expect(db.getWorkerDispatch(worker.dispatchId)).toMatchObject({ - state: workerState, - ...(outcome === 'succeeded' ? { last_error: null } : {}) - }) - } - ) - - it('refuses a stale process in the worker pane after an unknown stop', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') - vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') - - expect(await workerDone(worker)).toMatchObject({ - lifecycle: { code: 'worker_identity_changed' } - }) - await expect(ask()).rejects.toMatchObject({ code: 'worker_identity_changed' }) - }) - }) - - describe('remote worker states', () => { - function startRemoteWorker(): string { - const dispatchId = 'ctx_remote_worker' - db.createRemoteDispatchAttachment({ - runId: 'run-home', - dispatchId, - taskId: 'task_remote_worker', - homePeerFingerprint: 'run-home-device', - protocolVersion: 1, - runtimeEpoch: 'epoch_worker_host', - mutationReceipt: { - callerFingerprint: 'run-home-device', - requestId: 'remote_attach', - method: 'orchestration.federationAttachStart', - payloadHash: 'remote_attach_payload' - } - }) - db.prepareRemoteAttachmentAuthority({ - dispatchId, - paneKey: WORKER_PANE, - processIncarnation: WORKER_PROCESS, - worktreeId: 'repo::remote', - terminalHandle: 'term_worker', - setupState: 'completed', - effects: [] - }) - return dispatchId - } - - it.each([ - [ - 'stop_unknown', - (dispatchId: string) => { - db.markRemoteAttachmentReady(dispatchId) - db.beginRemoteAttachmentStop(dispatchId) - db.markRemoteAttachmentStopUnknown(dispatchId, 'tab not owned by Orca') - } - ], - [ - 'start_unknown', - (dispatchId: string) => - db.failRemoteAttachment(dispatchId, 'agent_readiness', 'connection lost', true) - ] - ])('settles a remote report from %s', async (state, reachState) => { - setup() - const dispatchId = startRemoteWorker() - reachState(dispatchId) - expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe(state) - - expect(await workerDone({ taskId: 'task_remote_worker', dispatchId })).toMatchObject({ - lifecycle: { action: 'completed' } - }) - expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe('succeeded') - }) - - it('refuses a remote report while the stop is in flight', async () => { - setup() - const dispatchId = startRemoteWorker() - db.markRemoteAttachmentReady(dispatchId) - db.beginRemoteAttachmentStop(dispatchId) - - await expect(workerDone({ taskId: 'task_remote_worker', dispatchId })).rejects.toMatchObject({ - code: 'dispatch_inactive' - }) - await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) - expect(db.listPendingFederationRelay(dispatchId, 'to_home')).toEqual([]) - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts deleted file mode 100644 index f93df6e4d7a..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' -import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -const READY_WAIT = { - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null -} satisfies RuntimeTerminalWait - -describe('Antigravity orchestration worker lifecycle', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { - h.setup() - const readiness = h.deferred() - vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) - - const pending = h.startWorker({ agent: 'antigravity' }) - await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) - - expect(h.runtime.createTerminal).toHaveBeenCalledWith( - 'id:repo::worktree', - expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) - ) - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ - ownership_state: 'owned', - terminal_handle: 'term_worker' - }) - - readiness.resolve(READY_WAIT) - await expect(pending).resolves.toEqual( - expect.objectContaining({ dispatchId: expect.any(String) }) - ) - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) - }) - - it('stops only the owned AGY terminal', async () => { - h.setup() - const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) - - await expect( - h.call('orchestration.workerStop', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') - }) - - it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker('succeeded', { - agent: 'antigravity' - }) - vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'owned', - release_state: 'releasing' - }) - - await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ - attempted: 1, - released: 1 - }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - release_state: 'released' - }) - expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) - expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') - }) - - it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker('succeeded', { - agent: 'antigravity' - }) - vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'release_unknown' }) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'owned', - release_state: 'unknown' - }) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'released' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts deleted file mode 100644 index bbca9ec29ec..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition -// used to reject it and its abandoned Task had no documented route back. -describe('worker-start --retry-of a context-only Dispatch', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - async function dispatchContextOnly( - spec: string - ): Promise<{ taskId: string; dispatchId: string }> { - const task = harness.db.createTask({ spec, runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { id: string } } - expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined() - return { taskId: task.id, dispatchId: result.dispatch.id } - } - - it('restarts the Task after the attempt is abandoned', async () => { - const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt') - - await expect( - harness.call('orchestration.workerAbandon', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) - expect(harness.db.getTask(taskId)?.status).toBe('blocked') - - const retried = (await harness.call('orchestration.workerStart', { - task: taskId, - from: 'term_coord', - terminal: 'term_worker', - retryOf: dispatchId - })) as { dispatchId: string; state: string } - - expect(retried.state).toBe('ready') - expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe( - dispatchId - ) - expect(harness.db.getTask(taskId)?.status).toBe('dispatched') - }) - - it('still refuses to retry an attempt that has not settled', async () => { - const { taskId, dispatchId } = await dispatchContextOnly('live attempt') - - await expect( - harness.call('orchestration.workerStart', { - task: taskId, - from: 'term_coord', - terminal: 'term_worker', - retryOf: dispatchId - }) - ).rejects.toMatchObject({ code: 'task_not_startable' }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts deleted file mode 100644 index 75ad57f3a12..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -type ListedWorker = { - dispatchId: string - workerState: string - dispatchStatus: string - projection: { - outcome: string - liveness: { verdict: string; reason?: string } - nextAction: { kind: string; argv: string[] } - attention: { categories: string[]; requiresAction: boolean } - } -} - -describe('pre-v3 dispatch rows in worker-list', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - /** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no - * worker_dispatches row, which is what every dispatch made before supervised workers looks like. */ - function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string { - const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId }) - const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`) - if (status === 'completed') { - h.db.completeDispatch(dispatch.id) - } - if (status === 'failed') { - h.db.failDispatch(dispatch.id, 'legacy failure') - } - return dispatch.id - } - - async function listWorkers(): Promise> { - const listed = (await h.call('orchestration.workerList', { - paginate: true, - run: h.activeRunId - })) as { workers: ListedWorker[] } - return new Map(listed.workers.map((worker) => [worker.dispatchId, worker])) - } - - it('projects a settled legacy dispatch as settled with nothing to act on', async () => { - h.setup() - const completed = createLegacyDispatch('completed') - - const worker = (await listWorkers()).get(completed)! - - expect(worker.workerState).toBe('unsupervised') - expect(worker.dispatchStatus).toBe('completed') - // `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded` - // report or a task completion, so the durable record is the whole settlement. - expect(worker.projection.outcome).toBe('succeeded') - // Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch - // that never had a worker row has no process whose absence could require action. - expect(worker.projection.liveness).toEqual({ - verdict: 'unverifiable', - reason: 'unsupervised_settled' - }) - expect(worker.projection.attention.categories).not.toContain('unverifiable') - expect(worker.projection.attention.requiresAction).toBe(false) - expect(worker.projection.nextAction.kind).toBe('none') - }) - - it.each(['completed', 'failed'] as const)( - 'closes a pending question when a legacy dispatch settles as %s', - async (status) => { - h.setup() - const task = h.db.createTask({ spec: `legacy ${status} with question`, runId: h.activeRunId }) - const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`) - const asked = h.db.createQuestion({ - runId: h.activeRunId, - dispatchId: dispatch.id, - askerHandle: `term_legacy_q_${status}`, - question: 'Which branch?' - }) - // Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch. - if (status === 'completed') { - h.db.updateTaskStatus(task.id, 'completed', 'done') - } else { - h.db.failDispatch(dispatch.id, 'legacy failure') - } - - const worker = (await listWorkers()).get(dispatch.id)! - - expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed') - expect(worker.dispatchStatus).toBe(status) - expect(worker.projection.attention.categories).not.toContain('input') - // Nothing can answer a question on a settled Dispatch, so `input` must not outlive it. - expect(worker.projection.attention.requiresAction).toBe(status === 'failed') - } - ) - - it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => { - h.setup() - const failed = createLegacyDispatch('failed') - - const worker = (await listWorkers()).get(failed)! - - expect(worker.dispatchStatus).toBe('failed') - expect(worker.projection.outcome).toBe('failed') - expect(worker.projection.attention.categories).toEqual(['failure']) - expect(worker.projection.attention.requiresAction).toBe(true) - }) - - it('leaves an unsettled legacy dispatch genuinely unknown', async () => { - h.setup() - const dispatched = createLegacyDispatch('dispatched') - - const worker = (await listWorkers()).get(dispatched)! - - expect(worker.projection.outcome).toBe('in_progress') - expect(worker.projection.liveness).toEqual({ - verdict: 'unverifiable', - reason: 'missing_status' - }) - expect(worker.projection.attention.categories).toContain('unverifiable') - expect(worker.projection.attention.requiresAction).toBe(true) - expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts deleted file mode 100644 index bd5becb2a06..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -// A coordinator that records context against its own terminal delegated nothing, so the row it -// leaves behind must not read back as the coordinator's own parent Attempt. -describe('context-only self-dispatch and nesting depth', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - async function selfDispatch(): Promise { - const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_coord' - })) as { dispatch: { id: string } } - return result.dispatch.id - } - - it('leaves the coordinator able to start a worker', async () => { - const selfDispatchId = await selfDispatch() - expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({ - creator_handle: 'term_coord', - creator_pane_key: harness.coordinatorPaneKey - }) - - const started = await harness.startWorker({ terminal: 'term_worker' }) - - expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({ - depth: 1, - creator_dispatch_id: null - }) - }) - - it('still counts a real assignment to another pane as a nesting parent', async () => { - const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId }) - const delegated = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { id: string } } - - expect( - harness.db.resolveCreatorDepth({ - kind: 'terminal', - handle: 'term_worker', - paneKey: harness.workerPaneKey - }) - ).toBe(1) - expect( - harness.db.resolveCreatorDispatchId({ - kind: 'terminal', - handle: 'term_worker', - paneKey: harness.workerPaneKey - }) - ).toBe(delegated.dispatch.id) - }) - - it('reports the self-dispatching coordinator as a root', async () => { - await selfDispatch() - - const creator = { - kind: 'terminal', - handle: 'term_coord', - paneKey: harness.coordinatorPaneKey - } as const - expect(harness.db.resolveCreatorDepth(creator)).toBe(0) - expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull() - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts deleted file mode 100644 index 434b0ee9788..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -const PTY_ID = 'pty_worker' - -// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the -// launched agent found in front, or the shell runs it. -describe('a worker start writes its brief only into the agent it launched', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - function launchedPane(foreground: LaunchedAgentForeground): string[] { - h.setup() - const writes: string[] = [] - vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) - vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true) - vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {}) - vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation( - async (handle, text, options) => { - await options?.beforeWrite?.(PTY_ID) - writes.push(text) - return { handle, accepted: true, bytesWritten: text.length } - } - ) - return writes - } - - it('types nothing when the agent exited and its shell is in front', async () => { - const writes = launchedPane('shell') - - await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow() - expect(writes).toEqual([]) - }) - - it('writes the brief once into the agent found in front', async () => { - const writes = launchedPane('agent') - - await h.startWorker({ agent: 'claude' }) - expect(writes).toHaveLength(1) - }) - - it('leaves a terminal the caller supplied to its own idle wait', async () => { - const writes = launchedPane('shell') - - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled() - expect(writes).toHaveLength(1) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts index d37d0b04a59..06db0da17d8 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' @@ -60,3 +61,282 @@ describe('orchestration worker-list Run scope (runtime)', () => { ) }) }) + +// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition +// used to reject it and its abandoned Task had no documented route back. +describe('worker-start --retry-of a context-only Dispatch', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + async function dispatchContextOnly( + spec: string + ): Promise<{ taskId: string; dispatchId: string }> { + const task = harness.db.createTask({ spec, runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { id: string } } + expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined() + return { taskId: task.id, dispatchId: result.dispatch.id } + } + + it('restarts the Task after the attempt is abandoned', async () => { + const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt') + + await expect( + harness.call('orchestration.workerAbandon', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) + expect(harness.db.getTask(taskId)?.status).toBe('blocked') + + const retried = (await harness.call('orchestration.workerStart', { + task: taskId, + from: 'term_coord', + terminal: 'term_worker', + retryOf: dispatchId + })) as { dispatchId: string; state: string } + + expect(retried.state).toBe('ready') + expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe( + dispatchId + ) + expect(harness.db.getTask(taskId)?.status).toBe('dispatched') + }) + + it('still refuses to retry an attempt that has not settled', async () => { + const { taskId, dispatchId } = await dispatchContextOnly('live attempt') + + await expect( + harness.call('orchestration.workerStart', { + task: taskId, + from: 'term_coord', + terminal: 'term_worker', + retryOf: dispatchId + }) + ).rejects.toMatchObject({ code: 'task_not_startable' }) + }) +}) + +// A coordinator that records context against its own terminal delegated nothing, so the row it +// leaves behind must not read back as the coordinator's own parent Attempt. +describe('context-only self-dispatch and nesting depth', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + async function selfDispatch(): Promise { + const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_coord' + })) as { dispatch: { id: string } } + return result.dispatch.id + } + + it('leaves the coordinator able to start a worker', async () => { + const selfDispatchId = await selfDispatch() + expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({ + creator_handle: 'term_coord', + creator_pane_key: harness.coordinatorPaneKey + }) + + const started = await harness.startWorker({ terminal: 'term_worker' }) + + expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({ + depth: 1, + creator_dispatch_id: null + }) + }) + + it('still counts a real assignment to another pane as a nesting parent', async () => { + const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId }) + const delegated = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { id: string } } + + expect( + harness.db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_worker', + paneKey: harness.workerPaneKey + }) + ).toBe(1) + expect( + harness.db.resolveCreatorDispatchId({ + kind: 'terminal', + handle: 'term_worker', + paneKey: harness.workerPaneKey + }) + ).toBe(delegated.dispatch.id) + }) + + it('reports the self-dispatching coordinator as a root', async () => { + await selfDispatch() + + const creator = { + kind: 'terminal', + handle: 'term_coord', + paneKey: harness.coordinatorPaneKey + } as const + expect(harness.db.resolveCreatorDepth(creator)).toBe(0) + expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull() + }) +}) + +type ReadWithProjection = { projection?: OrchestrationFleetWorker | null } + +describe('orchestration worker-read fleet projection', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => { + h.setup() + const { dispatchId } = await h.startWorker() + + const read = (await h.call('orchestration.workerRead', { + dispatch: dispatchId + })) as ReadWithProjection & { status: { liveness?: string } } + + expect(read.projection?.dispatchId).toBe(dispatchId) + // The agent verdict is not the PTY verdict; worker-read must carry both. + expect(read.status.liveness).toBe('live') + expect(read.projection?.liveness.verdict).toBe('unverifiable') + }) + + it('carries the projection on an archived read after release', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + await h.call('orchestration.workerRelease', { dispatch: dispatchId }) + + const read = (await h.call('orchestration.workerRead', { + dispatch: dispatchId + })) as ReadWithProjection + + expect(read.projection?.dispatchId).toBe(dispatchId) + expect(read.projection?.liveness.verdict).toBe('exited') + }) +}) + +type ListedWorker = { + dispatchId: string + workerState: string + dispatchStatus: string + projection: { + outcome: string + liveness: { verdict: string; reason?: string } + nextAction: { kind: string; argv: string[] } + attention: { categories: string[]; requiresAction: boolean } + } +} + +describe('pre-v3 dispatch rows in worker-list', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + /** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no + * worker_dispatches row, which is what every dispatch made before supervised workers looks like. */ + function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string { + const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId }) + const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`) + if (status === 'completed') { + h.db.completeDispatch(dispatch.id) + } + if (status === 'failed') { + h.db.failDispatch(dispatch.id, 'legacy failure') + } + return dispatch.id + } + + async function listWorkers(): Promise> { + const listed = (await h.call('orchestration.workerList', { + paginate: true, + run: h.activeRunId + })) as { workers: ListedWorker[] } + return new Map(listed.workers.map((worker) => [worker.dispatchId, worker])) + } + + it('projects a settled legacy dispatch as settled with nothing to act on', async () => { + h.setup() + const completed = createLegacyDispatch('completed') + + const worker = (await listWorkers()).get(completed)! + + expect(worker.workerState).toBe('unsupervised') + expect(worker.dispatchStatus).toBe('completed') + // `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded` + // report or a task completion, so the durable record is the whole settlement. + expect(worker.projection.outcome).toBe('succeeded') + // Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch + // that never had a worker row has no process whose absence could require action. + expect(worker.projection.liveness).toEqual({ + verdict: 'unverifiable', + reason: 'unsupervised_settled' + }) + expect(worker.projection.attention.categories).not.toContain('unverifiable') + expect(worker.projection.attention.requiresAction).toBe(false) + expect(worker.projection.nextAction.kind).toBe('none') + }) + + it.each(['completed', 'failed'] as const)( + 'closes a pending question when a legacy dispatch settles as %s', + async (status) => { + h.setup() + const task = h.db.createTask({ spec: `legacy ${status} with question`, runId: h.activeRunId }) + const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`) + const asked = h.db.createQuestion({ + runId: h.activeRunId, + dispatchId: dispatch.id, + askerHandle: `term_legacy_q_${status}`, + question: 'Which branch?' + }) + // Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch. + if (status === 'completed') { + h.db.updateTaskStatus(task.id, 'completed', 'done') + } else { + h.db.failDispatch(dispatch.id, 'legacy failure') + } + + const worker = (await listWorkers()).get(dispatch.id)! + + expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed') + expect(worker.dispatchStatus).toBe(status) + expect(worker.projection.attention.categories).not.toContain('input') + // Nothing can answer a question on a settled Dispatch, so `input` must not outlive it. + expect(worker.projection.attention.requiresAction).toBe(status === 'failed') + } + ) + + it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => { + h.setup() + const failed = createLegacyDispatch('failed') + + const worker = (await listWorkers()).get(failed)! + + expect(worker.dispatchStatus).toBe('failed') + expect(worker.projection.outcome).toBe('failed') + expect(worker.projection.attention.categories).toEqual(['failure']) + expect(worker.projection.attention.requiresAction).toBe(true) + }) + + it('leaves an unsettled legacy dispatch genuinely unknown', async () => { + h.setup() + const dispatched = createLegacyDispatch('dispatched') + + const worker = (await listWorkers()).get(dispatched)! + + expect(worker.projection.outcome).toBe('in_progress') + expect(worker.projection.liveness).toEqual({ + verdict: 'unverifiable', + reason: 'missing_status' + }) + expect(worker.projection.attention.categories).toContain('unverifiable') + expect(worker.projection.attention.requiresAction).toBe(true) + expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts deleted file mode 100644 index 3ae9c0c44d0..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -type ReadWithProjection = { projection?: OrchestrationFleetWorker | null } - -describe('orchestration worker-read fleet projection', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => { - h.setup() - const { dispatchId } = await h.startWorker() - - const read = (await h.call('orchestration.workerRead', { - dispatch: dispatchId - })) as ReadWithProjection & { status: { liveness?: string } } - - expect(read.projection?.dispatchId).toBe(dispatchId) - // The agent verdict is not the PTY verdict; worker-read must carry both. - expect(read.status.liveness).toBe('live') - expect(read.projection?.liveness.verdict).toBe('unverifiable') - }) - - it('carries the projection on an archived read after release', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - await h.call('orchestration.workerRelease', { dispatch: dispatchId }) - - const read = (await h.call('orchestration.workerRead', { - dispatch: dispatchId - })) as ReadWithProjection - - expect(read.projection?.dispatchId).toBe(dispatchId) - expect(read.projection?.liveness.verdict).toBe('exited') - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts index 9a6a399cfb1..7904c8e0472 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' +import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('workerRelease on a retained resource whose process exited', () => { @@ -102,3 +104,101 @@ describe('workerRelease on a retained resource whose process exited', () => { ) }) }) + +const READY_WAIT = { + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null +} satisfies RuntimeTerminalWait + +describe('Antigravity orchestration worker lifecycle', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { + h.setup() + const readiness = h.deferred() + vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) + + const pending = h.startWorker({ agent: 'antigravity' }) + await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) + + expect(h.runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) + ) + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ + ownership_state: 'owned', + terminal_handle: 'term_worker' + }) + + readiness.resolve(READY_WAIT) + await expect(pending).resolves.toEqual( + expect.objectContaining({ dispatchId: expect.any(String) }) + ) + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + }) + + it('stops only the owned AGY terminal', async () => { + h.setup() + const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) + + await expect( + h.call('orchestration.workerStop', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) + + it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'releasing' + }) + + await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ + attempted: 1, + released: 1 + }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + release_state: 'released' + }) + expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) + expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') + }) + + it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_unknown' }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'unknown' + }) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts index b2627bd13d3..f6742cea93e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('worker-start --terminal target', () => { @@ -157,3 +159,101 @@ describe('orchestration.dispatch --to the caller', () => { expect(result.dispatch.assignee_pane_key).toBe(harness.workerPaneKey) }) }) + +const PTY_ID = 'pty_worker' + +// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the +// launched agent found in front, or the shell runs it. +describe('a worker start writes its brief only into the agent it launched', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + function launchedPane(foreground: LaunchedAgentForeground): string[] { + h.setup() + const writes: string[] = [] + vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) + vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true) + vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {}) + vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation( + async (handle, text, options) => { + await options?.beforeWrite?.(PTY_ID) + writes.push(text) + return { handle, accepted: true, bytesWritten: text.length } + } + ) + return writes + } + + it('types nothing when the agent exited and its shell is in front', async () => { + const writes = launchedPane('shell') + + await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow() + expect(writes).toEqual([]) + }) + + it('writes the brief once into the agent found in front', async () => { + const writes = launchedPane('agent') + + await h.startWorker({ agent: 'claude' }) + expect(writes).toHaveLength(1) + }) + + it('leaves a terminal the caller supplied to its own idle wait', async () => { + const writes = launchedPane('shell') + + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled() + expect(writes).toHaveLength(1) + }) +}) + +describe('composer-marker first dispatch readiness', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for the new composer before dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) + ) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) + + it('keeps reused terminals on the normal idle wait', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle' }) + ) + }) + + it('never delivers a task after a startup timeout', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) + await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts deleted file mode 100644 index 79df47af820..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -describe('composer-marker first dispatch readiness', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - it.each(['zcode', 'opencode', 'opencode2'] as const)( - '%s waits for the new composer before dispatch', - async (agent) => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - agent, - 60_000 - ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve({ - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null - }) - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - } - ) - - it('keeps reused terminals on the normal idle wait', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() - expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( - 'term_worker', - expect.objectContaining({ condition: 'tui-idle' }) - ) - }) - - it('never delivers a task after a startup timeout', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) - await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/screen-ruled-readiness-scope.test.ts b/src/main/runtime/screen-ruled-readiness-scope.test.ts deleted file mode 100644 index af3fd48ae44..00000000000 --- a/src/main/runtime/screen-ruled-readiness-scope.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { readRuntimeFixture } from './agent-transcript-replay-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Why: the grid-trust check and raw rows belong to screen-ruled agents; every other agent must -// keep reading the screen exactly as it did before them. -describe('screen-rule trust stays with screen-ruled agents', () => { - it('a Codex pane still settles from its screen when the PTY reports another grid', async () => { - const options = { - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex' as const, - // Only the live screen shows this recording's header (STA-8628): the text reads `dirctory:`. - data: readRuntimeFixture('codex-0157-effort-override-embedded-warning'), - size: { cols: 120, rows: 40 } - } - const { runtime, handle } = await createTranscriptPane(options) - await runtime.readTerminal(handle, { screen: true }) - options.size = { cols: 100, rows: 30 } - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 20_000) -}) diff --git a/src/main/runtime/stale-title-display-clear.test.ts b/src/main/runtime/stale-title-display-clear.test.ts deleted file mode 100644 index 3a5c169573c..00000000000 --- a/src/main/runtime/stale-title-display-clear.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { clearWorkingIndicators } from '../../shared/agent-title-status' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Synthetic protocol cases: they test which title readiness, display and presence each read, -// not what a particular CLI paints. The 3 s timer is the stale-working-title clear. -describe('the stale-working title clear is display-only', () => { - it.each([ - ['claude', '. claude', '* claude'], - ['codex', '⠋ Codex working', 'Codex ready'], - ['gemini', '✦ Gemini CLI', '◇ Gemini CLI'], - ['opencode', '⠋ OC | task', 'OC | task'], - ['pi', 'π : project', 'π - project'] - ] as const)( - '%s does not become ready when its display title clears', - async (agent, busy, idle) => { - vi.useFakeTimers() - try { - const titleChanges: string[] = [] - const { runtime, handle } = await createTranscriptPane( - { - paneTitle: busy, - foregroundProcess: agent, - data: '', - launchAgent: agent - }, - { - onTerminalSideEffects: (batch) => { - for (const fact of batch.facts) { - if (fact.kind === 'title') { - titleChanges.push(fact.normalizedTitle) - } - } - } - } - ) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${busy}\x07`, Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - expect(titleChanges.at(-1)).toBe(clearWorkingIndicators(busy)) - // A renderer graph publish can carry the display-only cleared title back to main. - runtime.syncWindowGraph(1, { - tabs: [ - { - tabId: 'tab-1', - worktreeId: 'wt-1', - title: clearWorkingIndicators(busy), - activeLeafId: '11111111-1111-4111-8111-111111111111', - layout: null - } - ], - leaves: [ - { - tabId: 'tab-1', - worktreeId: 'wt-1', - leafId: '11111111-1111-4111-8111-111111111111', - paneRuntimeId: 1, - ptyId: TRANSCRIPT_PANE_PTY_ID, - paneTitle: clearWorkingIndicators(busy) - } - ] - }) - const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) - const assertion = expect(waiting).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(200)]) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${idle}\x07`, Date.now()) - const ready = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) - const readyAssertion = expect(ready).resolves.toMatchObject({ satisfied: true }) - await Promise.all([readyAssertion, vi.advanceTimersByTimeAsync(2_000)]) - } finally { - vi.useRealTimers() - } - } - ) - - // A name-shaped title (`⠋ Codex working`) clears to `Codex`, which still names an agent, as on main. - it.each([['⠋ repo']])( - 'an agent that exited behind %s does not pass for a running agent', - async (title) => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'zsh', - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${title}\x07`, Date.now()) - // The agent exits and the shell prints its prompt: output, but no title. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false, - status: null - }) - await expect( - runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) - ).resolves.toBe(false) - } finally { - vi.useRealTimers() - } - } - ) - - it('a live agent behind a cleared title is still found by its process', async () => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: null - }) - await expect( - runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) - ).resolves.toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it("the agent's next genuine title proves presence again", async () => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: null, - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠙ repo\x07', Date.now()) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'working' - }) - } finally { - vi.useRealTimers() - } - }) - - it('a live Cursor spinner title proves presence when no foreground read can answer', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: null, - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'working' - }) - }) -}) diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 84d7e204263..47104705d10 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -1,5 +1,3 @@ -// A worker parked on an interactive prompt must be distinguishable from one that is thinking -// or inside a long tool call (STA-4513, STA-3714). import { readFileSync } from 'node:fs' import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { join } from 'node:path' @@ -8,9 +6,11 @@ import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from './orca-runtime-core' import { createTranscriptPane, type TranscriptPaneOptions, - TRANSCRIPT_PANE_PTY_ID as PTY_ID + TRANSCRIPT_PANE_PTY_ID as PTY_ID, + waitForTranscriptIdle } from './agent-transcript-pane-test-harness' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' +import { readRuntimeFixture } from './agent-transcript-replay-test-harness' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -19,207 +19,319 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no -// escape bytes and no carriage returns, so they came through a terminal's renderer and a -// clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on — -// and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer. -// Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes. -function fixture(name: string): string { - return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') -} -const CURSOR_APPROVAL = fixture('cursor-agent-approval-prompt') -const CURSOR_LONG_TOOL_CALL = fixture('cursor-agent-long-tool-call') -const CURSOR_IDLE = fixture('cursor-agent-idle-after-approval') +describe('Interactive wait visibility and terminal admission', () => { + // A worker parked on an interactive prompt must be distinguishable from one that is thinking + // or inside a long tool call (STA-4513, STA-3714). -// Claude Code 2.1.234's own trust screen, which the runtime already matched by shape. -const CLAUDE_TRUST = [ - 'Accessing workspace:\n', - '/private/tmp/repo\n', - 'Quick safety check: Is this a project you created or one you trust?\n', - '❯ 1. Yes, I trust this folder\n', - ' 2. No, exit\n' -].join('') + // cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no + // escape bytes and no carriage returns, so they came through a terminal's renderer and a + // clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on — + // and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer. + // Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes. + function fixture(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') + } + const CURSOR_APPROVAL = fixture('cursor-agent-approval-prompt') + const CURSOR_LONG_TOOL_CALL = fixture('cursor-agent-long-tool-call') + const CURSOR_IDLE = fixture('cursor-agent-idle-after-approval') -function agentStatusOsc(state: string): string { - return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` -} + // Claude Code 2.1.234's own trust screen, which the runtime already matched by shape. + const CLAUDE_TRUST = [ + 'Accessing workspace:\n', + '/private/tmp/repo\n', + 'Quick safety check: Is this a project you created or one you trust?\n', + '❯ 1. Yes, I trust this folder\n', + ' 2. No, exit\n' + ].join('') -async function createPane( - options: TranscriptPaneOptions -): Promise>> { - // Compose the same central hook-store wiring as desktop and orcad so OSC rows exercise the - // production status path rather than silently disappearing in a bare runtime fixture. - const statusWiring = makeAgentStatusStoreWiring() - return createTranscriptPane(options, statusWiring.deps) -} + function agentStatusOsc(state: string): string { + return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` + } -// cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads -// that as `working`; the title is identical whether it is running a command or waiting. -const CURSOR_TITLE = '⠇ Cursor Agent' + async function createPane( + options: TranscriptPaneOptions + ): Promise>> { + // Compose the same central hook-store wiring as desktop and orcad so OSC rows exercise the + // production status path rather than silently disappearing in a bare runtime fixture. + const statusWiring = makeAgentStatusStoreWiring() + return createTranscriptPane(options, statusWiring.deps) + } -afterEach(() => vi.useRealTimers()) + // cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads + // that as `working`; the title is identical whether it is running a command or waiting. + const CURSOR_TITLE = '⠇ Cursor Agent' -describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { - describe('cursor-agent approval menu, the case no hook reports', () => { - it('names the pending approval on the pane a coordinator inspects', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL + afterEach(() => vi.useRealTimers()) + + describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { + describe('cursor-agent approval menu, the case no hook reports', () => { + it('names the pending approval on the pane a coordinator inspects', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt' } + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt', + since: expect.any(Number) + }) }) - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt' } + it('refuses a guarded send and a dispatch preamble while the approval is up', async () => { + // Why this matters beyond reporting: the coordinator's preamble was typed straight into + // the approval dialog instead of being refused (the STA-2631 shape). Both refusal paths + // are asserted here, not just the `permission` verdict they read. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).rejects.toThrow('terminal_guard_permission') + await expect( + runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'prompt-text', - reason: 'agent-approval-prompt', - since: expect.any(Number) + + it('lets a dispatch preamble through once the same lane is working', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_LONG_TOOL_CALL + }) + + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).resolves.toBeUndefined() + }) + + it('refuses to call the lane idle while the approval is unanswered', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 400 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-approval-prompt' }) + }) + + it('reports no wait for the same agent inside a long tool call', async () => { + // The control: identical vendor, identical spinner title, a real `sleep 60` running. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_LONG_TOOL_CALL + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ agentWait: null }) + }) + + it('reports no wait once the agent is idle again', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_IDLE + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('treats an answered menu still in scrollback as scrollback', async () => { + // Why: the menu is not erased on every terminal, so a live dialog is one that still owns + // the bottom of the screen rather than one that merely appears somewhere in the tail. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\n${CURSOR_IDLE}` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('treats an answered menu followed by any later output as scrollback', async () => { + // Why not keyed on cursor's own follow-up line: output after the menu can be anything, + // and a stale hit here fails tui-idle and refuses prompt injection on a healthy lane. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() + + runtime.onPtyData( + PTY_ID, + '\nCommand completed successfully.\nContinuing automatically.\n', + Date.now() + ) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('is not anchored by the agent narrating a choice after the menu', async () => { + // Why: matching each marker independently let prose below the answered menu carry the + // anchor down to the bottom of the screen and revive it. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\nApproved. Next time I will suggest Run Everything instead.\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('is not anchored by the agent narrating a choice before the menu', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `You can pick Run Everything or Run (once) if you prefer.\nStill thinking.\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('requires the dialog to be the last thing on the screen', async () => { + // Not a tolerance question: one line of slack is exactly enough for the agent's own + // narration to revive an answered menu, and every capture of a live dialog ends on it. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\n Auto · 6.1%\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('reports the same wait for a pane whose PTY is not local', async () => { + // The verdict is derived from retained tail and title state, so an SSH or WSL pane + // reaches it the same way a local one does. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL, + connectionId: 'ssh:build-host' + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt' + }) + }) + + it('ignores a partial menu that names no decision keys', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: 'The agent asked: Run this command? I said yes and it worked.\n' + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) }) - it('refuses a guarded send and a dispatch preamble while the approval is up', async () => { - // Why this matters beyond reporting: the coordinator's preamble was typed straight into - // the approval dialog instead of being refused (the STA-2631 shape). Both refusal paths - // are asserted here, not just the `permission` verdict they read. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL + describe('prompts the runtime already matched but never surfaced', () => { + it('surfaces a startup trust screen on the pane, not only on terminal wait', async () => { + // A pane on its trust screen still wears Orca's tab title; the agent has set none. + const { runtime, handle } = await createPane({ + paneTitle: 'sta4513-claude', + foregroundProcess: 'claude', + data: CLAUDE_TRUST + }) + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' } + }) }) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'permission' + it('still lets a live working title clear a stale startup prompt', async () => { + // Pins the shared authority getTerminalAgentStatus and the agent-prompt send guard + // already use: for the startup modals, a live non-permission title is the staleness + // proof, because their text survives in scrollback with no self-dismissal marker. + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: CLAUDE_TRUST + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect( - assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) - ).rejects.toThrow('terminal_guard_permission') - await expect( - runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) - ).rejects.toThrow('agent_prompt_blocked') }) - it('lets a dispatch preamble through once the same lane is working', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_LONG_TOOL_CALL + describe('hook-reported waits (STA-3714)', () => { + it('surfaces an agent-reported blocked state with hook provenance', async () => { + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('waiting') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'hook', + since: expect.any(Number) + }) + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'hook' } + }) }) - await expect( - assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) - ).resolves.toBeUndefined() + it('reports no wait for a hook-reported working turn', async () => { + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('working') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('drops a retained permission row once the agent stops owning the pane', async () => { + // Why not the title alone: a shell that takes the pane back usually sets something like + // `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for + // AGENT_STATUS_STALE_AFTER_MS — half an hour of reporting a dead agent as waiting. + const { runtime, handle } = await createPane({ + paneTitle: 'jinwoo@host: ~/repo', + foregroundProcess: 'zsh', + data: agentStatusOsc('waiting') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('drops a retained permission row once a shell owns the pane', async () => { + const { runtime, handle } = await createPane({ + paneTitle: 'zsh', + foregroundProcess: 'zsh', + data: agentStatusOsc('blocked') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) }) - it('refuses to call the lane idle while the approval is unanswered', async () => { + it('still reports a live dialog restored from terminal history', async () => { + // Why this case exists: a lane parked on a prompt emits no bytes, so an Orca restart is + // exactly when it would go quiet forever. A restored tail carries no waitBlockedAt, and + // the approval menu does not need one — being at the bottom of the restored screen is + // itself the proof that this is where the pane stopped. const { runtime, handle } = await createPane({ paneTitle: CURSOR_TITLE, foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL - }) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 400 }) - ).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-approval-prompt' }) - }) - - it('reports no wait for the same agent inside a long tool call', async () => { - // The control: identical vendor, identical spinner title, a real `sleep 60` running. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_LONG_TOOL_CALL - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ agentWait: null }) - }) - - it('reports no wait once the agent is idle again', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_IDLE - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('treats an answered menu still in scrollback as scrollback', async () => { - // Why: the menu is not erased on every terminal, so a live dialog is one that still owns - // the bottom of the screen rather than one that merely appears somewhere in the tail. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\n${CURSOR_IDLE}` - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('treats an answered menu followed by any later output as scrollback', async () => { - // Why not keyed on cursor's own follow-up line: output after the menu can be anything, - // and a stale hit here fails tui-idle and refuses prompt injection on a healthy lane. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL - }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() - - runtime.onPtyData( - PTY_ID, - '\nCommand completed successfully.\nContinuing automatically.\n', - Date.now() - ) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('is not anchored by the agent narrating a choice after the menu', async () => { - // Why: matching each marker independently let prose below the answered menu carry the - // anchor down to the bottom of the screen and revive it. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\nApproved. Next time I will suggest Run Everything instead.\n` - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('is not anchored by the agent narrating a choice before the menu', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `You can pick Run Everything or Run (once) if you prefer.\nStill thinking.\n` - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('requires the dialog to be the last thing on the screen', async () => { - // Not a tolerance question: one line of slack is exactly enough for the agent's own - // narration to revive an answered menu, and every capture of a live dialog ends on it. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\n Auto · 6.1%\n` - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('reports the same wait for a pane whose PTY is not local', async () => { - // The verdict is derived from retained tail and title state, so an SSH or WSL pane - // reaches it the same way a local one does. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL, - connectionId: 'ssh:build-host' + data: '' }) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CURSOR_APPROVAL, lastTitle: CURSOR_TITLE }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ source: 'prompt-text', @@ -227,232 +339,151 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { }) }) - it('ignores a partial menu that names no decision keys', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: 'The agent asked: Run this command? I said yes and it worked.\n' - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - }) - - describe('prompts the runtime already matched but never surfaced', () => { - it('surfaces a startup trust screen on the pane, not only on terminal wait', async () => { - // A pane on its trust screen still wears Orca's tab title; the agent has set none. + it('does not resurrect a startup modal from a restored tail', async () => { + // The startup prompts keep the timestamp rule: their text lingers in scrollback with no + // marker for whether it was answered, so restored bytes alone must not mint a wait. const { runtime, handle } = await createPane({ paneTitle: 'sta4513-claude', foregroundProcess: 'claude', - data: CLAUDE_TRUST - }) - - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' } - }) - }) - - it('still lets a live working title clear a stale startup prompt', async () => { - // Pins the shared authority getTerminalAgentStatus and the agent-prompt send guard - // already use: for the startup modals, a live non-permission title is the staleness - // proof, because their text survives in scrollback with no self-dismissal marker. - const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: CLAUDE_TRUST + data: '' }) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CLAUDE_TRUST }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - }) - describe('hook-reported waits (STA-3714)', () => { - it('surfaces an agent-reported blocked state with hook provenance', async () => { + it('stops reporting a wait once the pane is no longer running', async () => { + // Why: the menu stays at the bottom of a dead pane's tail forever. A worker whose process + // is gone needs intervention, not an answer, so it must not read as blocked on a human. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() + + runtime.onPtyExit(PTY_ID, 0) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() + }) + + it('does not accrue a probe per poll while the foreground probe wedges', async () => { + // Why: the timeout abandons the wait, not the request. Without single-flighting, a + // coordinator watching a wedged remote host adds one live probe on every poll. + let probes = 0 const { runtime, handle } = await createPane({ paneTitle: '✻ Claude Code', foregroundProcess: 'claude', - data: agentStatusOsc('waiting') + data: agentStatusOsc('waiting'), + onForegroundProbe: () => { + probes += 1 + }, + foregroundProbeHangs: true + }) + + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const probing = Promise.all([ + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle) + ]) + void probing.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + await probing + + expect(probes).toBe(1) + } finally { + vi.useRealTimers() + } + }, 20_000) + + it('accepts a menu whose keys are rendered as glyphs', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: [ + 'Run this command?\n', + ' → Run (once) (\u21b5)\n', + ' Run Everything (\u21e7\u21b9)\n' + ].join('') }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'hook', - since: expect.any(Number) - }) - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'hook' } + reason: 'agent-approval-prompt' }) }) - it('reports no wait for a hook-reported working turn', async () => { + it('still rejects prose that merely ends in parentheses', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\nNext time I will suggest Run Everything (as before)\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('leaves the wait unevaluated when the foreground probe wedges', async () => { + // Why bounded: this probe reaches a PTY controller that can be a remote host. A wedged + // one must leave the wait unknown, not stall every caller of showTerminal. const { runtime, handle } = await createPane({ paneTitle: '✻ Claude Code', foregroundProcess: 'claude', - data: agentStatusOsc('working') + data: agentStatusOsc('waiting'), + foregroundProbeHangs: true }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const waiting = runtime.getTerminalInteractiveWait(handle) + void waiting.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + await expect(waiting).resolves.toBeUndefined() + const showing = runtime.showTerminal(handle) + void showing.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + const show = await showing + expect('agentWait' in show).toBe(false) + } finally { + vi.useRealTimers() + } + }, 15_000) - it('drops a retained permission row once the agent stops owning the pane', async () => { - // Why not the title alone: a shell that takes the pane back usually sets something like - // `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for - // AGENT_STATUS_STALE_AFTER_MS — half an hour of reporting a dead agent as waiting. - const { runtime, handle } = await createPane({ - paneTitle: 'jinwoo@host: ~/repo', - foregroundProcess: 'zsh', - data: agentStatusOsc('waiting') + it('answers undefined rather than "not waiting" for a pane it cannot read', async () => { + const { runtime } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + await expect( + runtime.getTerminalInteractiveWait('term_does_not_exist') + ).resolves.toBeUndefined() }) - - it('drops a retained permission row once a shell owns the pane', async () => { - const { runtime, handle } = await createPane({ - paneTitle: 'zsh', - foregroundProcess: 'zsh', - data: agentStatusOsc('blocked') + }) +}) + +describe('Screen rule readiness scope', () => { + // Why: the grid-trust check and raw rows belong to screen-ruled agents; every other agent must + // keep reading the screen exactly as it did before them. + describe('screen-rule trust stays with screen-ruled agents', () => { + it('a Codex pane still settles from its screen when the PTY reports another grid', async () => { + const options = { + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex' as const, + // Only the live screen shows this recording's header (STA-8628): the text reads `dirctory:`. + data: readRuntimeFixture('codex-0157-effort-override-embedded-warning'), + size: { cols: 120, rows: 40 } + } + const { runtime, handle } = await createTranscriptPane(options) + await runtime.readTerminal(handle, { screen: true }) + options.size = { cols: 100, rows: 30 } + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - }) - - it('still reports a live dialog restored from terminal history', async () => { - // Why this case exists: a lane parked on a prompt emits no bytes, so an Orca restart is - // exactly when it would go quiet forever. A restored tail carries no waitBlockedAt, and - // the approval menu does not need one — being at the bottom of the restored screen is - // itself the proof that this is where the pane stopped. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: '' - }) - runtime.seedTerminalRestoreTail(PTY_ID, { text: CURSOR_APPROVAL, lastTitle: CURSOR_TITLE }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'prompt-text', - reason: 'agent-approval-prompt' - }) - }) - - it('does not resurrect a startup modal from a restored tail', async () => { - // The startup prompts keep the timestamp rule: their text lingers in scrollback with no - // marker for whether it was answered, so restored bytes alone must not mint a wait. - const { runtime, handle } = await createPane({ - paneTitle: 'sta4513-claude', - foregroundProcess: 'claude', - data: '' - }) - runtime.seedTerminalRestoreTail(PTY_ID, { text: CLAUDE_TRUST }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('stops reporting a wait once the pane is no longer running', async () => { - // Why: the menu stays at the bottom of a dead pane's tail forever. A worker whose process - // is gone needs intervention, not an answer, so it must not read as blocked on a human. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL - }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() - - runtime.onPtyExit(PTY_ID, 0) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() - }) - - it('does not accrue a probe per poll while the foreground probe wedges', async () => { - // Why: the timeout abandons the wait, not the request. Without single-flighting, a - // coordinator watching a wedged remote host adds one live probe on every poll. - let probes = 0 - const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('waiting'), - onForegroundProbe: () => { - probes += 1 - }, - foregroundProbeHangs: true - }) - - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - try { - const probing = Promise.all([ - runtime.getTerminalInteractiveWait(handle), - runtime.getTerminalInteractiveWait(handle), - runtime.getTerminalInteractiveWait(handle) - ]) - void probing.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - await probing - - expect(probes).toBe(1) - } finally { - vi.useRealTimers() - } - }, 20_000) - - it('accepts a menu whose keys are rendered as glyphs', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: [ - 'Run this command?\n', - ' → Run (once) (\u21b5)\n', - ' Run Everything (\u21e7\u21b9)\n' - ].join('') - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - reason: 'agent-approval-prompt' - }) - }) - - it('still rejects prose that merely ends in parentheses', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\nNext time I will suggest Run Everything (as before)\n` - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('leaves the wait unevaluated when the foreground probe wedges', async () => { - // Why bounded: this probe reaches a PTY controller that can be a remote host. A wedged - // one must leave the wait unknown, not stall every caller of showTerminal. - const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('waiting'), - foregroundProbeHangs: true - }) - - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - try { - const waiting = runtime.getTerminalInteractiveWait(handle) - void waiting.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - await expect(waiting).resolves.toBeUndefined() - const showing = runtime.showTerminal(handle) - void showing.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - const show = await showing - expect('agentWait' in show).toBe(false) - } finally { - vi.useRealTimers() - } - }, 15_000) - - it('answers undefined rather than "not waiting" for a pane it cannot read', async () => { - const { runtime } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL - }) - - await expect(runtime.getTerminalInteractiveWait('term_does_not_exist')).resolves.toBeUndefined() + }, 20_000) }) }) diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts index fd9f35e8065..7a61cd34081 100644 --- a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -1,8 +1,16 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi, afterEach } from 'vitest' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { makePaneKey } from '../../shared/stable-pane-id' import type { TuiAgent } from '../../shared/tui-agent' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' import type { OrcaRuntimeService } from './orca-runtime' vi.mock('electron', () => ({ @@ -12,213 +20,403 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// The harness pane's identity (agent-transcript-pane-test-harness.ts). -const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') -const OTHER_PANE_KEY = makePaneKey('tab-9', '99999999-9999-4999-8999-999999999999') -// A Pi turn in progress: no ready sign the other lanes could settle on. -const BUSY_SCREEN = '⠋ Working...\r\n' -// A Codex turn in progress, which Codex's screen rules read as busy. -const CODEX_BUSY_SCREEN = '• Working (4s • esc to interrupt)\r\n' -// OpenCode 1.18's permission dialog, as the line tail keeps it after the prompt is answered. -const OPENCODE_PERMISSION_DIALOG = [ - '△ Permission required', - '# Shell command', - '$ echo hi', - ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', - '' -].join('\r\n') -// OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. -const OPENCODE_QUESTION = [ - ' ┃ Which color do you prefer?', - ' ┃ 1. Red', - ' ┃ 2. Blue', - ' ┃ 3. Type your own answer', - ' ┃ ↑↓ select enter submit esc dismiss', - '' -].join('\r\n') -// Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. -const WAIT_MS = 150 +describe('Hermes captured hook readiness', () => { + const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8') -function row(overrides: Partial = {}): AgentStatusIpcPayload { - const now = Date.now() - return { - paneKey: PANE_KEY, - connectionId: null, - state: 'done', - prompt: '', - agentType: 'pi', - receivedAt: now, - stateStartedAt: now, - ...overrides + /** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the + * same normalizer the hook relay uses — so a regression in the event mapping fails here too, + * not only in the provider's own unit test. */ + const status = (eventName: string, payload: Record = {}): string => { + const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload) + expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull() + return `\x1b]9999;${JSON.stringify(parsed)}\x07` } -} -async function waitOutcome(options: { - rows: (handle: string) => AgentStatusIpcPayload[] - launchAgent?: TuiAgent - data?: string - afterCreate?: (runtime: OrcaRuntimeService, handle: string) => unknown -}): Promise { - let handle = '' - const pane = await createTranscriptPane( - { - paneTitle: 'Terminal', - foregroundProcess: options.launchAgent ?? 'pi', - data: options.data ?? BUSY_SCREEN, - launchAgent: options.launchAgent ?? 'pi' - }, - { getAgentStatusSnapshot: () => options.rows(handle) } - ) - handle = pane.handle - await options.afterCreate?.(pane.runtime, pane.handle) - try { - const result = await pane.runtime.waitForTerminal(pane.handle, { - condition: 'tui-idle', - timeoutMs: WAIT_MS + const readyPane = async () => + createTranscriptPane({ + paneTitle: 'Hermes Agent', + foregroundProcess: 'hermes', + launchAgent: 'hermes', + size: { cols: 120, rows: 31 }, + data: captured }) - return result.blockedReason ? `blocked:${result.blockedReason}` : 'ready' - } catch (error) { - return error instanceof Error ? error.message : String(error) - } -} -afterEach(() => { - vi.restoreAllMocks() + describe('Hermes TUI readiness from a captured PTY', () => { + it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => { + const { runtime, handle } = await readyPane() + // What Orca's own managed plugin sends for `on_session_start`. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + + const read = vi.spyOn(runtime, 'readTerminal') + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + ).resolves.toMatchObject({ satisfied: true }) + // The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it. + expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([]) + }, 5_000) + + it('settles through a leaf handle whose retained tail never showed the ready screen', async () => { + const { runtime } = await readyPane() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression. + const internals = runtime as unknown as { + leaves: Map + issueHandle: (leaf: unknown) => string + } + const leaf = internals.leaves.values().next().value + expect(leaf).toBeDefined() + expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i) + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(internals.issueHandle(leaf), { + condition: 'tui-idle', + timeoutMs: 2_000 + }) + ).resolves.toMatchObject({ satisfied: true }) + }, 5_000) + + it('does not settle while a turn the user started is still running', async () => { + const { runtime, handle } = await readyPane() + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + // `pre_llm_call` — the first event that means a turn actually began. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('pre_llm_call', { user_message: 'hi' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + + it('does not settle on a turn-end done row, only on a session boundary', async () => { + const { runtime, handle } = await readyPane() + // `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('post_llm_call', { session_id: 's1' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + + it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => { + const { runtime, handle } = await readyPane() + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + // `pre_approval_request` supersedes the boundary row the session opened with. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('pre_approval_request', { command: 'rm -rf build' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + }) }) -describe('tui-idle hook lane through the runtime', () => { - it('settles a mid-turn screen on the hook row alone, with no ready title (headless serve)', async () => { - expect(await waitOutcome({ rows: () => [row()] })).toBe('ready') - }) +describe('Claude indexed task wakeup readiness', () => { + const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + const ready = readFileSync( + join(import.meta.dirname, '__fixtures__/claude-ready-task-wakeup.txt'), + 'utf8' + ) - it('without a row, the same pane does not settle', async () => { - expect(await waitOutcome({ rows: () => [] })).toBe('timeout') - }) - - it('joins a row on the terminal handle when its pane key is not the pane', async () => { - expect( - await waitOutcome({ - rows: (handle) => [row({ paneKey: OTHER_PANE_KEY, terminalHandle: handle })] - }) - ).toBe('ready') - }) - - it('falls back to the other lanes for a row no pane key or handle joins', async () => { - expect(await waitOutcome({ rows: () => [row({ paneKey: OTHER_PANE_KEY })] })).toBe('timeout') - }) - - it('ignores the row the PTY id held before a respawn', async () => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - expect(await waitOutcome({ rows })).toBe('ready') - expect( - await waitOutcome({ - rows, - afterCreate: (runtime) => - runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { - value: 0, - generation: 'reset' - }) - }) - ).toBe('timeout') - }) - - // Both write funnels record input (terminal-run-facts-input.test.ts), the user's keys included: - // typing `pi` to restart the agent in the same shell must not read the old process's done. - it.each([ - ['a prompt Orca sent', 'next task\r'], - ['the user restarting the agent in the same shell', 'pi\r'] - ])('reads no done from before %s', async (_label, input) => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { - runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) + function owedRow(paneKey: string): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey, + state: 'working', + mainAgent: { state: 'done', stateStartedAt: now }, + agentType: 'claude', + prompt: '', + connectionId: null, + launchToken: 'transcript-launch', + claudeTaskWakeupPending: 'notification', + providerSession: { key: 'session_id', id: 'session-a' }, + observation: { + origin: 'hook', + authorityId: 'host-a', + incarnation: 1, + revision: 1, + observedAt: now + }, + receivedAt: now, + stateStartedAt: now } - expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') - expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') - }) + } - it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - expect( - await waitOutcome({ - rows, - afterCreate: (runtime) => { - runtime.terminalRunFacts.recordInput( - TRANSCRIPT_PANE_PTY_ID, - 'driving', - 'next\r', - before + 1 - ) - runtime.noteTerminalSpawnCommit({ - id: TRANSCRIPT_PANE_PTY_ID, - agentSessionEnsure: { disposition: 'adopted' } - }) + afterEach(() => vi.useRealTimers()) + + describe('Claude readiness indexed status reads', () => { + it.each([true, false])( + 'reads only its pane with 512 unrelated agents, including an empty index: pending=%s', + async (pending) => { + const unrelated = Array.from({ length: 512 }, (_, index) => owedRow(`other:${index}`)) + const row = owedRow(PANE) + const full = vi.fn(() => [row, ...unrelated]) + const indexed = vi.fn((paneKey: string) => (pending && paneKey === PANE ? [row] : [])) + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: 'claude', + data: ready, + launchAgent: 'claude' + }, + { getAgentStatusSnapshot: full, getAgentStatusSnapshotForPane: indexed } + ) + row.receivedAt = Date.now() + full.mockClear() + indexed.mockClear() + try { + const waiting = waitForTranscriptIdle(pane, 2_000) + await (pending + ? expect(waiting).rejects.toThrow(/timeout/) + : expect(waiting).resolves.toMatchObject({ satisfied: true })) + expect(indexed).toHaveBeenCalled() + expect(new Set(indexed.mock.calls.map(([paneKey]) => paneKey))).toEqual(new Set([PANE])) + expect(full).not.toHaveBeenCalled() + } finally { + pane.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) } - }) - ).toBe('timeout') - }) - - // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. - it('keeps the row while the agent paints its own shell-integration markers', async () => { - expect( - await waitOutcome({ - rows: () => [row({ receivedAt: Date.now() - 1000 })], - data: `\x1b]133;A\x07OK\x1b]133;C\x07${BUSY_SCREEN}` - }) - ).toBe('ready') - }) - - // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so only its done - // decides; a working row leaves its title and screen rules to decide. - it('settles Codex on its done, and leaves a working row to its screen rules', async () => { - const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } - expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe('ready') - expect( - await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex', state: 'working' })] }) - ).toBe('timeout') - }) - - it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { - const options = { launchAgent: 'opencode' as const, data: OPENCODE_PERMISSION_DIALOG } - expect(await waitOutcome({ ...options, rows: () => [] })).toBe( - 'blocked:agent-interactive-prompt' + } ) - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) +}) + +describe('Canonical hook status readiness', () => { + // The harness pane's identity (agent-transcript-pane-test-harness.ts). + const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const OTHER_PANE_KEY = makePaneKey('tab-9', '99999999-9999-4999-8999-999999999999') + // A Pi turn in progress: no ready sign the other lanes could settle on. + const BUSY_SCREEN = '⠋ Working...\r\n' + // A Codex turn in progress, which Codex's screen rules read as busy. + const CODEX_BUSY_SCREEN = '• Working (4s • esc to interrupt)\r\n' + // OpenCode 1.18's permission dialog, as the line tail keeps it after the prompt is answered. + const OPENCODE_PERMISSION_DIALOG = [ + '△ Permission required', + '# Shell command', + '$ echo hi', + ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', + '' + ].join('\r\n') + // OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. + const OPENCODE_QUESTION = [ + ' ┃ Which color do you prefer?', + ' ┃ 1. Red', + ' ┃ 2. Blue', + ' ┃ 3. Type your own answer', + ' ┃ ↑↓ select enter submit esc dismiss', + '' + ].join('\r\n') + // Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. + const WAIT_MS = 150 + + function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'pi', + receivedAt: now, + stateStartedAt: now, + ...overrides + } + } + + async function waitOutcome(options: { + rows: (handle: string) => AgentStatusIpcPayload[] + launchAgent?: TuiAgent + data?: string + afterCreate?: (runtime: OrcaRuntimeService, handle: string) => unknown + }): Promise { + let handle = '' + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ?? 'pi', + data: options.data ?? BUSY_SCREEN, + launchAgent: options.launchAgent ?? 'pi' + }, + { getAgentStatusSnapshot: () => options.rows(handle) } + ) + handle = pane.handle + await options.afterCreate?.(pane.runtime, pane.handle) + try { + const result = await pane.runtime.waitForTerminal(pane.handle, { + condition: 'tui-idle', + timeoutMs: WAIT_MS }) - ).toBe('blocked:agent-interactive-prompt') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] - }) - ).toBe('ready') + return result.blockedReason ? `blocked:${result.blockedReason}` : 'ready' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + } + + afterEach(() => { + vi.restoreAllMocks() }) + + describe('tui-idle hook lane through the runtime', () => { + it('settles a mid-turn screen on the hook row alone, with no ready title (headless serve)', async () => { + expect(await waitOutcome({ rows: () => [row()] })).toBe('ready') + }) + + it('without a row, the same pane does not settle', async () => { + expect(await waitOutcome({ rows: () => [] })).toBe('timeout') + }) + + it('joins a row on the terminal handle when its pane key is not the pane', async () => { + expect( + await waitOutcome({ + rows: (handle) => [row({ paneKey: OTHER_PANE_KEY, terminalHandle: handle })] + }) + ).toBe('ready') + }) - it('blocks on an open question the hook reports, and settles once it is answered', async () => { - const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } - expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] - }) - ).toBe('blocked:agent-interactive-prompt') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] - }) - ).toBe('ready') + it('falls back to the other lanes for a row no pane key or handle joins', async () => { + expect(await waitOutcome({ rows: () => [row({ paneKey: OTHER_PANE_KEY })] })).toBe('timeout') + }) + + it('ignores the row the PTY id held before a respawn', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect(await waitOutcome({ rows })).toBe('ready') + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => + runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { + value: 0, + generation: 'reset' + }) + }) + ).toBe('timeout') + }) + + // Both write funnels record input (terminal-run-facts-input.test.ts), the user's keys included: + // typing `pi` to restart the agent in the same shell must not read the old process's done. + it.each([ + ['a prompt Orca sent', 'next task\r'], + ['the user restarting the agent in the same shell', 'pi\r'] + ])('reads no done from before %s', async (_label, input) => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { + runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) + } + expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') + expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') + }) + + it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => { + runtime.terminalRunFacts.recordInput( + TRANSCRIPT_PANE_PTY_ID, + 'driving', + 'next\r', + before + 1 + ) + runtime.noteTerminalSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + agentSessionEnsure: { disposition: 'adopted' } + }) + } + }) + ).toBe('timeout') + }) + + // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. + it('keeps the row while the agent paints its own shell-integration markers', async () => { + expect( + await waitOutcome({ + rows: () => [row({ receivedAt: Date.now() - 1000 })], + data: `\x1b]133;A\x07OK\x1b]133;C\x07${BUSY_SCREEN}` + }) + ).toBe('ready') + }) + + // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so only its done + // decides; a working row leaves its title and screen rules to decide. + it('settles Codex on its done, and leaves a working row to its screen rules', async () => { + const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } + expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe( + 'ready' + ) + expect( + await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex', state: 'working' })] }) + ).toBe('timeout') + }) + + it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_PERMISSION_DIALOG } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe( + 'blocked:agent-interactive-prompt' + ) + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) + + it('blocks on an open question the hook reports, and settles once it is answered', async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) }) }) diff --git a/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts b/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts deleted file mode 100644 index ab2677a98f4..00000000000 --- a/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * A shell that titles the command it is about to run (zsh preexec auto-title) writes the agent's - * bare name before the agent's TUI has mounted. For an agent whose only rest signal is that name, - * the title alone used to settle `tui-idle`, so a launch pasted its prompt into a TUI still - * booting, or into the shell. A launch readiness wait holds that title to a quiet stream. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const BOOT_FRAME = 'Loading…\r\n' - -/** The shell echoes the launch line, then its preexec hook titles the pane with the command. */ -async function launchedPaneTitledByShell(agent: 'gemini' | 'copilot') { - return createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: agent, - launchAgent: agent, - data: `$ ${agent}\r\n${ESC}]2;${agent}${BEL}` - }) -} - -/** The agent is still painting its boot screen: output keeps arriving each second. */ -async function keepBooting( - runtime: Awaited>['runtime'], - seconds: number -): Promise { - for (let second = 0; second < seconds; second += 1) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, BOOT_FRAME, Date.now()) - await vi.advanceTimersByTimeAsync(1000) - } -} - -describe('a launch waiting on an agent whose shell titled the pane with its name', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - // Gemini's bare name was once normalized into its rest glyph; Copilot's name is its only signal. - it.each(['gemini', 'copilot'] as const)( - 'does not treat the shell’s auto-title as readiness while %s is still booting', - async (agent) => { - const { runtime, handle } = await launchedPaneTitledByShell(agent) - const settled = vi.fn() - runtime - .waitForTerminal(handle, { - condition: 'tui-idle', - timeoutMs: 60_000, - launchReadiness: true - }) - .then(settled, settled) - - await keepBooting(runtime, 10) - - expect(settled).not.toHaveBeenCalled() - } - ) - - it('settles once the agent’s stream goes quiet under that title', async () => { - const { runtime, handle } = await launchedPaneTitledByShell('gemini') - const settled = vi.fn() - runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000, launchReadiness: true }) - .then(settled, settled) - - await keepBooting(runtime, 4) - await vi.advanceTimersByTimeAsync(8_000) - - expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) - }) - - it('keeps settling a later, non-launch wait on the name alone, as agents that never quiet need', async () => { - const { runtime, handle } = await launchedPaneTitledByShell('gemini') - const settled = vi.fn() - runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) - .then(settled, settled) - - await keepBooting(runtime, 10) - - expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) - }) -}) diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts deleted file mode 100644 index 558aa3f306e..00000000000 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { - getSyntheticAgentTerminalTitle, - shouldDriveSyntheticAgentTitleFromHook -} from '../../shared/synthetic-agent-title' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' -import { hasExplicitIdleTitle } from './tui-idle-evidence' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -function readTranscript(): string { - return readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8') -} - -describe('ZCode readiness from captured terminal bytes', () => { - it('accepts a fresh composer after the renderer adopts the terminal handle', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'worker-zcode', - foregroundProcess: 'zcode', - launchAgent: 'zcode', - data: '\x1b[?1049h╭' - }) - await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ - handle, - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null - }) - }) - - it('never emits an OSC title, so no title lane can settle its wait', () => { - const data = readTranscript() - expect(data).toContain(String.fromCharCode(27)) - // Why: this absence is the whole reason ZCode needs a body-evidence readiness lane. - expect(data).not.toMatch(new RegExp(`${String.fromCharCode(27)}\\][0-2];`)) - }) - - it('keeps repainting long after the composer mounts, so a quiet window never settles', () => { - const data = readTranscript() - const composerIndex = data.indexOf('╭') - expect(composerIndex).toBeGreaterThan(-1) - // Why: ~175KB of banner animation after the composer is up. A quiet-render window - // measured in hundreds of ms cannot fire anywhere in that span. - expect(data.length - composerIndex).toBeGreaterThan(100_000) - }) - - it('leaves no durable readiness evidence in the wait-text tail', async () => { - const data = readTranscript() - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'zcode-first-class-workspace', - foregroundProcess: 'zcode', - launchAgent: 'zcode', - data - }) - // Why this asserts a NEGATIVE: Orca's wait text is a line-folded tail, and ZCode paints - // its composer once and then repaints only the banner — so the composer scrolls out and - // no screen rule can settle the wait. This is the evidence for driving ZCode readiness - // from its synthetic hook title instead (see synthetic-agent-title.ts). - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) - ).rejects.toThrow(/timeout/) - }, 15_000) - - it('settles a tui-idle wait from the synthetic hook title Orca owns for ZCode', () => { - expect(getSyntheticAgentTerminalTitle('zcode', 'done')).toBe('ZCode ready') - expect(getSyntheticAgentTerminalTitle('zcode', 'waiting')).toBe('ZCode - action required') - expect(shouldDriveSyntheticAgentTitleFromHook('zcode', 'working')).toBe(true) - expect( - hasExplicitIdleTitle({ lastAgentStatus: 'idle', lastOutputAt: Date.now() }, 'ZCode ready') - ).toBe(true) - }) - - it('fires the draft-paste signal at the composer mount, not at the hard timeout', () => { - const data = readTranscript() - const scanner = createDraftPasteReadyScanner('zcode-composer-prompt') - const composerIndex = data.indexOf('╭') - // Why: feeding the stream in PTY-sized chunks proves the marker survives chunk splits. - let readyAt: number | null = null - for (let offset = 0; offset < data.length; offset += 4096) { - const chunk = data.slice(offset, offset + 4096) - if (scanner.observe(chunk).ready) { - readyAt = offset + chunk.length - break - } - } - expect(readyAt).not.toBeNull() - // Ready lands on the chunk that carries the composer corner, not thousands of frames later. - expect(readyAt!).toBeGreaterThanOrEqual(composerIndex) - expect(readyAt!).toBeLessThan(composerIndex + 8192) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx new file mode 100644 index 00000000000..8b0d39764b2 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx @@ -0,0 +1,277 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() +}) + +{ + // An approval whose subject a newer Orca wrote, with no detail to show. + const NEWER_APPROVAL: AgentJournalRenderItem = JSON.parse( + JSON.stringify({ + itemId: 'approval-item', + revision: 3, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review proposed change', + detail: null, + subject: { kind: 'diff', path: 'a.ts' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) + + it("hands the card a newer Orca's subject in a writable chat, and its cancel goes to the host with the card", () => { + mocks.turnId = 'turn-1' + mocks.promptItems = [NEWER_APPROVAL] + render( + + ) + expect(mocks.approvalCardProps).toMatchObject({ + approval: { subject: { kind: 'diff' } } + }) + expect(mocks.approvalCardProps?.approval).not.toHaveProperty('detail') + mocks.approvalCardProps?.onCancel?.() + expect(mocks.cancel).toHaveBeenCalledWith('turn-1', { + itemId: 'approval-item', + expectedRevision: 3 + }) + }) + + // The host's cancel names its turn, so with none running nothing is sent. + it('sends nothing for the card when no turn is running', () => { + mocks.promptItems = [NEWER_APPROVAL] + render( + + ) + mocks.approvalCardProps?.onCancel?.() + expect(mocks.cancel).not.toHaveBeenCalled() + }) + + it('cancels a Pi extension dialog outside a model turn', () => { + mocks.promptItems = [NEWER_APPROVAL] + render( + + ) + mocks.approvalCardProps?.onCancel?.() + expect(mocks.cancel).toHaveBeenCalledWith(undefined, { + itemId: 'approval-item', + expectedRevision: 3 + }) + }) + + function renderSession() { + render( + + ) + } + + // Nothing here answers it, so a send is the way on: it starts a turn, whose card cancel works. + it('keeps the composer open and writable beside a card this build cannot answer', () => { + mocks.promptItems = [NEWER_APPROVAL] + renderSession() + expect(mocks.composerProps).not.toBeNull() + expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: false }) + }) + + it('gives a card this build can answer the composer slot', () => { + const plan: AgentJournalRenderItem = JSON.parse( + JSON.stringify(NEWER_APPROVAL).replace( + '{"kind":"diff","path":"a.ts"}', + '{"kind":"plan","text":"do it"}' + ) + ) + mocks.promptItems = [plan] + renderSession() + expect(mocks.composerProps).toBeNull() + expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: true }) + }) +} + +{ + const SESSION_ID = 'start-failure-session' + const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } + const START_FAILED_REASON = + 'Claude stopped before it finished starting. Send your message to try again.' + + function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + } + } + + function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { + return { + outbox: { + clientMessageId, + sessionId: SESSION_ID, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + state: 'rejected', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null, + lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } } + }, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason, + rejection, + submittedAt: 1, + resolvedAt: 1 + } + } + } + + // The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them. + function renderPane(messages: ReturnType[]): void { + mocks.submissions = messages.map((message) => message.submission) + localStorage.setItem( + `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, + JSON.stringify(messages.map((message) => message.outbox)) + ) + render( + + ) + } + + async function notice(clientMessageId: string): Promise { + return waitFor(() => { + const row = document.querySelector( + `[data-message-id="${agentJournalSubmissionKey(clientMessageId)}"]` + ) + if (!row) { + throw new Error(`no notice on ${clientMessageId}`) + } + return row + }) + } + + // The start's own row says why, so its rejected messages say only that they were not sent. Sending + // one again is a new message, so none offers a Retry. + it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + + renderPane([ + rejected('first', START_FAILED_REASON, START_FAILED), + rejected('second', START_FAILED_REASON, START_FAILED) + ]) + + for (const id of ['first', 'second']) { + const row = await notice(id) + expect(within(row).getByText('Your message was not sent.')).toBeTruthy() + expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull() + } + expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() + }) + + it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + const providerRejected: AgentSessionFailureFact = { + kind: 'providerRejected', + detail: { text: 'Image type .bmp', audience: 'person' } + } + + renderPane([ + rejected('stated', START_FAILED_REASON, START_FAILED), + rejected( + 'other', + 'The provider did not accept this message: Image type .bmp.', + providerRejected + ) + ]) + + expect(within(await notice('stated')).getByText('Your message was not sent.')).toBeTruthy() + expect( + within(await notice('other')).getByText( + 'The provider did not accept this message: Image type .bmp.' + ) + ).toBeTruthy() + }) + + it("keeps the start failure's own words when its row is not loaded", async () => { + renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) + + expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy() + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx deleted file mode 100644 index 8d56bcd3233..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import type { StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - resetStructuredSessionMocks() -}) - -const STILL_WORKING = "The agent is still working. Run /clear when it's done." -const BACKGROUND = - 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.' - -function renderPane() { - const pane = () => ( - - ) - const view = render(pane()) - // A fresh element: the mocked controller reads the chat's state only when the pane renders. - return { rerender: () => view.rerender(pane()) } -} - -function composerSays( - text: string | null, - refusedWhile?: StructuredAgentSessionCommandRefusalCause -) { - const onError = mocks.composerProps?.structuredTransport?.onError - if (typeof onError !== 'function') { - throw new Error('the composer has no transport') - } - act(() => onError(text, { refusedWhile })) -} - -it('a /clear refused while the agent works is said until the agent stops, then gone', () => { - mocks.turnId = 'turn-1' - const pane = renderPane() - composerSays(STILL_WORKING, 'working') - expect(screen.getAllByText(STILL_WORKING)).toHaveLength(1) - - mocks.turnId = null - pane.rerender() - expect(screen.queryByText(STILL_WORKING)).toBeNull() - // Gone, not hidden: the agent working again later is not what that press was refused for. - mocks.turnId = 'turn-2' - pane.rerender() - expect(screen.queryByText(STILL_WORKING)).toBeNull() -}) - -it('a /clear refused behind background tasks is said until the last one ends, then gone', () => { - mocks.monitoringBackgroundTasks = true - const pane = renderPane() - composerSays(BACKGROUND, 'background') - expect(screen.getAllByText(BACKGROUND)).toHaveLength(1) - - mocks.monitoringBackgroundTasks = false - pane.rerender() - expect(screen.queryByText(BACKGROUND)).toBeNull() -}) - -it('a line naming nothing the chat shows stays until the next send, as before', () => { - mocks.turnId = 'turn-1' - const pane = renderPane() - composerSays('Remove attachments before using a chat-session command.') - mocks.turnId = null - pane.rerender() - expect(screen.getByText('Remove attachments before using a chat-session command.')).toBeTruthy() - composerSays(null) - expect(screen.queryByText('Remove attachments before using a chat-session command.')).toBeNull() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx deleted file mode 100644 index 9f0b181d203..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -// @vitest-environment happy-dom - -// A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, even after the -// transcript loaded: its words alone, and nothing that could only be refused again. - -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import { TooltipProvider } from '@/components/ui/tooltip' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) -vi.mock('@/lib/structured-agent-session-launch', () => - moduleFactories.structuredAgentSessionLaunch() -) -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() -}) - -const APPROVAL: AgentJournalRenderItem = JSON.parse( - JSON.stringify({ - itemId: 'approval-item', - revision: 3, - sequence: 1, - observedAt: 1, - body: { - kind: 'approval', - title: 'Run the plan?', - detail: null, - subject: { kind: 'plan', text: 'x' }, - options: [{ id: 'allow', label: 'Approve' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - }) -) - -/** A chat with its transcript on screen, a pending approval, a goal, a queued card and a running - * background task, whose read then fails with `reason`. */ -function renderLoadedChatThenFailedRead( - reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' -): void { - mocks.status = 'error' - mocks.readRefusal = { code: 'agent_session_journal_unreadable', details: { reason } } - mocks.turnId = 'turn-1' - mocks.promptItems = [APPROVAL] - mocks.threadGoal = { - goal: { - objective: 'Ship', - status: 'active', - tokenBudget: null, - tokensUsed: 0, - timeUsedSeconds: 1, - createdAt: 1, - updatedAt: 1 - }, - pending: false, - change: vi.fn() - } - mocks.queuedCards = [ - { messageId: 'draft-1', position: 1, text: 'queued text', state: 'waiting', hold: 'turn' } - ] - mocks.monitoringBackgroundTasks = true - mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }] - render( - - - - ) -} - -it.each([ - ['journalCorrupt', 'Unable to load this chat.'], - ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.'] -] as const)('a %s read after the transcript loaded leaves only its words', (reason, words) => { - renderLoadedChatThenFailedRead(reason) - - expect(screen.getAllByText(words)).toHaveLength(1) - expect(screen.queryByTestId('message-list')).toBeNull() - expect(mocks.composerProps).toBeNull() - expect(mocks.approvalCardProps).toBeNull() - expect(document.querySelector('[data-native-chat-thread-goal]')).toBeNull() - expect(document.querySelector('[data-queued-message-id="draft-1"]')).toBeNull() - expect(document.querySelector('[data-native-chat-background-tasks]')).toBeNull() -}) - -it('a failed read that can clear keeps the transcript and every control', () => { - renderLoadedChatThenFailedRead('journalUnavailable') - - expect(screen.getByTestId('message-list')).toBeTruthy() - expect(mocks.approvalCardProps).not.toBeNull() - expect(document.querySelector('[data-queued-message-id="draft-1"]')).not.toBeNull() - expect(document.querySelector('[data-native-chat-background-tasks]')).not.toBeNull() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.first-read.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.first-read.test.tsx deleted file mode 100644 index e2e204fa4cb..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.first-read.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/lib/structured-agent-session-launch', () => - moduleFactories.structuredAgentSessionLaunch() -) -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -const START_A_CHAT = 'Start a chat with Codex' -const LOADING_TEXT = /Loading conversation|Reading the agent transcript/ - -function loadingCue(): HTMLElement | null { - return document.querySelector('[data-native-chat-loading-cue="true"]') -} - -function sessionView(): React.JSX.Element { - return ( - - ) -} - -describe('NativeChatStructuredSession before its first read settles', () => { - afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() - }) - - it.each(['idle', 'loading'] as const)( - 'shows a reopened chat only a textless loading cue while its read is %s', - (status) => { - mocks.messages = [] - mocks.status = status - render(sessionView()) - - const cue = screen.getByRole('status', { name: 'Loading chat' }) - expect(cue).toBe(loadingCue()) - expect(cue.textContent).toBe('') - expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) - expect(screen.queryByText(START_A_CHAT)).toBeNull() - expect(screen.getByTestId('structured-composer')).toBeTruthy() - } - ) - - it('drops the cue for the empty state once the read settles with nothing in it', () => { - mocks.messages = [] - mocks.status = 'idle' - const { rerender } = render(sessionView()) - expect(screen.queryByText(START_A_CHAT)).toBeNull() - - mocks.status = 'ready' - rerender(sessionView()) - expect(screen.getByText(START_A_CHAT)).toBeTruthy() - expect(loadingCue()).toBeNull() - }) - - it('shows no cue over a transcript, even while a read is still loading', () => { - mocks.status = 'loading' - render(sessionView()) - - expect(screen.getByTestId('message-list')).toBeTruthy() - expect(loadingCue()).toBeNull() - }) - - it('does not hold a cancelled resume on the cue', () => { - mocks.messages = [] - mocks.launchLifecycle = 'cancelled' - mocks.launchResumes = true - mocks.status = 'ready' - render(sessionView()) - - expect(loadingCue()).toBeNull() - }) - - it('keeps a chat this view started on its empty state through publish and the first read', () => { - mocks.messages = [] - mocks.launchLifecycle = 'pending' - // The real controller reports `ready` while the launch has not published. - mocks.status = 'ready' - const { rerender } = render(sessionView()) - expect(screen.getByText(START_A_CHAT)).toBeTruthy() - - mocks.launchLifecycle = null - for (const status of ['idle', 'loading', 'ready'] as const) { - mocks.status = status - rerender(sessionView()) - expect(screen.getByText(START_A_CHAT)).toBeTruthy() - expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) - expect(loadingCue()).toBeNull() - } - }) - - it.each([ - ['failed', 'Chat could not be started.'], - ['visibility-unknown', 'Chat connection could not be confirmed.'] - ] as const)( - 'leaves a %s resume blank beside its Retry line, since nothing is reading it', - (lifecycle, line) => { - mocks.messages = [] - mocks.launchLifecycle = lifecycle - mocks.launchResumes = true - mocks.status = 'ready' - render(sessionView()) - - expect(loadingCue()).toBeNull() - expect(screen.queryByText(START_A_CHAT)).toBeNull() - expect(screen.getByText(line)).toBeTruthy() - expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() - } - ) - - it('does not invite a first message into a resumed chat whose history is not read yet', () => { - mocks.messages = [] - mocks.launchLifecycle = 'pending' - mocks.launchResumes = true - mocks.status = 'ready' - render(sessionView()) - - expect(screen.queryByText(START_A_CHAT)).toBeNull() - expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) - expect(loadingCue()).not.toBeNull() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx new file mode 100644 index 00000000000..88389dca2bc --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() +}) + +{ + const START_A_CHAT = 'Start a chat with Codex' + const LOADING_TEXT = /Loading conversation|Reading the agent transcript/ + + function loadingCue(): HTMLElement | null { + return document.querySelector('[data-native-chat-loading-cue="true"]') + } + + function sessionView(): React.JSX.Element { + return ( + + ) + } + + describe('NativeChatStructuredSession before its first read settles', () => { + it.each(['idle', 'loading'] as const)( + 'shows a reopened chat only a textless loading cue while its read is %s', + (status) => { + mocks.messages = [] + mocks.status = status + render(sessionView()) + + const cue = screen.getByRole('status', { name: 'Loading chat' }) + expect(cue).toBe(loadingCue()) + expect(cue.textContent).toBe('') + expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) + expect(screen.queryByText(START_A_CHAT)).toBeNull() + expect(screen.getByTestId('structured-composer')).toBeTruthy() + } + ) + + it('drops the cue for the empty state once the read settles with nothing in it', () => { + mocks.messages = [] + mocks.status = 'idle' + const { rerender } = render(sessionView()) + expect(screen.queryByText(START_A_CHAT)).toBeNull() + + mocks.status = 'ready' + rerender(sessionView()) + expect(screen.getByText(START_A_CHAT)).toBeTruthy() + expect(loadingCue()).toBeNull() + }) + + it('shows no cue over a transcript, even while a read is still loading', () => { + mocks.status = 'loading' + render(sessionView()) + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(loadingCue()).toBeNull() + }) + + it('does not hold a cancelled resume on the cue', () => { + mocks.messages = [] + mocks.launchLifecycle = 'cancelled' + mocks.launchResumes = true + mocks.status = 'ready' + render(sessionView()) + + expect(loadingCue()).toBeNull() + }) + + it('keeps a chat this view started on its empty state through publish and the first read', () => { + mocks.messages = [] + mocks.launchLifecycle = 'pending' + // The real controller reports `ready` while the launch has not published. + mocks.status = 'ready' + const { rerender } = render(sessionView()) + expect(screen.getByText(START_A_CHAT)).toBeTruthy() + + mocks.launchLifecycle = null + for (const status of ['idle', 'loading', 'ready'] as const) { + mocks.status = status + rerender(sessionView()) + expect(screen.getByText(START_A_CHAT)).toBeTruthy() + expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) + expect(loadingCue()).toBeNull() + } + }) + + it.each([ + ['failed', 'Chat could not be started.'], + ['visibility-unknown', 'Chat connection could not be confirmed.'] + ] as const)( + 'leaves a %s resume blank beside its Retry line, since nothing is reading it', + (lifecycle, line) => { + mocks.messages = [] + mocks.launchLifecycle = lifecycle + mocks.launchResumes = true + mocks.status = 'ready' + render(sessionView()) + + expect(loadingCue()).toBeNull() + expect(screen.queryByText(START_A_CHAT)).toBeNull() + expect(screen.getByText(line)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() + } + ) + + it('does not invite a first message into a resumed chat whose history is not read yet', () => { + mocks.messages = [] + mocks.launchLifecycle = 'pending' + mocks.launchResumes = true + mocks.status = 'ready' + render(sessionView()) + + expect(screen.queryByText(START_A_CHAT)).toBeNull() + expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) + expect(loadingCue()).not.toBeNull() + }) + }) +} + +{ + // A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, even after the + // transcript loaded: its words alone, and nothing that could only be refused again. + + const APPROVAL: AgentJournalRenderItem = JSON.parse( + JSON.stringify({ + itemId: 'approval-item', + revision: 3, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Run the plan?', + detail: null, + subject: { kind: 'plan', text: 'x' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) + + /** A chat with its transcript on screen, a pending approval, a goal, a queued card and a running + * background task, whose read then fails with `reason`. */ + function renderLoadedChatThenFailedRead( + reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' + ): void { + mocks.status = 'error' + mocks.readRefusal = { code: 'agent_session_journal_unreadable', details: { reason } } + mocks.turnId = 'turn-1' + mocks.promptItems = [APPROVAL] + mocks.threadGoal = { + goal: { + objective: 'Ship', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 1, + createdAt: 1, + updatedAt: 1 + }, + pending: false, + change: vi.fn() + } + mocks.queuedCards = [ + { messageId: 'draft-1', position: 1, text: 'queued text', state: 'waiting', hold: 'turn' } + ] + mocks.monitoringBackgroundTasks = true + mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }] + render( + + + + ) + } + + it.each([ + ['journalCorrupt', 'Unable to load this chat.'], + ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.'] + ] as const)('a %s read after the transcript loaded leaves only its words', (reason, words) => { + renderLoadedChatThenFailedRead(reason) + + expect(screen.getAllByText(words)).toHaveLength(1) + expect(screen.queryByTestId('message-list')).toBeNull() + expect(mocks.composerProps).toBeNull() + expect(mocks.approvalCardProps).toBeNull() + expect(document.querySelector('[data-native-chat-thread-goal]')).toBeNull() + expect(document.querySelector('[data-queued-message-id="draft-1"]')).toBeNull() + expect(document.querySelector('[data-native-chat-background-tasks]')).toBeNull() + }) + + it('a failed read that can clear keeps the transcript and every control', () => { + renderLoadedChatThenFailedRead('journalUnavailable') + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(mocks.approvalCardProps).not.toBeNull() + expect(document.querySelector('[data-queued-message-id="draft-1"]')).not.toBeNull() + expect(document.querySelector('[data-native-chat-background-tasks]')).not.toBeNull() + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx deleted file mode 100644 index 52b9a19e6b8..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() -}) - -// An approval whose subject a newer Orca wrote, with no detail to show. -const NEWER_APPROVAL: AgentJournalRenderItem = JSON.parse( - JSON.stringify({ - itemId: 'approval-item', - revision: 3, - sequence: 1, - observedAt: 1, - body: { - kind: 'approval', - title: 'Review proposed change', - detail: null, - subject: { kind: 'diff', path: 'a.ts' }, - options: [{ id: 'allow', label: 'Approve' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - }) -) - -it("hands the card a newer Orca's subject in a writable chat, and its cancel goes to the host with the card", () => { - mocks.turnId = 'turn-1' - mocks.promptItems = [NEWER_APPROVAL] - render( - - ) - expect(mocks.approvalCardProps).toMatchObject({ - approval: { subject: { kind: 'diff' } } - }) - expect(mocks.approvalCardProps?.approval).not.toHaveProperty('detail') - mocks.approvalCardProps?.onCancel?.() - expect(mocks.cancel).toHaveBeenCalledWith('turn-1', { - itemId: 'approval-item', - expectedRevision: 3 - }) -}) - -// The host's cancel names its turn, so with none running nothing is sent. -it('sends nothing for the card when no turn is running', () => { - mocks.promptItems = [NEWER_APPROVAL] - render( - - ) - mocks.approvalCardProps?.onCancel?.() - expect(mocks.cancel).not.toHaveBeenCalled() -}) - -it('cancels a Pi extension dialog outside a model turn', () => { - mocks.promptItems = [NEWER_APPROVAL] - render( - - ) - mocks.approvalCardProps?.onCancel?.() - expect(mocks.cancel).toHaveBeenCalledWith(undefined, { - itemId: 'approval-item', - expectedRevision: 3 - }) -}) - -function renderSession() { - render( - - ) -} - -// Nothing here answers it, so a send is the way on: it starts a turn, whose card cancel works. -it('keeps the composer open and writable beside a card this build cannot answer', () => { - mocks.promptItems = [NEWER_APPROVAL] - renderSession() - expect(mocks.composerProps).not.toBeNull() - expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: false }) -}) - -it('gives a card this build can answer the composer slot', () => { - const plan: AgentJournalRenderItem = JSON.parse( - JSON.stringify(NEWER_APPROVAL).replace( - '{"kind":"diff","path":"a.ts"}', - '{"kind":"plan","text":"do it"}' - ) - ) - mocks.promptItems = [plan] - renderSession() - expect(mocks.composerProps).toBeNull() - expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: true }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx deleted file mode 100644 index ccfba80707e..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - resetStructuredSessionMocks() -}) - -function renderPane(): void { - render( - - ) -} - -function journalRefusal( - reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' -) { - return { code: 'agent_session_journal_unreadable', details: { reason } } as const -} - -// The host's message and code never reach the pane; it words the refusal, and says it once. The -// read retries on its own, which the pane does not report. -it('says only that a failed read with no refusal did not load, and adds nothing of the host', () => { - mocks.status = 'error' - mocks.messages = [] - - renderPane() - - expect(screen.getByText('Could not load conversation')).toBeTruthy() - expect(screen.queryByText(/keeps trying/)).toBeNull() - expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() - expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() -}) - -it('says a damaged history cannot load in one line, without claiming Orca keeps trying', () => { - mocks.status = 'error' - mocks.readRefusal = journalRefusal('journalCorrupt') - mocks.messages = [] - - renderPane() - - expect(screen.getAllByText('Unable to load this chat.')).toHaveLength(1) - expect(screen.queryByText('Could not load conversation')).toBeNull() - expect(screen.queryByText(/keeps trying/)).toBeNull() - expect(screen.queryByText(/agent_session_/)).toBeNull() - // Nothing to send into: a send would only be refused and say it again. - expect(mocks.composerProps).toBeNull() -}) - -it("names a history that couldn't open right now once, in its one line", () => { - mocks.status = 'error' - mocks.readRefusal = journalRefusal('journalUnavailable') - mocks.messages = [] - - renderPane() - - expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1) - expect(screen.queryByText('Could not load conversation')).toBeNull() - expect(screen.queryByText(/keeps trying/)).toBeNull() - expect(screen.queryByText(/Try again/)).toBeNull() - // It can clear, so the composer stays and a send waits for the read. - expect(mocks.composerProps).not.toBeNull() -}) - -it("says a code's own words that the history didn't load, and nothing under them", () => { - mocks.status = 'error' - mocks.readRefusal = { - code: 'agent_session_checkpoint_stale', - details: { reason: 'fenceStale' } - } as const - mocks.messages = [] - - renderPane() - - expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) - expect(screen.queryByText(/keeps trying/)).toBeNull() -}) - -// "This isn't available in this chat." would name nothing the reader asked for. -it('says why the history did not load for a chat its host cannot run', () => { - mocks.status = 'error' - mocks.readRefusal = { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } as const - mocks.messages = [] - - renderPane() - - expect( - screen.getAllByText( - "Orca can't run this agent in a chat here. This chat's history couldn't be loaded." - ) - ).toHaveLength(1) - expect(screen.queryByText(/isn't available|newer Orca/)).toBeNull() -}) - -it('says once that a newer Orca saved the chat and only an update opens it, with no composer', () => { - mocks.status = 'error' - mocks.readRefusal = journalRefusal('journalWrittenByNewerOrca') - mocks.messages = [] - - renderPane() - - expect( - screen.getAllByText('This chat was saved by a newer Orca. Update Orca to open it.') - ).toHaveLength(1) - expect(screen.queryByText('Could not load conversation')).toBeNull() - expect(screen.queryByText(/keeps trying/)).toBeNull() - expect(mocks.composerProps).toBeNull() -}) - -// Only a chat that never loaded stores a failure with no host refusal (the read owner drops it after load). -it('says once that the history did not load beside a bubble of a chat that never loaded', () => { - mocks.status = 'error' - - render( - - ) - - expect(screen.getByTestId('message-list')).toBeTruthy() - expect(screen.getByTestId('structured-composer')).toBeTruthy() - expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) - expect(screen.queryByText(/reconnect/i)).toBeNull() -}) - -it('words a failed reconnect beside a transcript it keeps', () => { - mocks.status = 'error' - mocks.readRefusal = journalRefusal('journalUnavailable') - - renderPane() - - expect(screen.getByTestId('message-list')).toBeTruthy() - expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx new file mode 100644 index 00000000000..2f64fad8aa9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx @@ -0,0 +1,241 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() +}) + +{ + function renderPane(): void { + render( + + ) + } + + function journalRefusal( + reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' + ) { + return { code: 'agent_session_journal_unreadable', details: { reason } } as const + } + + // The host's message and code never reach the pane; it words the refusal, and says it once. The + // read retries on its own, which the pane does not report. + it('says only that a failed read with no refusal did not load, and adds nothing of the host', () => { + mocks.status = 'error' + mocks.messages = [] + + renderPane() + + expect(screen.getByText('Could not load conversation')).toBeTruthy() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() + expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() + }) + + it('says a damaged history cannot load in one line, without claiming Orca keeps trying', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalCorrupt') + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText('Unable to load this chat.')).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(screen.queryByText(/agent_session_/)).toBeNull() + // Nothing to send into: a send would only be refused and say it again. + expect(mocks.composerProps).toBeNull() + }) + + it("names a history that couldn't open right now once, in its one line", () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalUnavailable') + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(screen.queryByText(/Try again/)).toBeNull() + // It can clear, so the composer stays and a send waits for the read. + expect(mocks.composerProps).not.toBeNull() + }) + + it("says a code's own words that the history didn't load, and nothing under them", () => { + mocks.status = 'error' + mocks.readRefusal = { + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + } as const + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) + expect(screen.queryByText(/keeps trying/)).toBeNull() + }) + + // "This isn't available in this chat." would name nothing the reader asked for. + it('says why the history did not load for a chat its host cannot run', () => { + mocks.status = 'error' + mocks.readRefusal = { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } as const + mocks.messages = [] + + renderPane() + + expect( + screen.getAllByText( + "Orca can't run this agent in a chat here. This chat's history couldn't be loaded." + ) + ).toHaveLength(1) + expect(screen.queryByText(/isn't available|newer Orca/)).toBeNull() + }) + + it('says once that a newer Orca saved the chat and only an update opens it, with no composer', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalWrittenByNewerOrca') + mocks.messages = [] + + renderPane() + + expect( + screen.getAllByText('This chat was saved by a newer Orca. Update Orca to open it.') + ).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(mocks.composerProps).toBeNull() + }) + + // Only a chat that never loaded stores a failure with no host refusal (the read owner drops it after load). + it('says once that the history did not load beside a bubble of a chat that never loaded', () => { + mocks.status = 'error' + + render( + + ) + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) + expect(screen.queryByText(/reconnect/i)).toBeNull() + }) + + it('words a failed reconnect beside a transcript it keeps', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalUnavailable') + + renderPane() + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() + }) +} + +{ + const STILL_WORKING = "The agent is still working. Run /clear when it's done." + const BACKGROUND = + 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.' + + function renderPane() { + const pane = () => ( + + ) + const view = render(pane()) + // A fresh element: the mocked controller reads the chat's state only when the pane renders. + return { rerender: () => view.rerender(pane()) } + } + + function composerSays( + text: string | null, + refusedWhile?: StructuredAgentSessionCommandRefusalCause + ) { + const onError = mocks.composerProps?.structuredTransport?.onError + if (typeof onError !== 'function') { + throw new Error('the composer has no transport') + } + act(() => onError(text, { refusedWhile })) + } + + it('a /clear refused while the agent works is said until the agent stops, then gone', () => { + mocks.turnId = 'turn-1' + const pane = renderPane() + composerSays(STILL_WORKING, 'working') + expect(screen.getAllByText(STILL_WORKING)).toHaveLength(1) + + mocks.turnId = null + pane.rerender() + expect(screen.queryByText(STILL_WORKING)).toBeNull() + // Gone, not hidden: the agent working again later is not what that press was refused for. + mocks.turnId = 'turn-2' + pane.rerender() + expect(screen.queryByText(STILL_WORKING)).toBeNull() + }) + + it('a /clear refused behind background tasks is said until the last one ends, then gone', () => { + mocks.monitoringBackgroundTasks = true + const pane = renderPane() + composerSays(BACKGROUND, 'background') + expect(screen.getAllByText(BACKGROUND)).toHaveLength(1) + + mocks.monitoringBackgroundTasks = false + pane.rerender() + expect(screen.queryByText(BACKGROUND)).toBeNull() + }) + + it('a line naming nothing the chat shows stays until the next send, as before', () => { + mocks.turnId = 'turn-1' + const pane = renderPane() + composerSays('Remove attachments before using a chat-session command.') + mocks.turnId = null + pane.rerender() + expect(screen.getByText('Remove attachments before using a chat-session command.')).toBeTruthy() + composerSays(null) + expect(screen.queryByText('Remove attachments before using a chat-session command.')).toBeNull() + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx deleted file mode 100644 index 73334d71ca3..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen, waitFor, within } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() -}) - -const SESSION_ID = 'start-failure-session' -const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } -const START_FAILED_REASON = - 'Claude stopped before it finished starting. Send your message to try again.' - -function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { - return { - itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')), - revision: 1, - sequence: 1, - observedAt: 1, - body: { - kind: 'status', - tone: 'error', - ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) - } - } -} - -function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { - return { - outbox: { - clientMessageId, - sessionId: SESSION_ID, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - previewUris: [], - state: 'rejected', - queuedAt: 1, - lastAttemptAt: null, - retryAfterUnknownSubmittedAt: null, - lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } } - }, - submission: { - clientMessageId, - fence: 1, - payloadFingerprint: clientMessageId, - dispatchState: 'rejected', - providerItemId: null, - reason, - rejection, - submittedAt: 1, - resolvedAt: 1 - } - } -} - -// The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them. -function renderPane(messages: ReturnType[]): void { - mocks.submissions = messages.map((message) => message.submission) - localStorage.setItem( - `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, - JSON.stringify(messages.map((message) => message.outbox)) - ) - render( - - ) -} - -async function notice(clientMessageId: string): Promise { - return waitFor(() => { - const row = document.querySelector( - `[data-message-id="${agentJournalSubmissionKey(clientMessageId)}"]` - ) - if (!row) { - throw new Error(`no notice on ${clientMessageId}`) - } - return row - }) -} - -// The start's own row says why, so its rejected messages say only that they were not sent. Sending -// one again is a new message, so none offers a Retry. -it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { - mocks.journalItems = [startFailureRow(START_FAILED)] - - renderPane([ - rejected('first', START_FAILED_REASON, START_FAILED), - rejected('second', START_FAILED_REASON, START_FAILED) - ]) - - for (const id of ['first', 'second']) { - const row = await notice(id) - expect(within(row).getByText('Your message was not sent.')).toBeTruthy() - expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull() - } - expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() -}) - -it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => { - mocks.journalItems = [startFailureRow(START_FAILED)] - const providerRejected: AgentSessionFailureFact = { - kind: 'providerRejected', - detail: { text: 'Image type .bmp', audience: 'person' } - } - - renderPane([ - rejected('stated', START_FAILED_REASON, START_FAILED), - rejected( - 'other', - 'The provider did not accept this message: Image type .bmp.', - providerRejected - ) - ]) - - expect(within(await notice('stated')).getByText('Your message was not sent.')).toBeTruthy() - expect( - within(await notice('other')).getByText( - 'The provider did not accept this message: Image type .bmp.' - ) - ).toBeTruthy() -}) - -it("keeps the start failure's own words when its row is not loaded", async () => { - renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) - - expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 0ba9acd7de0..410cb2b8da4 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -360,6 +360,7 @@ export function createStructuredSessionMocks() { const resetStructuredSessionMocks = (): void => { mocks.call.mockReset() + mocks.fileLinkClick.mockClear() mocks.launchLifecycle = null mocks.ownerWorktreeId = 'wt-1' mocks.fileLinkContext = DEFAULT_FILE_LINK_CONTEXT @@ -402,6 +403,10 @@ export function createStructuredSessionMocks() { mocks.loadingOlder = false mocks.olderHistoryGeneration = 0 mocks.loadOlder.mockReset() + mocks.queuedCards = [] + mocks.queuedSteer.mockClear() + mocks.queuedRemove.mockClear() + mocks.queuedEdit.mockClear() Object.assign(mocks, { queuedResumable: false, queueSendsNext: false, unavailable: null }) mocks.queuedResume.mockReset() mocks.revealLatest.mockReset() diff --git a/src/renderer/src/lib/github-source-runtime-context.test.ts b/src/renderer/src/lib/github-source-runtime-context.test.ts index 8759da60e6f..942b446bc69 100644 --- a/src/renderer/src/lib/github-source-runtime-context.test.ts +++ b/src/renderer/src/lib/github-source-runtime-context.test.ts @@ -1,5 +1,3 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { RuntimeClientTarget } from '../runtime/runtime-client-target' @@ -162,24 +160,3 @@ describe('getGitHubRepoRoutingTarget', () => { ).toEqual({ kind: 'environment', environmentId: 'source-runtime' }) }) }) - -function rendererSourceFiles(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) { - return rendererSourceFiles(path) - } - return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [path] : [] - }) -} - -describe('GitHub repo routing call sites', () => { - it('never spreads a task source over repo-owner settings (#7623)', () => { - const rendererRoot = resolve(__dirname, '..') - // Why: a local source spreads `activeRuntimeEnvironmentId: null` over a runtime owner. - const offenders = rendererSourceFiles(rendererRoot) - .filter((file) => /\.\.\.getTaskSourceRuntimeSettings\(/.test(readFileSync(file, 'utf8'))) - .map((file) => relative(rendererRoot, file).split('\\').join('/')) - expect(offenders).toEqual([]) - }) -}) diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 276810168f4..4d15a233938 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -1,5 +1,3 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' @@ -209,112 +207,3 @@ describe('editorTabDocumentFolderAccess', () => { expect(editorTabDocumentFolderAccess(useAppStore.getState(), file)).toBeUndefined() }) }) - -// Why a ratchet: a content-driven reader that adopted user-file would bring back the round-1 leak. -// These scans only see the two ways a renderer file can produce user-file today, by name and by -// pattern: building user-file access directly, and opening a tab the tab rule reads as user-named. They -// cannot see a decision laundered through props or a helper, so they are a tripwire for review, not -// a proof; a branded user-named path type is the follow-up that would make it one. -const USER_NAMED_ACCESS_IMPORTERS = [ - 'components/browser-pane/describe-page/browser-artifact-upload.ts', - 'components/native-chat/use-native-chat-external-attachments.ts', - 'components/sidebar/useSidebarProjectDrop.ts', - 'components/terminal-pane/terminal-host-workspace-file.ts', - 'hooks/composer-state/attachment-drop-state.ts', - 'lib/local-file-access.ts', - 'lib/user-opened-local-path.ts' -] - -// Files whose openFile call can store relativePath === filePath (read and saved as user-named by -// the tab rule) or open a floating-workspace tab. The scan matches by value, so a few listed files -// only look like it (their relativePath is genuinely relative); review each new entry by hand. -// Files that build write access beside an opened document; each must derive it from the tab rule. -const DOCUMENT_FOLDER_ACCESS_BUILDERS = [ - 'components/editor/editor-header-file-rename.ts', - 'components/editor/rich-markdown-image-insert.ts', - 'components/tab-bar/EditorFileTab.tsx', - 'lib/execute-open-editor-path-move.ts', - 'lib/local-file-access.ts' -] - -const USER_NAMED_TAB_OPENERS = [ - 'components/browser-pane/navigate/navigate-browser-page-url.ts', - 'components/editor/editor-dropped-file-open.ts', - 'components/editor/markdown-preview-link-actions.ts', - 'components/floating-terminal/use-floating-terminal-create-actions.ts', - 'components/quick-open-file-navigation.ts', - 'components/right-sidebar/ai-vault-session-log-open.ts', - 'components/right-sidebar/source-control/notes/use-note-opening.ts', - 'components/right-sidebar/useFileExplorerHandlers.ts', - 'components/settings/KeybindingsFileActions.tsx', - 'components/tab-bar/tab-create-entry-absolute-file.ts', - 'components/terminal-pane/terminal-file-open-routing.ts', - 'lib/floating-workspace-tab-creation.ts', - 'lib/open-document-in-floating-workspace.ts', - 'store/slices/editor/actions/markdown-link-action.ts' -] - -function collectSourceFiles(dir: string): string[] { - return readdirSync(dir).flatMap((entry) => { - const full = join(dir, entry) - if (statSync(full).isDirectory()) { - return collectSourceFiles(full) - } - return /\.tsx?$/.test(entry) && - !/\.test\.tsx?$/.test(entry) && - !/test-(harness|fixture)/.test(entry) - ? [full] - : [] - }) -} - -function opensUserNamedTabs(source: string): boolean { - if (!/\bopenFile\(/.test(source)) { - return false - } - if (source.includes('FLOATING_TERMINAL_WORKTREE_ID')) { - return true - } - const filePathValues = [...source.matchAll(/\bfilePath:\s*([\w.]+)/g)] - .map((match) => match[1]) - .filter((value) => value !== 'string') - if (/\bfilePath,/.test(source)) { - filePathValues.push('filePath') - } - const relativePathValues = [ - ...source.matchAll(/\brelativePath(?::|\s*=)\s*([\s\S]*?)(?:,\s*\n|\n\s*[})])/g) - ].map((match) => match[1]) - return relativePathValues.some((value) => - filePathValues.some((filePath) => - new RegExp(`(^|[^\\w.])${filePath.replace(/\./g, '\\.')}($|[^\\w.])`).test(value) - ) - ) -} - -function rendererFilesMatching(test: (source: string) => boolean): string[] { - const rendererRoot = resolve(__dirname, '..') - return collectSourceFiles(rendererRoot) - .filter((file) => test(readFileSync(file, 'utf8'))) - .map((file) => relative(rendererRoot, file).split('\\').join('/')) - .sort() -} - -describe('user-named file access ratchet', () => { - it('lists every file that builds user-file access', () => { - expect( - rendererFilesMatching((source) => /\buserNamedFileAccess\b|kind: 'user-file'/.test(source)) - ).toEqual(USER_NAMED_ACCESS_IMPORTERS) - }) - - it('lists every file that builds document-folder write access', () => { - expect( - rendererFilesMatching((source) => - /\b(editorTab)?[dD]ocumentFolderAccess\b|kind: 'document-folder'/.test(source) - ) - ).toEqual(DOCUMENT_FOLDER_ACCESS_BUILDERS) - }) - - it('lists every file that can open a tab read as user-named', () => { - expect(rendererFilesMatching(opensUserNamedTabs)).toEqual(USER_NAMED_TAB_OPENERS) - }) -})