Files
orca/src/main/runtime/multi-client-navigation-isolation.integration.test.ts
T
OrcaWinandOrcaWin 7d6603aa17 [comment audit] Complete repository-wide comment cleanup (#33249)
* [comment audit] Align legacy failure-read counts with cached display

* [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci]

* [comment audit] Condense shared generation, path and preview comments [skip ci]

* [comment audit] Condense host, file and Git contract rationale [skip ci]

* [comment audit] Tighten Git lifecycle and provider contract comments [skip ci]

* [comment audit] Clarify shared host authority and launch isolation [skip ci]

* [comment audit] simplify local access mobile and native chat contracts [skip ci]

* [comment audit] simplify native chat presentation and state contracts [skip ci]

* [comment audit] simplify native runtime and provider contracts [skip ci]

* [comment audit] simplify migration and orchestration contracts [skip ci]

* [comment audit] simplify plugin trust and process capture rationale [skip ci]

* [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci]

* [comment audit] clarify remote recovery and launch wire rationale [skip ci]

* [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci]

* [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci]

* [comment audit] clarify sidebar, skill and SSH invariants [skip ci]

* [comment audit] condense structured chat delivery and timing rationale [skip ci]

* [comment audit] condense terminal replay and telemetry invariants [skip ci]

* [comment audit] clarify launch permission and terminal delivery rationale [skip ci]

* [comment audit] clarify workspace ownership and layout contracts [skip ci]

* [comment audit] wait for fitted and painted SSH terminal links

* [comment audit] clarify layout persistence and session ownership [skip ci]

* [comment audit] clarify host routing and workspace lifecycle contracts [skip ci]

* [comment audit] clarify startup persistence and app lifetime comments [skip ci]

* [comment audit] clarify recovery and activity boundaries [skip ci]

* [comment audit] condense activity and artifact invariants [skip ci]

* [comment audit] condense automation authority contracts [skip ci]

* [comment audit] simplify automation scheduling and history contracts [skip ci]

* [comment audit] clarify browser chrome and annotation lifecycles [skip ci]

* [comment audit] clarify browser routing and retained guest ownership [skip ci]

* [comment audit] clarify browser recovery and document ownership comments [skip ci]

* [comment audit] restore hosted runners required by native IME fixtures

* [comment audit] shorten preview, palette and dashboard invariants [skip ci]

* [comment audit] clarify terminal preview, dashboard and retention comments [skip ci]

* [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci]

* [comment audit] shorten rich-text and combined-diff ownership comments [skip ci]

* [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci]

* [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci]

* [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci]

* [comment audit] clarify rich Markdown source and selection safeguards [skip ci]

* [comment audit] clarify editor loading and emulator stream lifetimes [skip ci]

* [comment audit] trim emulator and feature tour narration [skip ci]

* [comment audit] clarify floating workspace and GitHub cache ownership [skip ci]

* [comment audit] trim provider controls and Linear filter explanations [skip ci]

* [comment audit] clarify pairing and chat resume lifetimes [skip ci]

* [comment audit] clarify chat delivery and transcript lifetimes [skip ci]

* [comment audit] clarify session options and host-owned status [skip ci]

* [comment audit] clarify draft durability and live-status precedence [skip ci]

* [comment audit] Trim chat send and model ownership explanations [skip ci]

* [comment audit] Shorten transcript ownership and send recovery comments [skip ci]

* [comment audit] Simplify native chat composer and transcript hook comments [skip ci]

* [comment audit] Shorten native chat scroll and structured session explanations [skip ci]

* [comment audit] Trim workspace picker and onboarding commentary [skip ci]

* [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci]

* [comment audit] Trim sidebar and session history explanations [skip ci]

* [comment audit] Condense session resume and review checks comments [skip ci]

* [comment audit] Shorten checks state and file explorer refresh comments [skip ci]

* [comment audit] Trim plugin bridge and source-control action rationale [skip ci]

* [comment audit] Trim source-control listing and review rationale [skip ci]

* [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci]

* [comment audit] Condense Settings account and appearance rationale [skip ci]

* [comment audit] Trim pairing and repository Settings explanations [skip ci]

* [comment audit] Condense Settings search SSH and terminal safeguards [skip ci]

* [comment audit] Shorten Settings voice and provider ownership comments [skip ci]

* [comment audit] Condense Settings draft persistence and host-state rationale [skip ci]

* [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci]

* [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci]

* [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci]

* [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci]

* [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci]

* [comment audit] Prune focus-read baseline after comment cleanup

* [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci]

* [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci]

* [comment audit] Clarify viewport geometry and skill update safeguards [skip ci]

* [comment audit] Tighten skill inventory, usage and host status comments [skip ci]

* [comment audit] Clarify usage ownership and resource kill safeguards [skip ci]

* [comment audit] Tighten tab selection, rename and search contracts [skip ci]

* [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci]

* [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci]

* [comment audit] Tighten provider loading and terminal recovery comments [skip ci]

* [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci]

* [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci]

* [comment audit] Tighten PTY startup and replay explanations [skip ci]

* [comment audit] Clarify PTY identity and delivery ownership [skip ci]

* [comment audit] Shorten remote reconnect and input replay rationale [skip ci]

* [comment audit] Condense terminal parking clipboard and file link comments [skip ci]

* [comment audit] Clarify IME ownership and trim trace history [skip ci]

* [comment audit] Shorten pane recovery and parked watcher explanations [skip ci]

* [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci]

* [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci]

* [comment audit] Trim renderer lifecycle and menu rationale [skip ci]

* [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci]

* [comment audit] Clarify renderer hook evidence and source ownership [skip ci]

* [comment audit] Tighten IPC ownership and reconnect explanations [skip ci]

* [comment audit] Trim renderer focus, paste and attention comments [skip ci]

* [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci]

* [comment audit] Trim agent launch, resume and ownership commentary [skip ci]

* [comment audit] Shorten account, browser and editor comments [skip ci]

* [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci]

* [comment audit] Condense launch recovery and editor tokenization comments [skip ci]

* [comment audit] Condense workspace selection and terminal pane rationale [skip ci]

* [comment audit] Condense terminal fitting and rendering recovery comments [skip ci]

* [comment audit] Condense terminal input and host routing explanations [skip ci]

* [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci]

* [comment audit] Condense structured launch and terminal link explanations [skip ci]

* [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci]

* [comment audit] Condense session persistence and workspace activation rationale [skip ci]

* [comment audit] Condense workspace routing and browser ownership rationale [skip ci]

* [comment audit] Condense mirror recovery and layout authority rationale [skip ci]

* [comment audit] Condense runtime transport and file safety rationale [skip ci]

* [comment audit] Condense runtime graph and structured session rationale [skip ci]

* [comment audit] Condense browser creation and remote tab recovery rationale [skip ci]

* [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci]

* [comment audit] Condense frame, startup and provider cache rationale [skip ci]

* [comment audit] Condense host twin and agent lifecycle rationale [skip ci]

* [comment audit] Condense browser authority and editor cleanup rationale [skip ci]

* [comment audit] Condense editor buffer identity and review cache rationale [skip ci]

* [comment audit] Condense provider lifetime and repository ownership rationale [skip ci]

* [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci]

* [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci]

* [comment audit] Condense cleanup ownership and listing admission rationale [skip ci]

* [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci]

* [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci]

* [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci]

* [comment audit] Condense cloud rollout and assignment safety rationale [skip ci]

* [comment audit] Condense relay session and database recovery rationale [skip ci]

* [comment audit] Condense relay placement and protocol compatibility rationale [skip ci]

* [comment audit] Condense build and packaging constraints [skip ci]

* [comment audit] Condense CI gates and validation isolation rationale [skip ci]

* [comment audit] Condense catalog identity and browser validation rationale [skip ci]

* [comment audit] Condense mobile browser and frame-budget rationale [skip ci]

* [comment audit] Shorten mobile route and browser test explanations [skip ci]

* [comment audit] Condense editor and session seam test rationale [skip ci]

* [comment audit] Trim session and terminal closure history [skip ci]

* [comment audit] Shorten terminal browser and census explanations [skip ci]

* [comment audit] Preserve root-only E2E diagnostic baseline

* [comment audit] Condense mobile seam and HTML preview test rationale [skip ci]

* [comment audit] Condense preview instrumentation and render harness rationale [skip ci]

* [comment audit] Condense bundle contracts and native-build constraints [skip ci]

* [comment audit] Shorten native packaging and CI routing rationale [skip ci]

* [comment audit] Condense release tooling and benchmark constraints [skip ci]

* [comment audit] Condense packaging and platform gate rationale [skip ci]

* [comment audit] Shorten mobile route and history rationale [skip ci]

* [comment audit] Condense mobile browser and component rationale [skip ci]

* [comment audit] Shorten drawer and workspace-form rationale [skip ci]

* [comment audit] Trim mobile review and editor-document explanations [skip ci]

* [comment audit] Condense editor lifecycle and Markdown explanations [skip ci]

* [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci]

* [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci]

* [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci]

* [comment audit] Condense bridge stream, native request and audio rationale [skip ci]

* [comment audit] Reduce bridge schema and page-client commentary [skip ci]

* [comment audit] Tighten mobile page bridge contracts [skip ci]

* [comment audit] Clarify mobile cache and session constraints [skip ci]

* [comment audit] Condense mobile route and lifecycle rationale [skip ci]

* [comment audit] Tighten mobile shell hook timing rationale [skip ci]

* [comment audit] Clarify route handoff and notification compatibility [skip ci]

* [comment audit] Clarify push registration and dictation cleanup constraints [skip ci]

* [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci]

* [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci]

* [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci]

* [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci]

* [comment audit] Tighten stream replay and queued session ownership rationale [skip ci]

* [comment audit] Condense mobile composer and reply-schema constraints [skip ci]

* [comment audit] Clarify mobile transcript recovery and session ownership [skip ci]

* [comment audit] Clarify mobile queue and source-control compatibility [skip ci]

* [comment audit] Condense storage and task launch constraints [skip ci]

* [comment audit] Clarify task schemas and terminal document ownership [skip ci]

* [comment audit] Tighten terminal input and viewport rationale [skip ci]

* [comment audit] Shorten terminal and RPC recorder contracts [skip ci]

* [comment audit] Condense recorder engine and connection contracts [skip ci]

* [comment audit] Shorten host credential and relay recovery comments [skip ci]

* [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci]

* [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci]

* [comment audit] Condense catalog and relay entry contracts [skip ci]

* [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci]

* [comment audit] Condense WSL relay and E2E regression rationale [skip ci]

* [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci]

* [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci]

* [comment audit] Shorten layout oracle and regression explanations [skip ci]

* [comment audit] Condense paired recovery and ownership regression contracts [skip ci]

* [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci]

* [comment audit] Condense terminal rendering and IME regression rationale [skip ci]

* [comment audit] Shorten terminal recovery and benchmark contracts [skip ci]

* [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci]

* [comment audit] Condense workflow routing and release rationale [skip ci]

* [comment audit] Condense mobile security and installer contracts [skip ci]

* [comment audit] Shorten native rationale and stylesheet workarounds [skip ci]

* [comment audit] Shorten Windows visibility probe rationale [skip ci]

* [comment audit] Condense embedded release and signing rationale [skip ci]

* [comment audit] Retain current main test behavior in final reconciliation [skip ci]

* [comment audit] Complete final comment review across current source inventory

* [comment audit] Handle consolidated audit reports before final CI

* [comment audit] Preserve credential storage census detail [skip ci]

* [comment audit] Scale broad changed E2E selections with verified timing shards

* [comment audit] Align E2E assertions with browser close and settled session metadata

* [comment audit] Match existing E2Es to current resource and windowing contracts

* [comment audit] Observe the shared server outage notice in reconnect coverage

* [comment audit] Make existing renderer probes measure settled tab and PTY work

* [comment audit] Align palette E2E selector with task search

* [comment audit] Refresh remaining palette search selectors

* [comment audit] Keep selector correction within selected audit specs

* [comment audit] Await emoji fixture settings inside renderer evaluation

* [comment audit] Cancel fixture fit retries between terminal tests

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-25 04:05:46 -07:00

806 lines
28 KiB
TypeScript

import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { parsePairingCode } from '../../shared/pairing'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types'
import { OrcaRuntimeService } from './orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
import {
activeTabId,
authenticate,
CLIENT_A2_WORKTREE_ID,
CLIENT_A_WORKTREE_ID,
CLIENT_B_WORKTREE_ID,
createReader,
FOLDER_REPO_ID,
HOST_WORKTREE_ID,
makeStore,
REPO_ID,
resultType,
seedSessionTabs,
send,
SESSION_WORKTREE_ID,
snapshotVersion,
worktreeId,
type PairedSession,
type ResponseReader
} from './paired-client-navigation-test-harness'
vi.mock('../git/worktree', () => {
const worktrees = ['host', 'client-a', 'client-a2', 'client-b', 'session'].map((name) => ({
path: `/tmp/${name}`,
head: name,
branch: name,
isBare: false,
isMainWorktree: false
}))
return {
listWorktrees: vi.fn().mockResolvedValue(worktrees),
listWorktreesStrict: vi.fn().mockResolvedValue(worktrees)
}
})
describe('paired runtime navigation isolation', () => {
const servers: OrcaRuntimeRpcServer[] = []
const sessions: PairedSession[] = []
const readers: ResponseReader[] = []
afterEach(async () => {
for (const reader of readers.splice(0)) {
reader.dispose()
}
for (const session of sessions.splice(0)) {
session.ws.close()
}
await Promise.all(servers.splice(0).map((server) => server.stop()))
})
async function startHarness(options: { headless?: boolean } = {}) {
const hostSelections = { worktreeId: HOST_WORKTREE_ID, tabId: 'host-tab' }
const activateWorktree = vi.fn((_repoId: string, nextWorktreeId: string) => {
hostSelections.worktreeId = nextWorktreeId
})
const focusTerminal = vi.fn((nextTabId: string) => {
hostSelections.tabId = nextTabId
})
const runtime = new OrcaRuntimeService(makeStore() as never)
if (!options.headless) {
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree,
createTerminal: vi.fn(),
revealTerminalSession: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal,
closeTerminal: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
seedSessionTabs(runtime)
}
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-navigation-isolation-'))
onTestFinished(() => rmSync(userDataPath, { recursive: true, force: true }))
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0
})
servers.push(server)
await server.start()
const offerA = server.createPairingOffer({
address: '127.0.0.1',
name: 'client-a',
scope: 'runtime'
})
if (!offerA.available) {
throw new Error('pairing_unavailable')
}
const clientA = await authenticate(offerA.pairingUrl)
sessions.push(clientA)
const offerB = server.createPairingOffer({
address: '127.0.0.1',
name: 'client-b',
scope: 'runtime'
})
if (!offerB.available) {
throw new Error('pairing_unavailable')
}
const clientB = await authenticate(offerB.pairingUrl)
sessions.push(clientB)
expect(offerA.deviceId).not.toBe(offerB.deviceId)
expect(parsePairingCode(offerA.pairingUrl)?.pairedDeviceId).toBe(offerA.deviceId)
expect(parsePairingCode(offerB.pairingUrl)?.pairedDeviceId).toBe(offerB.deviceId)
expect(parsePairingCode(offerA.pairingUrl)?.deviceToken).not.toBe(
parsePairingCode(offerB.pairingUrl)?.deviceToken
)
const readerA = createReader(clientA)
const readerB = createReader(clientB)
readers.push(readerA, readerB)
return {
runtime,
hostSelections,
activateWorktree,
focusTerminal,
clientA,
clientB,
deviceIdA: offerA.deviceId,
deviceIdB: offerB.deviceId,
pairingUrlA: offerA.pairingUrl,
pairingUrlB: offerB.pairingUrl,
readerA,
readerB
}
}
it('attributes workspace creation to each authenticated device across reconnect', async () => {
const harness = await startHarness()
const createManagedWorktree = vi
.spyOn(harness.runtime, 'createManagedWorktree')
.mockImplementation(async (args) => {
const creatorProvenance = (args as unknown as Record<string, unknown>).creatorProvenance
return {
worktree: {
id: worktreeId(args.name),
creatorProvenance
}
} as never
})
send(harness.clientA, {
id: 'status-a',
method: 'status.get'
})
send(harness.clientB, {
id: 'status-b',
method: 'status.get'
})
const [statusA, statusB] = await Promise.all([
harness.readerA.next('status-a'),
harness.readerB.next('status-b')
])
expect(statusA).toMatchObject({
ok: true,
result: { pairedDeviceId: harness.deviceIdA }
})
expect(statusB).toMatchObject({
ok: true,
result: { pairedDeviceId: harness.deviceIdB }
})
send(harness.clientA, {
id: 'create-a',
method: 'worktree.create',
params: {
repo: REPO_ID,
name: 'created-a',
creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB }
}
})
send(harness.clientB, {
id: 'create-b',
method: 'worktree.create',
params: { repo: REPO_ID, name: 'created-b' }
})
await Promise.all([harness.readerA.next('create-a'), harness.readerB.next('create-b')])
expect(createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
name: 'created-a',
creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdA }
})
)
expect(createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
name: 'created-b',
creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB }
})
)
await new Promise<void>((resolve) => {
harness.clientA.ws.once('close', () => resolve())
harness.clientA.ws.close()
})
const reconnectedA = await authenticate(harness.pairingUrlA)
sessions.push(reconnectedA)
const reconnectedReaderA = createReader(reconnectedA)
readers.push(reconnectedReaderA)
send(reconnectedA, { id: 'status-a2', method: 'status.get' })
await expect(reconnectedReaderA.next('status-a2')).resolves.toMatchObject({
ok: true,
result: { pairedDeviceId: harness.deviceIdA }
})
})
it('keeps worktree navigation local to each paired runtime client by default', async () => {
const harness = await startHarness()
const clientSelections = {
a: CLIENT_A_WORKTREE_ID,
b: CLIENT_B_WORKTREE_ID
}
for (const [session, id] of [
[harness.clientA, 'events-a'],
[harness.clientB, 'events-b']
] as const) {
send(session, { id, method: 'runtime.clientEvents.subscribe' })
}
await Promise.all([
harness.readerA.next('events-a', (response) => resultType(response) === 'ready'),
harness.readerB.next('events-b', (response) => resultType(response) === 'ready')
])
clientSelections.a = CLIENT_A2_WORKTREE_ID
send(harness.clientA, {
id: 'activate-a2',
method: 'worktree.activate',
params: {
worktree: `id:${CLIENT_A2_WORKTREE_ID}`,
notifyClients: true
}
})
await expect(harness.readerA.next('activate-a2')).resolves.toMatchObject({ ok: true })
harness.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID)
const [nextA, nextB] = await Promise.all([
harness.readerA.next('events-a'),
harness.readerB.next('events-b')
])
expect([resultType(nextA), resultType(nextB)]).toEqual(['worktreesChanged', 'worktreesChanged'])
expect(clientSelections).toEqual({
a: CLIENT_A2_WORKTREE_ID,
b: CLIENT_B_WORKTREE_ID
})
expect(harness.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID)
expect(harness.activateWorktree).not.toHaveBeenCalled()
})
async function subscribeBothClientEventStreams(harness: {
clientA: PairedSession
clientB: PairedSession
readerA: ResponseReader
readerB: ResponseReader
}): Promise<void> {
for (const [session, id] of [
[harness.clientA, 'events-a'],
[harness.clientB, 'events-b']
] as const) {
send(session, { id, method: 'runtime.clientEvents.subscribe' })
}
await Promise.all([
harness.readerA.next('events-a', (response) => resultType(response) === 'ready'),
harness.readerB.next('events-b', (response) => resultType(response) === 'ready')
])
}
it('keeps a paired client workspace create-with-activate off every other client and the host', async () => {
const harness = await startHarness()
await subscribeBothClientEventStreams(harness)
for (const [index, target] of [
CLIENT_A2_WORKTREE_ID,
CLIENT_A_WORKTREE_ID,
CLIENT_A2_WORKTREE_ID
].entries()) {
send(harness.clientA, {
id: `nav-${index}`,
method: 'worktree.activate',
params: { worktree: `id:${target}` }
})
await expect(harness.readerA.next(`nav-${index}`)).resolves.toMatchObject({ ok: true })
}
send(harness.clientA, {
id: 'create-with-agent',
method: 'worktree.create',
params: {
repo: FOLDER_REPO_ID,
name: 'teammate-agent-workspace',
activate: true
}
})
await expect(harness.readerA.next('create-with-agent')).resolves.toMatchObject({ ok: true })
// Ordered shared-catalog event fences the assertion: any erroneous activation would arrive before it.
harness.runtime.notifyReposChangedForRemoteClients()
const observed: Record<'a' | 'b', string[]> = { a: [], b: [] }
for (const [key, reader, id] of [
['a', harness.readerA, 'events-a'],
['b', harness.readerB, 'events-b']
] as const) {
for (;;) {
const type = resultType(await reader.next(id))
observed[key].push(type ?? 'unknown')
if (type === 'reposChanged' || type === 'activateWorktree') {
break
}
}
}
expect(observed.b).not.toContain('activateWorktree')
expect(observed.b.at(-1)).toBe('reposChanged')
expect(harness.activateWorktree).not.toHaveBeenCalled()
expect(harness.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID)
expect(observed.a).not.toContain('activateWorktree')
expect(observed.a.at(-1)).toBe('reposChanged')
expect(observed.a).toContain('worktreesChanged')
expect(observed.b).toContain('worktreesChanged')
})
it('does not replay a missed create activation to a reconnecting observer', async () => {
const harness = await startHarness()
await subscribeBothClientEventStreams(harness)
harness.readerB.dispose()
await new Promise<void>((resolve) => {
harness.clientB.ws.once('close', () => resolve())
harness.clientB.ws.close()
})
// navigation: all proves an activation occurred while B was offline; caller-scoped creates emit none.
send(harness.clientA, {
id: 'create-while-b-offline',
method: 'worktree.create',
params: {
repo: FOLDER_REPO_ID,
name: 'offline-observer-workspace',
activate: true,
navigation: 'all'
}
})
await expect(harness.readerA.next('create-while-b-offline')).resolves.toMatchObject({
ok: true
})
expect(
resultType(
await harness.readerA.next(
'events-a',
(response) => resultType(response) === 'activateWorktree'
)
)
).toBe('activateWorktree')
const reconnectedB = await authenticate(harness.pairingUrlB)
sessions.push(reconnectedB)
const reconnectedReaderB = createReader(reconnectedB)
readers.push(reconnectedReaderB)
send(reconnectedB, { id: 'events-b2', method: 'runtime.clientEvents.subscribe' })
await reconnectedReaderB.next('events-b2', (response) => resultType(response) === 'ready')
// Ready snapshots must never retain live navigation intent and replay it on reconnect.
harness.runtime.notifyReposChangedForRemoteClients()
const observed: string[] = []
for (;;) {
const type = resultType(await reconnectedReaderB.next('events-b2'))
observed.push(type ?? 'unknown')
if (type === 'reposChanged' || type === 'activateWorktree') {
break
}
}
expect(observed).not.toContain('activateWorktree')
expect(observed.at(-1)).toBe('reposChanged')
})
it('still reveals to every client when a paired caller asks for all-surface navigation', async () => {
const harness = await startHarness()
await subscribeBothClientEventStreams(harness)
send(harness.clientA, {
id: 'cli-shaped-create',
method: 'worktree.create',
params: {
repo: FOLDER_REPO_ID,
name: 'cli-shaped-workspace',
activate: true,
navigation: 'all'
}
})
await expect(harness.readerA.next('cli-shaped-create')).resolves.toMatchObject({ ok: true })
const [eventA, eventB] = await Promise.all([
harness.readerA.next('events-a', (response) => resultType(response) === 'activateWorktree'),
harness.readerB.next('events-b', (response) => resultType(response) === 'activateWorktree')
])
expect([resultType(eventA), resultType(eventB)]).toEqual([
'activateWorktree',
'activateWorktree'
])
expect(harness.activateWorktree).toHaveBeenCalled()
expect(eventA).toMatchObject({ result: { navigation: 'all' } })
expect(eventB).toMatchObject({ result: { navigation: 'all' } })
})
it('keeps create activation caller-scoped on a headless orca serve host', async () => {
const harness = await startHarness({ headless: true })
await subscribeBothClientEventStreams(harness)
send(harness.clientA, {
id: 'headless-create',
method: 'worktree.create',
params: { repo: FOLDER_REPO_ID, name: 'headless-agent-workspace', activate: true }
})
await expect(harness.readerA.next('headless-create')).resolves.toMatchObject({ ok: true })
harness.runtime.notifyReposChangedForRemoteClients()
const observed: string[] = []
for (;;) {
const type = resultType(await harness.readerB.next('events-b'))
observed.push(type ?? 'unknown')
if (type === 'reposChanged' || type === 'activateWorktree') {
break
}
}
expect(observed).not.toContain('activateWorktree')
expect(observed).toContain('worktreesChanged')
await harness.runtime.createManagedWorktree({
repoSelector: `id:${FOLDER_REPO_ID}`,
name: 'headless-cli-workspace',
activate: true
})
harness.runtime.notifyReposChangedForRemoteClients()
const afterCliCreate: string[] = []
for (;;) {
const type = resultType(await harness.readerB.next('events-b'))
afterCliCreate.push(type ?? 'unknown')
if (type === 'reposChanged' || type === 'activateWorktree') {
break
}
}
expect(afterCliCreate).not.toContain('activateWorktree')
expect(afterCliCreate).toContain('worktreesChanged')
})
it('normalizes a paired focused terminal.create before host-renderer activation', async () => {
const harness = await startHarness()
const created = { handle: 'term-b', worktreeId: CLIENT_B_WORKTREE_ID, title: null }
const createTerminal = vi
.spyOn(harness.runtime, 'createTerminal')
.mockImplementation(async (_worktree, options) => {
if (options?.presentation === 'focused') {
harness.hostSelections.worktreeId = CLIENT_B_WORKTREE_ID
}
return created as never
})
vi.spyOn(harness.runtime, 'dedupeTerminalCreate').mockImplementation(
async (_owner, worktree, _mutationId, _reconcile, run) => run(worktree, undefined)
)
send(harness.clientB, {
id: 'terminal-create-b',
method: 'terminal.create',
params: {
worktree: `id:${CLIENT_B_WORKTREE_ID}`,
presentation: 'focused'
}
})
await expect(harness.readerB.next('terminal-create-b')).resolves.toMatchObject({
ok: true,
result: { terminal: created }
})
expect(createTerminal).toHaveBeenCalledWith(
`id:${CLIENT_B_WORKTREE_ID}`,
expect.objectContaining({ presentation: 'background', focus: false, activate: false })
)
expect(harness.hostSelections).toEqual({
worktreeId: HOST_WORKTREE_ID,
tabId: 'host-tab'
})
})
it.each([false, true])(
'keeps host create activation off paired observers (runHooks=%s)',
async (runHooks) => {
const harness = await startHarness()
await subscribeBothClientEventStreams(harness)
await harness.runtime.createManagedWorktree({
repoSelector: `id:${FOLDER_REPO_ID}`,
name: 'cli-created-workspace',
activate: !runHooks,
runHooks
})
harness.runtime.notifyReposChangedForRemoteClients()
for (const [reader, id] of [
[harness.readerA, 'events-a'],
[harness.readerB, 'events-b']
] as const) {
const observed: string[] = []
for (;;) {
const type = resultType(await reader.next(id))
observed.push(type ?? 'unknown')
if (type === 'reposChanged' || type === 'activateWorktree') {
break
}
}
expect(observed).not.toContain('activateWorktree')
expect(observed).toContain('worktreesChanged')
}
expect(harness.activateWorktree).toHaveBeenCalled()
}
)
it('keeps host worktree activation off paired observers', async () => {
const harness = await startHarness()
await subscribeBothClientEventStreams(harness)
await harness.runtime.activateManagedWorktree(`id:${CLIENT_A_WORKTREE_ID}`)
harness.runtime.notifyReposChangedForRemoteClients()
expect(resultType(await harness.readerA.next('events-a'))).toBe('reposChanged')
expect(resultType(await harness.readerB.next('events-b'))).toBe('reposChanged')
expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID)
})
it('projects session-tab activation only to the paired caller across fanout and reconnect', async () => {
const harness = await startHarness()
send(harness.clientA, {
id: 'select-a',
method: 'session.tabs.activate',
params: {
worktree: `id:${SESSION_WORKTREE_ID}`,
tabId: 'client-a-tab',
navigation: 'caller',
notifyClients: false
}
})
send(harness.clientB, {
id: 'select-b',
method: 'session.tabs.activate',
params: {
worktree: `id:${SESSION_WORKTREE_ID}`,
tabId: 'client-b-tab',
navigation: 'caller',
notifyClients: false
}
})
expect(activeTabId(await harness.readerA.next('select-a'))).toBe('client-a-tab')
expect(activeTabId(await harness.readerB.next('select-b'))).toBe('client-b-tab')
for (const [session, id] of [
[harness.clientA, 'tabs-a'],
[harness.clientB, 'tabs-b']
] as const) {
send(session, {
id,
method: 'session.tabs.subscribe',
params: { worktree: `id:${SESSION_WORKTREE_ID}` }
})
}
expect(activeTabId(await harness.readerA.next('tabs-a'))).toBe('client-a-tab')
expect(activeTabId(await harness.readerB.next('tabs-b'))).toBe('client-b-tab')
send(harness.clientA, {
id: 'select-a2',
method: 'session.tabs.activate',
params: {
worktree: `id:${SESSION_WORKTREE_ID}`,
tabId: 'client-a2-tab',
navigation: 'caller',
notifyClients: false
}
})
const selectA2 = await harness.readerA.next('select-a2')
expect(activeTabId(selectA2)).toBe('client-a2-tab')
harness.runtime.notifyMobileSessionTabsChanged(SESSION_WORKTREE_ID)
const [updateA, updateB] = await Promise.all([
harness.readerA.next(
'tabs-a',
(response) =>
resultType(response) === 'updated' &&
snapshotVersion(response) >= snapshotVersion(selectA2)
),
harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated')
])
expect(activeTabId(updateA)).toBe('client-a2-tab')
expect(activeTabId(updateB)).toBe('client-b-tab')
expect(harness.hostSelections.tabId).toBe('host-tab')
expect(harness.focusTerminal).not.toHaveBeenCalled()
expect(
(await harness.runtime.listMobileSessionTabs(`id:${SESSION_WORKTREE_ID}`)).activeTabId
).toBe('host-tab')
harness.readerA.dispose()
harness.clientA.ws.close()
const reconnectedA = await authenticate(harness.pairingUrlA)
sessions.push(reconnectedA)
const reconnectedReaderA = createReader(reconnectedA)
readers.push(reconnectedReaderA)
send(reconnectedA, {
id: 'tabs-a-reconnected',
method: 'session.tabs.list',
params: { worktree: `id:${SESSION_WORKTREE_ID}` }
})
expect(activeTabId(await reconnectedReaderA.next('tabs-a-reconnected'))).toBe('client-a2-tab')
})
it('routes explicit host and paired-client follow intent without changing the default', async () => {
const harness = await startHarness()
for (const [session, id] of [
[harness.clientA, 'events-a'],
[harness.clientB, 'events-b']
] as const) {
send(session, { id, method: 'runtime.clientEvents.subscribe' })
}
await Promise.all([
harness.readerA.next('events-a', (response) => resultType(response) === 'ready'),
harness.readerB.next('events-b', (response) => resultType(response) === 'ready')
])
send(harness.clientA, {
id: 'host-follow',
method: 'worktree.activate',
params: { worktree: `id:${CLIENT_A_WORKTREE_ID}`, navigation: 'host' }
})
await harness.readerA.next('host-follow')
harness.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID)
expect(resultType(await harness.readerA.next('events-a'))).toBe('worktreesChanged')
expect(resultType(await harness.readerB.next('events-b'))).toBe('worktreesChanged')
expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID)
send(harness.clientB, {
id: 'clients-follow',
method: 'worktree.activate',
params: { worktree: `id:${CLIENT_B_WORKTREE_ID}`, navigation: 'clients' }
})
await harness.readerB.next('clients-follow')
const [eventA, eventB] = await Promise.all([
harness.readerA.next('events-a'),
harness.readerB.next('events-b')
])
expect([resultType(eventA), resultType(eventB)]).toEqual([
'activateWorktree',
'activateWorktree'
])
expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID)
for (const [session, id] of [
[harness.clientA, 'tabs-a'],
[harness.clientB, 'tabs-b']
] as const) {
send(session, {
id,
method: 'session.tabs.subscribe',
params: { worktree: `id:${SESSION_WORKTREE_ID}` }
})
}
await Promise.all([harness.readerA.next('tabs-a'), harness.readerB.next('tabs-b')])
send(harness.clientA, {
id: 'tabs-host-follow',
method: 'session.tabs.activate',
params: {
worktree: `id:${SESSION_WORKTREE_ID}`,
tabId: 'client-a-tab',
navigation: 'host'
}
})
expect(activeTabId(await harness.readerA.next('tabs-host-follow'))).toBe('client-a-tab')
expect(
activeTabId(
await harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated')
)
).toBe('client-a-tab')
expect(harness.hostSelections.tabId).toBe('client-a-tab')
send(harness.clientB, {
id: 'tabs-clients-follow',
method: 'session.tabs.activate',
params: {
worktree: `id:${SESSION_WORKTREE_ID}`,
tabId: 'client-b-tab',
navigation: 'clients'
}
})
await harness.readerB.next('tabs-clients-follow')
const [tabsA, tabsB] = await Promise.all([
harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'),
harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated')
])
expect([activeTabId(tabsA), activeTabId(tabsB)]).toEqual(['client-b-tab', 'client-b-tab'])
expect(
[tabsA, tabsB].map(
(response) =>
(response.result as RuntimeMobileSessionTabsResult | undefined)?.navigationIntent
)
).toEqual(['follow', 'follow'])
expect(harness.hostSelections.tabId).toBe('client-a-tab')
})
it('isolates crossed clients across two runtime servers', async () => {
const serverOne = await startHarness()
const serverTwo = await startHarness()
for (const [session, id] of [
[serverTwo.clientA, 'server-two-events-a'],
[serverTwo.clientB, 'server-two-events-b']
] as const) {
send(session, { id, method: 'runtime.clientEvents.subscribe' })
}
await Promise.all([
serverTwo.readerA.next('server-two-events-a', (response) => resultType(response) === 'ready'),
serverTwo.readerB.next('server-two-events-b', (response) => resultType(response) === 'ready')
])
send(serverOne.clientA, {
id: 'server-one-worktree',
method: 'worktree.activate',
params: { worktree: `id:${CLIENT_A2_WORKTREE_ID}`, notifyClients: true }
})
await serverOne.readerA.next('server-one-worktree')
serverTwo.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID)
expect(resultType(await serverTwo.readerA.next('server-two-events-a'))).toBe('worktreesChanged')
expect(resultType(await serverTwo.readerB.next('server-two-events-b'))).toBe('worktreesChanged')
expect(serverOne.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID)
expect(serverTwo.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID)
for (const [session, reader, requestId, tabId] of [
[serverOne.clientA, serverOne.readerA, 's1-a', 'client-a2-tab'],
[serverOne.clientB, serverOne.readerB, 's1-b', 'client-b-tab'],
[serverTwo.clientA, serverTwo.readerA, 's2-a', 'client-a-tab'],
[serverTwo.clientB, serverTwo.readerB, 's2-b', 'host-tab']
] as const) {
send(session, {
id: requestId,
method: 'session.tabs.activate',
params: {
worktree: `id:${SESSION_WORKTREE_ID}`,
tabId,
navigation: 'caller',
notifyClients: false
}
})
expect(activeTabId(await reader.next(requestId))).toBe(tabId)
}
expect(serverOne.hostSelections.tabId).toBe('host-tab')
expect(serverTwo.hostSelections.tabId).toBe('host-tab')
})
it('restores a device tab selection after a runtime restart', async () => {
const persisted: { state: PersistedMobileClientTabSelections } = { state: {} }
const makeStoreWithSelections = () => ({
...makeStore(),
getMobileClientTabSelections: () => persisted.state,
setMobileClientTabSelections: (next: PersistedMobileClientTabSelections) => {
persisted.state = next
}
})
const first = new OrcaRuntimeService(makeStoreWithSelections() as never)
first.attachWindow(1)
first.markGraphReady(1)
seedSessionTabs(first)
await first.activateMobileSessionTab(`id:${SESSION_WORKTREE_ID}`, 'client-a-tab', undefined, {
notifyClients: false,
clientNavigationId: 'device-a',
navigation: 'caller'
})
expect(persisted.state['device-a']?.[SESSION_WORKTREE_ID]?.activeTabId).toBe('client-a-tab')
const restarted = new OrcaRuntimeService(makeStoreWithSelections() as never)
restarted.attachWindow(1)
restarted.markGraphReady(1)
seedSessionTabs(restarted)
const remembered = await restarted.listMobileSessionTabs(
`id:${SESSION_WORKTREE_ID}`,
'device-a'
)
expect(remembered.activeTabId).toBe('client-a-tab')
expect(remembered.tabs.find((tab) => tab.isActive)?.id).toBe('client-a-tab')
// Unknown devices inherit the host view, never another device's restored state.
const freshDevice = await restarted.listMobileSessionTabs(
`id:${SESSION_WORKTREE_ID}`,
'device-b'
)
expect(freshDevice.activeTabId).toBe('host-tab')
})
})