test(terminal): pin layout invariants the mirror refactor must keep (#26073)

This commit is contained in:
Jinwoo Hong
2026-10-07 14:46:12 -04:00
committed by GitHub
parent a0026588e4
commit 7bea20d83a
5 changed files with 1102 additions and 0 deletions
@@ -0,0 +1,167 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { buildAgentResumeStartupPlan } from '../../../shared/tui-agent-resume-startup'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection'
import type { Store } from '../loading-store/store'
import {
emptyTerminalSessionProfile,
FIXTURE_GIT_WORKTREE_ID as WORKTREE,
openTopologyStore,
reopenTopologyStore
} from './terminal-topology-profile-fixture'
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
// The mirror refactor moves sleeping records into main's own commits and makes window saves
// presentation-only; a slept agent must still be resumable after quit and relaunch.
const TAB = 'tab-agent'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PTY = `${WORKTREE}@@0a1b2c3d`
const PANE_KEY = `${TAB}:${LEAF}`
const directories: string[] = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function record(origin: 'worktree-sleep' | 'quit', capturedAt: number): SleepingAgentSessionRecord {
return {
paneKey: PANE_KEY,
tabId: TAB,
worktreeId: WORKTREE,
agent: 'codex',
providerSession: { key: 'session_id', id: `session-${capturedAt}` },
prompt: 'finish the task',
state: 'waiting',
capturedAt,
updatedAt: capturedAt,
origin
}
}
/** The window's session after it spawned an agent pane, as its debounced save sends it. */
function windowSessionWithAgentPane(store: Store): WorkspaceSessionState {
const session = structuredClone(store.getWorkspaceSession())
session.tabsByWorktree = {
...session.tabsByWorktree,
[WORKTREE]: [
{
id: TAB,
ptyId: PTY,
worktreeId: WORKTREE,
title: 'codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
launchAgent: 'codex'
}
]
}
session.terminalLayoutsByTabId = {
...session.terminalLayoutsByTabId,
[TAB]: {
root: { type: 'leaf', leafId: LEAF },
activeLeafId: LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF]: PTY }
}
}
return session
}
async function storeWithAgentPane() {
const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-quit-resume-'))
directories.push(directory)
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
const window = windowSessionWithAgentPane(store)
store.setWorkspaceSession(structuredClone(window))
await expect(
store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: LEAF, ptyId: PTY })
).resolves.toBe(true)
return { directory, store, window }
}
/** What main holds for the slept pane, and what the window would show from its slice. */
function heldForPane(store: Store) {
const session = store.getWorkspaceSession()
const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, WORKTREE)
return {
record: session.sleepingAgentSessionsByPaneKey?.[PANE_KEY],
sliceRecord: slice.sleeping[PANE_KEY],
tabs: slice.tabs.map((tab) => tab.id),
binding: slice.layouts[TAB]?.ptyIdsByLeafId?.[LEAF]
}
}
function resumeCommand(held: SleepingAgentSessionRecord | undefined): string | undefined {
return held
? buildAgentResumeStartupPlan({
agent: held.agent,
providerSession: held.providerSession,
cmdOverrides: {},
platform: 'linux'
})?.launchCommand
: undefined
}
describe('sleep → quit → resume', () => {
it('keeps the quit capture over the periodic one through quit and relaunch', async () => {
const { directory, store, window } = await storeWithAgentPane()
// The agent is slept; the periodic save carries the first capture.
window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('worktree-sleep', 1) }
store.setWorkspaceSession(structuredClone(window))
// Quit: the window's quit capture rides only on the synchronous before-unload stage.
window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 2) }
store.stageWorkspaceSessionBeforeUnload(structuredClone(window))
const relaunched = await reopenTopologyStore(store, directory)
try {
const expected = {
record: record('quit', 2),
sliceRecord: record('quit', 2),
tabs: [TAB],
binding: PTY
}
expect(heldForPane(relaunched)).toEqual(expected)
// The relaunched window's first save is its hydrated copy; the record stays.
relaunched.setWorkspaceSession(structuredClone(relaunched.getWorkspaceSession()))
expect(heldForPane(relaunched)).toEqual(expected)
expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-2'")
} finally {
await relaunched.freezeWritesAsync()
}
})
it('keeps a record that only the before-unload stage carried', async () => {
const { directory, store, window } = await storeWithAgentPane()
window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 3) }
store.stageWorkspaceSessionBeforeUnload(structuredClone(window))
const relaunched = await reopenTopologyStore(store, directory)
try {
expect(heldForPane(relaunched)).toEqual({
record: record('quit', 3),
sliceRecord: record('quit', 3),
tabs: [TAB],
binding: PTY
})
expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-3'")
} finally {
await relaunched.freezeWritesAsync()
}
})
})
@@ -0,0 +1,529 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mulberry32 } from '../../../shared/agent-tui-ansi-fuzz-stream'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection'
import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target'
import { collectTerminalLeafOwners, isSameTerminal } from './terminal-owner-invariants'
import { closeLeafOrTab } from './terminal-topology-commit'
import {
emptyTerminalSessionProfile,
FIXTURE_FOLDER_WORKTREE_ID,
FIXTURE_GIT_WORKTREE_ID,
openTopologyStore,
reopenTopologyStore
} from './terminal-topology-profile-fixture'
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
/**
* Model test for terminal layout as main holds it. Seeded gesture sequences run against main's
* real Store, written the way today's desktop window writes them; a plain reference model says
* what main must hold after each one. Making the window a mirror of main changes who writes, not
* this outcome, so every seed must stay green through that refactor.
* Replay one seed: FUZZ_SEED=<n> FUZZ_ITERATIONS=1 pnpm test <this file>.
*/
const WORKTREES = [
FIXTURE_GIT_WORKTREE_ID,
FIXTURE_FOLDER_WORKTREE_ID,
FLOATING_TERMINAL_WORKTREE_ID
]
const FIRST_SEED = Number(process.env.FUZZ_SEED ?? 0x9417)
const ITERATIONS = Number(process.env.FUZZ_ITERATIONS ?? 6)
const OPS_PER_SEED = 40
type ModelPane = { ptyId: string | undefined; sleeping: boolean }
/** What main must hold: tab → leaf → binding, per worktree, in tab order. */
type Model = Map<string, { worktreeId: string; leaves: Map<string, ModelPane> }>
const directories: string[] = []
function makeIds(random: () => number) {
const hex = (length: number): string =>
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join('')
return {
leaf: () => `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`,
tab: () => `tab-${hex(8)}`,
pty: (worktreeId: string) => `${worktreeId}@@${hex(8)}`
}
}
function leafIds(node: TerminalPaneLayoutNode | null | undefined): string[] {
if (!node) {
return []
}
return node.type === 'leaf' ? [node.leafId] : [...leafIds(node.first), ...leafIds(node.second)]
}
function withoutLeaf(node: TerminalPaneLayoutNode, leafId: string): TerminalPaneLayoutNode | null {
if (node.type === 'leaf') {
return node.leafId === leafId ? null : node
}
const first = withoutLeaf(node.first, leafId)
const second = withoutLeaf(node.second, leafId)
if (!first || !second) {
return first ?? second
}
return { ...node, first, second }
}
function sleepingRecord(
worktreeId: string,
tabId: string,
leafId: string
): SleepingAgentSessionRecord {
return {
paneKey: `${tabId}:${leafId}`,
tabId,
worktreeId,
agent: 'codex',
providerSession: { key: 'session_id', id: `session-${leafId}` },
prompt: 'model',
state: 'waiting',
capturedAt: 1,
updatedAt: 1,
origin: 'quit'
}
}
/** The desktop window's copy of the session: it authors tabs and layouts and saves them whole. */
class WindowSession {
constructor(public session: WorkspaceSessionState) {}
addTab(worktreeId: string, tabId: string, leafId: string): void {
const tabs = this.session.tabsByWorktree[worktreeId] ?? []
const tab: TerminalTab = {
id: tabId,
ptyId: null,
worktreeId,
title: `Terminal ${tabs.length + 1}`,
customTitle: null,
color: null,
sortOrder: tabs.length,
createdAt: 1
}
this.session.tabsByWorktree = { ...this.session.tabsByWorktree, [worktreeId]: [...tabs, tab] }
this.setLayout(tabId, { type: 'leaf', leafId }, {})
}
setLayout(tabId: string, root: TerminalPaneLayoutNode, ptyIdsByLeafId: Record<string, string>) {
this.session.terminalLayoutsByTabId = {
...this.session.terminalLayoutsByTabId,
[tabId]: {
root,
activeLeafId: leafIds(root)[0] ?? null,
expandedLeafId: null,
ptyIdsByLeafId
}
}
}
bind(tabId: string, leafId: string, ptyId: string): void {
const layout = this.session.terminalLayoutsByTabId[tabId]
if (layout?.root) {
this.setLayout(tabId, layout.root, { ...layout.ptyIdsByLeafId, [leafId]: ptyId })
}
this.setTabPtyId(tabId, ptyId)
}
/** `clearTabPtyId` on exit: the tab's live id goes; the layout keeps the binding as a resume hint. */
setTabPtyId(tabId: string, ptyId: string | null): void {
this.session.tabsByWorktree = Object.fromEntries(
Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.map((tab) => (tab.id === tabId ? { ...tab, ptyId } : tab))
])
)
}
removeTab(tabId: string): void {
this.session.tabsByWorktree = Object.fromEntries(
Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.filter((tab) => tab.id !== tabId)
])
)
const { [tabId]: _removed, ...layouts } = this.session.terminalLayoutsByTabId
void _removed
this.session.terminalLayoutsByTabId = layouts
this.setSleeping(
Object.fromEntries(
Object.entries(this.session.sleepingAgentSessionsByPaneKey ?? {}).filter(
([paneKey]) => !paneKey.startsWith(`${tabId}:`)
)
)
)
}
setSleeping(records: Record<string, SleepingAgentSessionRecord>): void {
this.session.sleepingAgentSessionsByPaneKey = records
}
snapshot(): WorkspaceSessionState {
return structuredClone(this.session)
}
}
/** Fails on a terminal bound to two leaves or a leaf in two tabs, naming both owners. */
function expectOwnerInvariants(session: WorkspaceSessionState, context: string): void {
const owners = collectTerminalLeafOwners({ hostId: LOCAL_EXECUTION_HOST_ID, session })
const describe = (owner: (typeof owners)[number]): string =>
`${owner.worktreeId} ${owner.tab.id}:${owner.leafId} → ${owner.ptyId ?? 'unbound'}`
const breaches: string[] = []
for (const [index, left] of owners.entries()) {
for (const right of owners.slice(index + 1)) {
if (left.leafId === right.leafId && left.tab.id !== right.tab.id) {
breaches.push(`leaf in two tabs: ${describe(left)} | ${describe(right)}`)
} else if (left.leafId !== right.leafId && isSameTerminal(left, right)) {
breaches.push(`terminal on two leaves: ${describe(left)} | ${describe(right)}`)
}
}
}
expect(breaches, context).toEqual([])
}
/** Main's projected slices, reduced to what the model states. */
function projectedTopology(session: WorkspaceSessionState) {
return Object.fromEntries(
WORKTREES.map((worktreeId) => {
const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, worktreeId)
const tabs = slice.tabs.map((tab) => {
const layout = slice.layouts[tab.id]
return {
tabId: tab.id,
leaves: Object.fromEntries(
leafIds(layout?.root).map((leafId) => [leafId, layout?.ptyIdsByLeafId?.[leafId]])
)
}
})
return [worktreeId, { tabs, sleeping: Object.keys(slice.sleeping).sort() }]
})
)
}
function expectedTopology(model: Model) {
return Object.fromEntries(
WORKTREES.map((worktreeId) => {
const entries = [...model.entries()].filter(([, tab]) => tab.worktreeId === worktreeId)
const tabs = entries.map(([tabId, tab]) => ({
tabId,
leaves: Object.fromEntries([...tab.leaves].map(([leafId, pane]) => [leafId, pane.ptyId]))
}))
const sleeping = entries
.flatMap(([tabId, tab]) =>
[...tab.leaves]
.filter(([, pane]) => pane.sleeping)
.map(([leafId]) => `${tabId}:${leafId}`)
)
.sort()
return [worktreeId, { tabs, sleeping }]
})
)
}
type Op =
| 'new_tab'
| 'split'
| 'close_pane'
| 'close_tab'
| 'move_pane'
| 'pty_exit'
| 'respawn'
| 'sleep'
| 'wake'
| 'restart'
| 'quit_restart'
const OPS: readonly Op[] = [
'new_tab',
'new_tab',
'split',
'split',
'close_pane',
'close_tab',
'move_pane',
'pty_exit',
'respawn',
'sleep',
'wake',
'restart',
'quit_restart'
]
async function runSeed(seed: number): Promise<string[]> {
const random = mulberry32(seed)
const pick = <T>(items: readonly T[]): T | undefined =>
items.length === 0 ? undefined : items[Math.floor(random() * items.length)]
const ids = makeIds(random)
const directory = mkdtempSync(join(tmpdir(), 'orca-topology-model-'))
directories.push(directory)
let store = await openTopologyStore(directory, emptyTerminalSessionProfile())
const window = new WindowSession(structuredClone(store.getWorkspaceSession()))
const model: Model = new Map()
const log: string[] = []
const save = (): void => store.setWorkspaceSession(window.snapshot())
const panes = () =>
[...model].flatMap(([tabId, tab]) =>
[...tab.leaves].map(([leafId, pane]) => ({ tabId, leafId, tab, pane }))
)
const splitTabs = () => [...model].filter(([, tab]) => tab.leaves.size > 1)
const bind = async (worktreeId: string, tabId: string, leafId: string, ptyId: string) => {
await expect(store.persistPtyBinding({ worktreeId, tabId, leafId, ptyId })).resolves.toBe(true)
window.bind(tabId, leafId, ptyId)
model.get(tabId)!.leaves.set(leafId, { ptyId, sleeping: false })
}
/** `closeTerminalSurfaceFromRenderer`: the window removed the surface; main commits the close. */
const commitClose = async (worktreeId: string, target: TerminalSurfaceCloseTarget) => {
const refusal = await store.runDurableMutation(
closeLeafOrTab({
worktreeId,
target,
options: { allowMissing: true, force: true, closedByLayoutOwner: true, reason: 'user' },
requestedSession: store.getWorkspaceSession(),
ownerMatches: () => true,
hostId: () => LOCAL_EXECUTION_HOST_ID,
getSession: (hostId) => store.getWorkspaceSession(hostId),
setSession: (session, hostId) => store.setWorkspaceSession(session, hostId),
onClosed: () => {}
})
)
expect(refusal).toBeUndefined()
}
const reopen = async (stageQuit: boolean): Promise<void> => {
if (stageQuit) {
store.stageWorkspaceSessionBeforeUnload(window.snapshot())
}
store = await reopenTopologyStore(store, directory)
window.session = structuredClone(store.getWorkspaceSession())
}
for (let step = 0; step < OPS_PER_SEED; step++) {
const op = pick(OPS)!
switch (op) {
case 'new_tab': {
const worktreeId = pick(WORKTREES)!
const tabId = ids.tab()
const leafId = ids.leaf()
const ptyId = ids.pty(worktreeId)
// pty:spawn can beat the window's debounced layout save.
const spawnFirst = random() < 0.5
log.push(
`${op} ${worktreeId} ${tabId}:${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}`
)
model.set(tabId, { worktreeId, leaves: new Map() })
window.addTab(worktreeId, tabId, leafId)
if (!spawnFirst) {
save()
}
await bind(worktreeId, tabId, leafId, ptyId)
save()
break
}
case 'split': {
const target = pick(panes())
if (!target) {
continue
}
const leafId = ids.leaf()
const ptyId = ids.pty(target.tab.worktreeId)
const spawnFirst = random() < 0.5
const direction = random() < 0.5 ? 'vertical' : 'horizontal'
log.push(
`${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}`
)
const layout = window.session.terminalLayoutsByTabId[target.tabId]!
window.setLayout(
target.tabId,
{
type: 'split',
direction,
first: layout.root!,
second: { type: 'leaf', leafId }
},
layout.ptyIdsByLeafId ?? {}
)
if (!spawnFirst) {
save()
}
await bind(target.tab.worktreeId, target.tabId, leafId, ptyId)
save()
break
}
case 'close_pane': {
const [tabId, tab] = pick(splitTabs()) ?? []
const leafId = tab && pick([...tab.leaves.keys()])
if (!tabId || !tab || !leafId) {
continue
}
log.push(`${op} ${tabId}:${leafId}`)
const layout = window.session.terminalLayoutsByTabId[tabId]!
const { [leafId]: _closed, ...bindings } = layout.ptyIdsByLeafId ?? {}
void _closed
window.setLayout(tabId, withoutLeaf(layout.root!, leafId)!, bindings)
const { [`${tabId}:${leafId}`]: _record, ...sleeping } =
window.session.sleepingAgentSessionsByPaneKey ?? {}
void _record
window.setSleeping(sleeping)
tab.leaves.delete(leafId)
await commitClose(tab.worktreeId, { kind: 'pane', tabId, leafId })
save()
break
}
case 'close_tab': {
const tabId = pick([...model.keys()])
if (!tabId) {
continue
}
log.push(`${op} ${tabId}`)
const { worktreeId } = model.get(tabId)!
window.removeTab(tabId)
model.delete(tabId)
await commitClose(worktreeId, { kind: 'tab', tabId })
save()
break
}
case 'move_pane': {
const [sourceTabId, source] = pick(splitTabs()) ?? []
const leafId = source && pick([...source.leaves.keys()])
if (!sourceTabId || !source || !leafId) {
continue
}
const targetTabId = ids.tab()
const pane = source.leaves.get(leafId)!
log.push(`${op} ${sourceTabId}:${leafId} → ${targetTabId}`)
await expect(
store.moveTerminalLeafToNewTab({
worktreeId: source.worktreeId,
sourceTabId,
targetTabId,
leafId,
ptyId: pane.ptyId ?? null
})
).resolves.toEqual({ status: 'moved', ptyId: pane.ptyId ?? null })
// The window then shows what main moved, as the drag-out does today.
const layout = window.session.terminalLayoutsByTabId[sourceTabId]!
const { [leafId]: movedPty, ...bindings } = layout.ptyIdsByLeafId ?? {}
window.setLayout(sourceTabId, withoutLeaf(layout.root!, leafId)!, bindings)
window.addTab(source.worktreeId, targetTabId, leafId)
if (movedPty) {
window.bind(targetTabId, leafId, movedPty)
}
const records = { ...window.session.sleepingAgentSessionsByPaneKey }
const record = records[`${sourceTabId}:${leafId}`]
if (record) {
delete records[`${sourceTabId}:${leafId}`]
records[`${targetTabId}:${leafId}`] = {
...record,
paneKey: `${targetTabId}:${leafId}`,
tabId: targetTabId
}
window.setSleeping(records)
}
source.leaves.delete(leafId)
model.set(targetTabId, {
worktreeId: source.worktreeId,
leaves: new Map([[leafId, pane]])
})
save()
break
}
case 'pty_exit': {
const target = pick(panes().filter(({ pane }) => pane.ptyId))
if (!target) {
continue
}
log.push(`${op} ${target.tabId}:${target.leafId}`)
// Main keeps an exited pane's binding; only the window's live id goes.
window.setTabPtyId(target.tabId, null)
save()
break
}
case 'respawn': {
const target = pick(panes())
if (!target) {
continue
}
const ptyId = ids.pty(target.tab.worktreeId)
log.push(`${op} ${target.tabId}:${target.leafId} ${ptyId}`)
const sleeping = target.pane.sleeping
await bind(target.tab.worktreeId, target.tabId, target.leafId, ptyId)
target.tab.leaves.get(target.leafId)!.sleeping = sleeping
save()
break
}
case 'sleep':
case 'wake': {
const target = pick(panes().filter(({ pane }) => pane.sleeping === (op === 'wake')))
if (!target) {
continue
}
log.push(`${op} ${target.tabId}:${target.leafId}`)
const paneKey = `${target.tabId}:${target.leafId}`
const records = { ...window.session.sleepingAgentSessionsByPaneKey }
if (op === 'sleep') {
records[paneKey] = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId)
} else {
delete records[paneKey]
}
window.setSleeping(records)
target.pane.sleeping = op === 'sleep'
save()
break
}
case 'restart':
case 'quit_restart': {
log.push(op)
await reopen(op === 'quit_restart')
break
}
}
const context = `seed ${seed}, step ${step}:\n${log.join('\n')}`
const session = store.getWorkspaceSession()
expectOwnerInvariants(session, context)
expect(projectedTopology(session), context).toEqual(expectedTopology(model))
}
// The saved profile, read back cold, holds the same layout.
await reopen(false)
const context = `seed ${seed}, after reopen:\n${log.join('\n')}`
expectOwnerInvariants(store.getWorkspaceSession(), context)
expect(projectedTopology(store.getWorkspaceSession()), context).toEqual(expectedTopology(model))
await store.freezeWritesAsync()
return log
}
describe('terminal layout model (main session)', () => {
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
it('keeps one tab per pane, one pane per terminal, and the expected layout across random gestures', async () => {
const ranOps = new Set<string>()
for (let iteration = 0; iteration < ITERATIONS; iteration++) {
const log = await runSeed(FIRST_SEED + iteration)
for (const entry of log) {
ranOps.add(entry.split(' ')[0]!)
}
}
// Precondition: the seeds reached every gesture, so a green run is not a quiet one.
if (ITERATIONS >= 6) {
expect([...ranOps].sort()).toEqual([...new Set(OPS)].sort())
}
}, 60_000)
})
@@ -0,0 +1,46 @@
import { join } from 'node:path'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture'
import { Store } from '../loading-store/store'
import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture'
import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority'
/** A worktree of the fixture's registered repo, so load keeps its rows. */
export const FIXTURE_GIT_WORKTREE_ID = 'repo-local::/fixture/local'
export const FIXTURE_FOLDER_WORKTREE_ID = 'repo-local::/fixture/local::workspace:folder-1'
/** The cutover fixture profile with no terminal session yet. */
export function emptyTerminalSessionProfile(): string {
const state = buildProfileStateCutoverFixture()
return JSON.stringify({
...state,
// A fixed install id: load mints a random one when it is missing.
settings: {
...state.settings,
telemetry: { existedBeforeTelemetryRelease: true, optedIn: null, installId: 'install-1' }
},
workspaceSession: getDefaultWorkspaceSession(),
workspaceSessionsByHostId: {}
})
}
/** Opens a real Store on SQLite in `directory`, seeding it first when `seed` is given. */
export async function openTopologyStore(directory: string, seed?: string): Promise<Store> {
const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology')
if (seed) {
inner.writeSerializedState(Buffer.from(seed))
}
const store = new Store({
dataFile: join(directory, 'orca-data.json'),
profileStateAuthority: new DelayedAuthority(inner)
})
await store.flushPendingOrThrowAsync()
return store
}
/** Quit and relaunch: drain pending writes, close, and load the profile cold. */
export async function reopenTopologyStore(store: Store, directory: string): Promise<Store> {
await store.flushPendingOrThrowAsync()
await store.freezeWritesAsync()
return openTopologyStore(directory)
}
@@ -0,0 +1,284 @@
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store'
import { useAppStore } from '@/store'
import { registerTerminalPresentationIpcBridge } from '@/hooks/ipc-events/terminal-presentation-ipc-bridge'
import { getDefaultSettings } from '../../../shared/constants'
import type { RuntimeTerminalSummary } from '../../../shared/runtime-types'
import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types'
import { activateAndRevealWorktree } from './worktree-activation'
import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate'
import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state'
/**
* STA-9417, converted from the natural repro: a host-created worktree whose setup script runs in a
* split of the (unmounted) primary tab, then the first activation of that worktree. When main's
* split reveal does not reach the window, the activation sweep mints a second tab for the setup
* PTY, so two saved panes own one terminal and one of them freezes.
*/
const initialState = useAppStore.getState()
const TAB_B = 'b3f1c1de-5e0f-4a51-9d0c-7a8e2a9f6b11'
const PRIMARY_LEAF = '11111111-1111-4111-8111-111111111111'
const SETUP_LEAF = '22222222-2222-4222-8222-222222222222'
type CreateTerminalListener = Parameters<Window['api']['ui']['onCreateTerminal']>[0]
function baseState(): Partial<AppState> {
const worktree = makeWorktree()
return {
repos: [
{
id: worktree.repoId,
path: path.join(path.sep, 'workspace', 'repo'),
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { [worktree.repoId]: [worktree] },
activeRepoId: worktree.repoId,
activeView: 'terminal',
workspaceSessionReady: true,
terminalStartupRestorationReady: true,
tabsByWorktree: {},
ptyIdsByTabId: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
layoutByWorktree: {},
activeGroupIdByWorktree: {},
terminalLayoutsByTabId: {},
openFiles: [],
browserTabsByWorktree: {},
activeFileIdByWorktree: {},
activeBrowserTabIdByWorktree: {},
activeTabTypeByWorktree: {},
activeTabIdByWorktree: {},
tabBarOrderByWorktree: {},
pendingStartupByTabId: {},
automaticAgentResumeClaimsByTabId: {},
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
settings: {
...getDefaultSettings(path.join(path.sep, 'home')),
agentCmdOverrides: {},
defaultTuiAgent: 'codex',
setupScriptLaunchMode: 'split-vertical'
},
markWorktreeVisited: vi.fn(),
recordWorktreeVisit: vi.fn(),
refreshGitHubForWorktreeIfStale: vi.fn(),
revealWorktreeInSidebar: vi.fn()
}
}
/** The host's census: both panes are unmounted, so it reports them unowned with their record. */
function orphanRow(ptyId: string, leafId: string): RuntimeTerminalSummary {
const worktree = makeWorktree()
return {
handle: `handle-${leafId}`,
ptyId,
orphaned: true,
recordedPaneKey: `${TAB_B}:${leafId}`,
worktreeId: worktree.id,
worktreePath: worktree.path,
branch: worktree.branch ?? 'main',
tabId: `pty:${ptyId}`,
leafId: `pty:${ptyId}`,
title: 'Terminal',
connected: true,
writable: true,
lastOutputAt: null,
preview: ''
}
}
function stubHost(
primaryPtyId: string,
setupPtyId: string
): { reveal: () => CreateTerminalListener } {
const worktree = makeWorktree()
let listener: CreateTerminalListener | undefined
const runtimeCall = vi.fn(async ({ method }: { method: string }) => {
if (method === 'session.tabs.list') {
return {
ok: true,
result: {
worktree: worktree.id,
publicationEpoch: 'sta-9417',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
}
}
if (method === 'terminal.list') {
return {
ok: true,
result: {
terminals: [orphanRow(primaryPtyId, PRIMARY_LEAF), orphanRow(setupPtyId, SETUP_LEAF)],
truncated: false,
hostScope: { hostIds: ['local'], omittedHostIds: [] }
}
}
}
throw new Error(`Unexpected runtime method: ${method}`)
})
const listSessions = vi.fn(async () =>
[primaryPtyId, setupPtyId].map((id) => ({
id,
cwd: worktree.path,
title: 'Terminal',
agentOwnership: 'absent' as const
}))
)
vi.stubGlobal('window', {
api: {
runtime: { call: runtimeCall },
pty: { listSessions },
ui: {
onCreateTerminal: (next: CreateTerminalListener) => {
listener = next
return () => {}
},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: vi.fn()
}
},
dispatchEvent: vi.fn()
})
return {
reveal: () => {
if (!listener) {
throw new Error('presentation bridge did not register')
}
return listener
}
}
}
/** Main's background reveal of the primary terminal: the tab exists here but never mounted. */
function seedRevealedPrimaryTab(worktreeId: string, primaryPtyId: string): void {
useAppStore.getState().createTab(worktreeId, undefined, undefined, {
id: TAB_B,
initialLeafId: PRIMARY_LEAF,
initialPtyId: primaryPtyId,
activate: false,
recordInteraction: false
})
}
function leafIdsInOrder(node: TerminalPaneLayoutNode | null): string[] {
if (!node) {
return []
}
return node.type === 'leaf'
? [node.leafId]
: [...leafIdsInOrder(node.first), ...leafIdsInOrder(node.second)]
}
function setupSplitReveal(
requestId: string,
worktreeId: string,
setupPtyId: string
): Parameters<CreateTerminalListener>[0] {
return {
requestId,
worktreeId,
ptyId: setupPtyId,
activate: false,
surfaceOwner: false,
tabId: TAB_B,
leafId: SETUP_LEAF,
splitFromLeafId: PRIMARY_LEAF,
splitDirection: 'vertical'
}
}
/** ptyId → every tab:leaf in the worktree that holds it. */
function ownersByPty(worktreeId: string): Map<string, string[]> {
const state = useAppStore.getState()
const owners = new Map<string, string[]>()
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
const layout = state.terminalLayoutsByTabId[tab.id]
for (const leafId of leafIdsInOrder(layout?.root ?? null)) {
const ptyId = layout?.ptyIdsByLeafId?.[leafId]
if (ptyId) {
owners.set(ptyId, [...(owners.get(ptyId) ?? []), `${tab.id}:${leafId}`])
}
}
}
return owners
}
/** The sidebar click, then the first-activation sweep the terminal watcher runs (STA-9417 step 2). */
async function activateFirstTime(worktreeId: string): Promise<void> {
activateAndRevealWorktree(worktreeId)
await expect(gateWorktreeAgentActivation(worktreeId)).resolves.toBe('adopted')
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
useAppStore.setState(initialState, true)
})
describe('STA-9417: setup split of an unmounted tab, then first activation', () => {
async function runScenario(revealSplit: boolean): Promise<void> {
const worktree = makeWorktree()
const primaryPtyId = `${worktree.id}@@primary`
const setupPtyId = `${worktree.id}@@setup`
useAppStore.setState(baseState())
const host = stubHost(primaryPtyId, setupPtyId)
registerTerminalPresentationIpcBridge([])
seedRevealedPrimaryTab(worktree.id, primaryPtyId)
if (revealSplit) {
host.reveal()(setupSplitReveal('reveal-setup-split', worktree.id, setupPtyId))
}
await activateFirstTime(worktree.id)
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
expect(tabs.map((tab) => tab.id)).toEqual([TAB_B])
expect(Object.fromEntries(ownersByPty(worktree.id))).toEqual({
[primaryPtyId]: [`${TAB_B}:${PRIMARY_LEAF}`],
[setupPtyId]: [`${TAB_B}:${SETUP_LEAF}`]
})
}
it('ends with one tab and one owner per terminal when main reveals the split', async () => {
await runScenario(true)
})
// STA-9417: fails until the mirror refactor (B2-4) pushes main's split before the sweep runs.
it.fails('ends with one tab and one owner per terminal when the split reveal never arrives', async () => {
await runScenario(false)
})
// STA-9417: fails until the mirror refactor (B2-4); the odd runs mint the duplicate.
it.fails('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => {
let duplicates = 0
for (let run = 0; run < 8; run++) {
const worktree = makeWorktree()
const primaryPtyId = `${worktree.id}@@primary-${run}`
const setupPtyId = `${worktree.id}@@setup-${run}`
useAppStore.setState(initialState, true)
useAppStore.setState(baseState())
const host = stubHost(primaryPtyId, setupPtyId)
registerTerminalPresentationIpcBridge([])
seedRevealedPrimaryTab(worktree.id, primaryPtyId)
// Odd runs lose the reveal, as the natural repro did.
if (run % 2 === 0) {
host.reveal()(setupSplitReveal(`reveal-${run}`, worktree.id, setupPtyId))
}
await activateFirstTime(worktree.id)
duplicates += [...ownersByPty(worktree.id).values()].filter(
(owners) => owners.length > 1
).length
duplicates += (useAppStore.getState().tabsByWorktree[worktree.id] ?? []).length - 1
}
expect(duplicates).toBe(0)
})
})
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { createTestStore, makeTab, makeWorktree } from '../slices/store-test-helpers'
import { createStoreSessionMockApi } from '../slices/store-session-test-harness'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
createStoreSessionMockApi()
// "Is this pane running" reads the liveness map; an exit leaves the pane's layout binding in place
// for its one stale reattach. The mirror refactor keeps this split: liveness stays in the window,
// the binding becomes main's.
const WORKTREE = 'repo1::/path/wt1'
const LEFT = '11111111-1111-4111-8111-111111111111'
const RIGHT = '22222222-2222-4222-8222-222222222222'
function splitLayout(): TerminalLayoutSnapshot {
return {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEFT },
second: { type: 'leaf', leafId: RIGHT }
},
activeLeafId: LEFT,
expandedLeafId: null,
ptyIdsByLeafId: { [LEFT]: 'pty-left', [RIGHT]: 'pty-right' }
}
}
function storeWithSplitTab() {
const store = createTestStore()
store.setState({
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
worktreesByRepo: {
repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt1', hostId: 'local' })]
},
tabsByWorktree: {
[WORKTREE]: [makeTab({ id: 'tab-1', worktreeId: WORKTREE, ptyId: 'pty-right' })]
},
ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] },
terminalLayoutsByTabId: { 'tab-1': splitLayout() }
})
return store
}
describe('exited pane in a split', () => {
it('drops only the exited PTY from liveness and keeps every layout binding', () => {
const store = storeWithSplitTab()
// Ctrl-D in the left pane.
store.getState().clearTabPtyId('tab-1', 'pty-left')
const state = store.getState()
expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-right'])
expect(tabHasLivePty(state.ptyIdsByTabId, 'tab-1')).toBe(true)
expect(state.terminalLayoutsByTabId['tab-1']).toEqual(splitLayout())
})
it('reads the tab as not running once both panes exit, with the bindings still saved', () => {
const store = storeWithSplitTab()
store.getState().clearTabPtyId('tab-1', 'pty-left')
store.getState().clearTabPtyId('tab-1', 'pty-right')
const state = store.getState()
expect(tabHasLivePty(state.ptyIdsByTabId, 'tab-1')).toBe(false)
expect(state.tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBeNull()
expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({
[LEFT]: 'pty-left',
[RIGHT]: 'pty-right'
})
})
})