[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
This commit is contained in:
Neil
2026-10-10 14:26:04 -07:00
committed by GitHub
parent 853c25de3e
commit 26ee9bbfab
67 changed files with 6917 additions and 7653 deletions
+5 -5
View File
@@ -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": {
+5 -2
View File
@@ -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) {
+36 -22
View File
@@ -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([])
@@ -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<Record<string, unknown>>()),
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<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
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<string, unknown>) {
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<typeof makeRepo>): 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()
})
})
+423 -1
View File
@@ -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:<env>` 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<unknown>
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<unknown>
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')
})
})
@@ -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<string, unknown>) {
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<typeof makeRepo>): 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()
})
})
@@ -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<Record<string, unknown>>()),
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<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
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'
})
})
})
@@ -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<Record<string, unknown>>()),
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<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
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:<env>` 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<unknown>
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<unknown>
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')
})
})
@@ -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'
})
})
})
})
@@ -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<Record<string, unknown>>()),
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<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
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' }
)
})
})
@@ -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'
})
})
})
@@ -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' }
)
})
})
@@ -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<Record<string, unknown>>()),
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<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
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'
})
})
})
})
@@ -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; `<name>.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; `<name>.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)
})
})
@@ -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)
})
@@ -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)
}
}
)
})
@@ -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<number | null> {
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<ReturnType<typeof launchedPaneTitledByShell>>['runtime'],
seconds: number
): Promise<void> {
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 }))
})
})
})
@@ -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<TranscriptReplayFrame> {
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<TranscriptReplayFrame> {
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 `<model> <effort> ·`; 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 `<model> <effort> ·`; 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<ReturnType<typeof launchedCodexPane>>,
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'
})
})
})
})
File diff suppressed because it is too large Load Diff
@@ -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<TranscriptReplayFrame | null> {
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
)
})
})
@@ -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)
}
})
})
@@ -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, unknown> = {}): 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<string, { tailBuffer: string[]; tailPartialLine: string }>
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)
})
@@ -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<ReturnType<typeof launchedCodexPane>>,
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)
})
@@ -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<number | null> {
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)
})
})
@@ -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)
})
+509 -205
View File
@@ -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<boolean> {
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<boolean> {
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 `· <model>` on the box's last line, above its corner. */
async function agentRowRead(name: string, chunks: string[]): Promise<number> {
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)
}
})
})
})
@@ -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 `· <model>` on the box's last line, above its corner. */
async function agentRowRead(name: string, chunks: string[]): Promise<number> {
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)
}
)
})
@@ -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)
})
})
@@ -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)
})
})
@@ -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<string, unknown> = {}) {
return h.call(
'orchestration.check',
{ terminal: handle, terminalPaneKey: paneKey, ...params },
ctx
) as Promise<CheckResult>
}
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'])
})
})
@@ -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<string, string>
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<typeof h.setup>
let dispatch: ReturnType<typeof createRootDispatch>
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)
})
})
@@ -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<typeof h.setup>
let dispatch: ReturnType<typeof createRootDispatch>
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)
})
})
@@ -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<string, unknown> = {}) {
return h.call(
'orchestration.check',
{ terminal: handle, terminalPaneKey: paneKey, ...params },
ctx
) as Promise<CheckResult>
}
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<string, unknown>) {
return h.call(name, params, ctx)
}
async function leadInbox(params: Record<string, unknown> = {}): Promise<unknown> {
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:<id> 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:<id> 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'
@@ -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<string, unknown>) {
return h.call(name, params, ctx)
}
async function leadInbox(params: Record<string, unknown> = {}): Promise<unknown> {
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:<id> 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:<id> 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 } })
})
})
@@ -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<string, unknown> }
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<string, unknown>[] }
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<string, unknown> }
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 <handle>`: 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<string, unknown>) {
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}`)
})
})
@@ -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<string, unknown> }
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<string, unknown>[] }
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<string, unknown> }
expect(result.message).toMatchObject({ subject: 'Re: Need an answer' })
for (const column of INTERNAL_COLUMNS) {
expect(result.message).not.toHaveProperty(column)
}
})
})
@@ -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 <handle>`: 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])
})
})
@@ -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<string, unknown>) {
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}`)
})
})
@@ -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<string, string>
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([])
})
})
})
@@ -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<RuntimeTerminalWait>()
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')
})
})
@@ -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' })
})
})
@@ -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<Map<string, ListedWorker>> {
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: [] })
})
})
@@ -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<string> {
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()
})
})
@@ -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)
})
})
@@ -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<string> {
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<Map<string, ListedWorker>> {
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: [] })
})
})
@@ -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')
})
})
@@ -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<RuntimeTerminalWait>()
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')
})
})
@@ -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<RuntimeTerminalWait>()
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()
})
})
@@ -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<RuntimeTerminalWait>()
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()
})
})
@@ -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)
})
@@ -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'
})
})
})
@@ -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<Awaited<ReturnType<typeof createTranscriptPane>>> {
// 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<Awaited<ReturnType<typeof createTranscriptPane>>> {
// 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)
})
})
@@ -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> = {}): 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, unknown> = {}): 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<string> {
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<string, { tailBuffer: string[]; tailPartialLine: string }>
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> = {}): 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<string> {
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')
})
})
})
@@ -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<ReturnType<typeof launchedPaneTitledByShell>>['runtime'],
seconds: number
): Promise<void> {
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 }))
})
})
@@ -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)
})
})
@@ -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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).not.toHaveBeenCalled()
})
it('cancels a Pi extension dialog outside a model turn', () => {
mocks.promptItems = [NEWER_APPROVAL]
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="pi-dialog-tab"
sessionId="pi-dialog-session"
target={{ kind: 'local' }}
agent="pi"
/>
)
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).toHaveBeenCalledWith(undefined, {
itemId: 'approval-item',
expectedRevision: 3
})
})
function renderSession() {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
}
// 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<typeof rejected>[]): void {
mocks.submissions = messages.map((message) => message.submission)
localStorage.setItem(
`orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`,
JSON.stringify(messages.map((message) => message.outbox))
)
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="start-failure-tab"
sessionId={SESSION_ID}
target={{ kind: 'local' }}
agent="claude"
/>
)
}
async function notice(clientMessageId: string): Promise<HTMLElement> {
return waitFor(() => {
const row = document.querySelector<HTMLElement>(
`[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()
})
}
@@ -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 = () => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="refusal-line-tab"
sessionId="refusal-line-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
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()
})
@@ -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(
<TooltipProvider>
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="final-read-failure-tab"
sessionId="final-read-failure-session"
target={{ kind: 'environment', environmentId: 'env-1' }}
agent="codex"
/>
</TooltipProvider>
)
}
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()
})
@@ -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 (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="codex"
/>
)
}
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()
})
})
@@ -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 (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="codex"
/>
)
}
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(
<TooltipProvider>
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="final-read-failure-tab"
sessionId="final-read-failure-session"
target={{ kind: 'environment', environmentId: 'env-1' }}
agent="codex"
/>
</TooltipProvider>
)
}
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()
})
}
@@ -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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).not.toHaveBeenCalled()
})
it('cancels a Pi extension dialog outside a model turn', () => {
mocks.promptItems = [NEWER_APPROVAL]
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="pi-dialog-tab"
sessionId="pi-dialog-session"
target={{ kind: 'local' }}
agent="pi"
/>
)
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).toHaveBeenCalledWith(undefined, {
itemId: 'approval-item',
expectedRevision: 3
})
})
function renderSession() {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
}
// 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 })
})
@@ -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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-read-error-tab"
sessionId="read-error-session"
target={{ kind: 'local' }}
agent="codex"
/>
)
}
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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-read-error-tab"
sessionId="read-error-session"
target={{ kind: 'environment', environmentId: 'remote-host' }}
agent="codex"
/>
)
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()
})
@@ -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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-read-error-tab"
sessionId="read-error-session"
target={{ kind: 'local' }}
agent="codex"
/>
)
}
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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-read-error-tab"
sessionId="read-error-session"
target={{ kind: 'environment', environmentId: 'remote-host' }}
agent="codex"
/>
)
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 = () => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="refusal-line-tab"
sessionId="refusal-line-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
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()
})
}
@@ -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<typeof rejected>[]): void {
mocks.submissions = messages.map((message) => message.submission)
localStorage.setItem(
`orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`,
JSON.stringify(messages.map((message) => message.outbox))
)
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="start-failure-tab"
sessionId={SESSION_ID}
target={{ kind: 'local' }}
agent="claude"
/>
)
}
async function notice(clientMessageId: string): Promise<HTMLElement> {
return waitFor(() => {
const row = document.querySelector<HTMLElement>(
`[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()
})
@@ -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()
@@ -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([])
})
})
@@ -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)
})
})