diff --git a/src/main/ipc/pty-launch-parity-caller.test-cases.ts b/src/main/ipc/pty-launch-parity-caller.test-cases.ts new file mode 100644 index 00000000000..a10592bed8e --- /dev/null +++ b/src/main/ipc/pty-launch-parity-caller.test-cases.ts @@ -0,0 +1,148 @@ +// Test-only: host-lane parity cases whose caller already built the launch (row 8, run verbatim), +// or sends a paired host its intent (row 7) or a model pick (row 4). +import { POSIX_PATH, WIN_PATH } from '../../shared/launch-parity-window-cases.test-fixture' +import { + AGENT_PHONE, + CMD, + CMD_EXE, + HOST_LEAF_ID, + HOST_TAB_ID, + repo, + ZSH, + type HostLaunchCall, + type HostLaunchCase, + type HostProviderFacts +} from './pty-launch-parity-host.test-cases' + +// A window-built codex startup quoted for a Windows cmd.exe client; the host must not re-quote it. +const CLIENT_CODEX = 'codex "--dangerously-bypass-approvals-and-sandbox"' +const CALLER_ENV = { CUSTOM_FLAG: '1' } +const NEW_WORKSPACE = { + agent_kind: 'codex', + launch_source: 'new_workspace_composer', + request_kind: 'new' +} as const +export const OPENCODE_MODEL = 'opencode/fledge-alpha-free' +const rpc = ( + method: 'terminal.create' | 'terminal.createAgentSession', + params: Record +): HostLaunchCall => ({ kind: 'rpc', method, params }) +const POSIX_CLAUDE = "claude '--dangerously-skip-permissions'" +const CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'" + +// Row 7: the terminal.createAgentSession params a paired desktop's new tab sends a current host. +// main today: command, env, launchConfig and startupCommandDelivery never reach the host; agentArgs +// is the window's default args. +const PAIRED_TAB_PATH = '/worktree' +const PAIRED_TAB = { + worktree: `id:repo-1::${PAIRED_TAB_PATH}`, + viewMode: 'terminal', + presentation: 'background' +} as const +const PAIRED_TAB_CASES: [string, Record, HostProviderFacts][] = [ + [ + 'claude draft continuation, initial cwd', + { + ...PAIRED_TAB, + agent: 'claude', + prompt: "review Bob's change", + promptDelivery: 'draft', + agentArgs: '--dangerously-skip-permissions', + startupCwd: `${PAIRED_TAB_PATH}/packages/app` + }, + { + command: `${POSIX_CLAUDE} --prefill 'review Bob'"'"'s change'`, + cwd: `${PAIRED_TAB_PATH}/packages/app` + } + ], + [ + 'codex auto-submit', + { + ...PAIRED_TAB, + agent: 'codex', + prompt: 'fix it', + promptDelivery: 'auto-submit', + agentArgs: '--dangerously-bypass-approvals-and-sandbox' + }, + { command: `${CODEX} 'fix it'`, startupCommandDelivery: 'shell-ready' } + ] +] + +export const CALLER_LAUNCH_CASES: HostLaunchCase[] = [ + // C1 changes the host planner; these are its only draft (--prefill) and startupCwd-from-intent rows. + ...PAIRED_TAB_CASES.map(([name, params, provider]): HostLaunchCase => ({ + name: `paired new tab: ${name}`, + row: '7', + os: 'linux', + workspace: repo(PAIRED_TAB_PATH), + call: rpc('terminal.createAgentSession', params), + provider: { ...provider, ...ZSH }, + // main today: a paired client's launch_source never reaches the host. + telemetry: { + agent_kind: params.agent === 'claude' ? 'claude-code' : String(params.agent), + launch_source: 'unknown', + request_kind: 'new' + }, + phone: { title: 'Terminal', launchAgent: String(params.agent), isActive: false } + })), + { + // C4/C5: worktree-remote.ts spawnLocalStartupAndSetupTerminals runs the caller's command and env + // without re-quoting them, and keeps telemetry. + name: 'local-git worktrees:create startup, verbatim', + row: '8', + os: 'linux', + workspace: repo(POSIX_PATH), + call: { + kind: 'create', + options: { + command: CLIENT_CODEX, + env: CALLER_ENV, + launchConfig: { + agentCommand: CLIENT_CODEX, + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: CALLER_ENV + }, + launchAgent: 'codex', + startupCommandDelivery: 'shell-ready', + surfaceOwner: false, + telemetry: NEW_WORKSPACE + } + }, + provider: { + command: CLIENT_CODEX, + startupCommandDelivery: 'shell-ready', + env: CALLER_ENV, + ...ZSH + }, + telemetry: NEW_WORKSPACE, + phone: { ...AGENT_PHONE, launchAgent: 'codex' } + }, + // C2 keeps model validation on the host while moving the spawn to the pane; the command must survive. + ...( + [ + ['macOS repo', 'darwin', POSIX_PATH, "opencode '--standalone'", ZSH], + ['Windows C: repo', 'win32', WIN_PATH, 'opencode "--standalone"', CMD_EXE] + ] as const + ).map(([where, os, path, command, shell]): HostLaunchCase => ({ + name: `OpenCode model pick, ${where}`, + row: '4', + os, + workspace: repo(path), + settings: CMD, + // spawnIpcPty's terminal.createAgentSession for a seeded model-pick pane. + call: rpc('terminal.createAgentSession', { + worktree: `id:repo-1::${path}`, + agent: 'opencode', + launchPreferences: { model: OPENCODE_MODEL }, + startupCwd: path, + placement: { tabId: HOST_TAB_ID, leafId: HOST_LEAF_ID }, + presentation: 'background', + // A local Windows ConPTY pane withholds kitty. + ...(path === WIN_PATH ? {} : { terminalKittyKeyboardProtocol: true }) + }), + provider: { command, ...shell }, + // main today: the pane's own launch_source never reaches the host. + telemetry: { agent_kind: 'opencode', launch_source: 'unknown', request_kind: 'new' }, + phone: { title: 'Terminal', launchAgent: 'opencode', isActive: false } + })) +] diff --git a/src/main/ipc/pty-launch-parity-host.test-cases.ts b/src/main/ipc/pty-launch-parity-host.test-cases.ts new file mode 100644 index 00000000000..631291c0a10 --- /dev/null +++ b/src/main/ipc/pty-launch-parity-host.test-cases.ts @@ -0,0 +1,188 @@ +// Test-only: the host-lane launch parity table (rows 3, 4, 7, 8, 10). Each case is the call a host +// producer makes and the facts main hands the provider, the phone and telemetry for it. +import type { TerminalCreateOptions } from '../runtime/runtime-terminal-contracts' +import type { LaunchWorkspace } from '../../shared/launch-parity-window-request.test-fixture' +import { POSIX_PATH } from '../../shared/launch-parity-window-cases.test-fixture' + +export const HOST_TAB_ID = '33333333-3333-4333-8333-333333333333' +export const HOST_LEAF_ID = '44444444-4444-4444-8444-444444444444' + +export type HostLaunchCall = + | { kind: 'create'; options: TerminalCreateOptions } + /** A paired client's RPC, run through the host's method handler; clientOperationId is added. */ + | { + kind: 'rpc' + method: 'terminal.create' | 'terminal.createAgentSession' + params: Record + } + +export type HostProviderFacts = { + command: string + /** Default: the workspace path. */ + cwd?: string + shellOverride?: string + terminalWindowsWslDistro?: string | null + startupCommandDelivery?: 'shell-ready' + /** The agent or caller env keys the case set, with their values; default none. */ + env?: Record + /** Whether main marks the PTY hidden before it has a view; default true on this lane. */ + hidden?: boolean +} + +export type HostLaunchCase = { + name: string + /** The launch-convergence producer row. */ + row: '3' | '4' | '7' | '8' | '10' + os: NodeJS.Platform + workspace: LaunchWorkspace + settings?: Record + call: HostLaunchCall + provider: HostProviderFacts + /** agent_started payload, or null when main emits none. */ + telemetry: Record | null + /** The phone's first session tab. */ + phone: { title: string; launchAgent?: string; isActive: boolean } +} + +// rpc/methods/agent-launch-surfaces.ts createTerminalAgent options for an AI button's reserved pane. +const agentLaunch = (extra: Record = {}): HostLaunchCall => ({ + kind: 'create', + options: { + startupAgent: 'claude', + tabId: HOST_TAB_ID, + leafId: HOST_LEAF_ID, + requireFreshPane: true, + launchSource: 'task_page', + viewMode: 'terminal', + surfaceOwner: false, + ...extra + } +}) +// worker-topology.ts createExistingWorktreeWorkerTerminal options. +const worker: HostLaunchCall = { + kind: 'create', + options: { + startupAgent: 'claude', + launchSource: 'orchestration', + title: 'worker-task-1', + surfaceOwner: false + } +} + +export const repo = (path: string): LaunchWorkspace => ({ kind: 'repo', path }) +const folder = (path: string): LaunchWorkspace => ({ kind: 'folder', path }) +// The same factory for a mobile quick command (agent.launchReplay with a reserved pane and a +// prompt): a paired phone never moves the host window. +const mobileQuickCommand = (startupPrompt: string): HostLaunchCall => ({ + kind: 'create', + options: { + startupAgent: 'codex', + startupPrompt, + launchSource: 'quick_command', + tabId: HOST_TAB_ID, + leafId: HOST_LEAF_ID, + requireFreshPane: true, + viewMode: 'terminal', + surfaceOwner: false + } +}) +const TASK_PAGE = { agent_kind: 'claude-code', launch_source: 'task_page', request_kind: 'new' } +// The AI button that reaches each workspace kind: fix checks needs a repo with a PR (task_page); +// in a folder, review notes' "New agent" (also on browser and markdown annotations, notes_send). +const aiButtonSource = (workspace: LaunchWorkspace): string => + workspace.kind === 'repo' ? 'task_page' : 'notes_send' +const QUICK_COMMAND = { agent_kind: 'codex', launch_source: 'quick_command', request_kind: 'new' } +export const AGENT_PHONE = { title: 'Terminal', launchAgent: 'claude', isActive: true } +const POSIX_CLAUDE = "claude '--dangerously-skip-permissions'" +// main today: a local POSIX host hands the provider the settings shell untrimmed. +export const ZSH = { shellOverride: ' /bin/zsh ', terminalWindowsWslDistro: null } +export const CMD_EXE = { shellOverride: 'cmd.exe', terminalWindowsWslDistro: null } +export const CMD = { terminalWindowsShell: 'cmd.exe' } +// A saved default argument with an apostrophe: the byte that tells POSIX and PowerShell quoting apart. +const BOB_ARGS = { + agentDefaultArgs: { claude: `--dangerously-skip-permissions --name "Bob's box"` } +} +const BOB_POSIX = `${POSIX_CLAUDE} '--name' 'Bob'"'"'s box'` +const CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'" + +/** Row 3: agent.launch with no prompt; `command` + shell are what the provider gets. */ +function aiButton( + name: string, + os: NodeJS.Platform, + workspace: LaunchWorkspace, + provider: HostProviderFacts, + settings?: Record +): HostLaunchCase { + return { + name, + row: '3', + os, + workspace, + ...(settings ? { settings } : {}), + call: agentLaunch({ launchSource: aiButtonSource(workspace) }), + provider, + telemetry: { ...TASK_PAGE, launch_source: aiButtonSource(workspace) }, + phone: AGENT_PHONE + } +} +export const HOST_LAUNCH_CASES: HostLaunchCase[] = [ + // C1 (host planner inputs) and C2 (AI buttons onto the pane trigger) must keep these commands. + aiButton('macOS repo', 'darwin', repo(POSIX_PATH), { command: POSIX_CLAUDE, ...ZSH }), + aiButton( + 'macOS folder, saved default args', + 'darwin', + folder(POSIX_PATH), + { command: BOB_POSIX, ...ZSH }, + BOB_ARGS + ), + { + // C1-C7 keep the host path's prompt carry. + name: 'mobile quick command, macOS repo, codex prompt waits for the shell', + row: '10', + os: 'darwin', + workspace: repo(POSIX_PATH), + call: mobileQuickCommand("fix Bob's branch"), + provider: { + command: `${CODEX} 'fix Bob'"'"'s branch'`, + startupCommandDelivery: 'shell-ready', + ...ZSH + }, + telemetry: QUICK_COMMAND, + phone: { ...AGENT_PHONE, launchAgent: 'codex' } + }, + // C5d/C5e (options, presentation) check host callers against these. + ...[repo(POSIX_PATH), folder(POSIX_PATH)].map((workspace): HostLaunchCase => ({ + name: `orchestration worker, Linux ${workspace.kind}`, + row: '10', + os: 'linux', + workspace, + call: worker, + provider: { command: POSIX_CLAUDE, ...ZSH }, + telemetry: { ...TASK_PAGE, launch_source: 'orchestration' }, + phone: { title: 'worker-task-1', launchAgent: 'claude', isActive: true } + })) +] + +const HOST_ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + 'ORCA_TERMINAL_HANDLE' +] +const FOLDER_ENV_KEYS = ['ORCA_WORKSPACE_ID', 'ORCA_PROJECT_GROUP_ID', 'ORCA_WORKSPACE_ROOT'] + +/** What main hands the provider for a host-lane case. */ +export function expectedHostProvider(c: HostLaunchCase): Record { + return { + cwd: c.workspace.path.replaceAll('\\', '/'), + cols: 120, + rows: 40, + commandDelivery: 'provider', + hidden: true, + env: {}, + ...c.provider, + // main today: the host lane stamps ORCA_WORKSPACE_ID on folders only. + orcaEnv: c.workspace.kind === 'folder' ? [...FOLDER_ENV_KEYS, ...HOST_ENV_KEYS] : HOST_ENV_KEYS + } +} diff --git a/src/main/ipc/pty-launch-parity.test-fixture.ts b/src/main/ipc/pty-launch-parity.test-fixture.ts new file mode 100644 index 00000000000..f17206d7b26 --- /dev/null +++ b/src/main/ipc/pty-launch-parity.test-fixture.ts @@ -0,0 +1,255 @@ +// Test-only: one real OrcaRuntimeService wired into the real pty handlers, so both launch lanes +// reach the same fake provider. Window lane: pty:spawn. Host lane: createTerminal / createAgentSession +// through the runtime controller registerPtyHandlers installs. Suites still declare the IPC vi.mock block. +import { vi } from 'vitest' +import type { Mock } from 'vitest' +import { registerPtyHandlers } from './pty' +import { existsSyncMock, statSyncMock } from './pty-ipc-mock-registry' +import type { PtyIpcSuiteFixtures } from './pty-ipc-test-harness' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { TerminalWorkspaceLaunchScope } from '../runtime/runtime-legacy-worker-terminal-recovery-types' +import { + IDENTITY_ENV_KEYS, + type LaunchWorkspace +} from '../../shared/launch-parity-window-request.test-fixture' +import { isHiddenRendererPty } from './pty-hidden-delivery-gate' +import { HOST_TAB_ID } from './pty-launch-parity-host.test-cases' + +const WINDOWS_ENV = { + COMSPEC: 'C:\\Windows\\system32\\cmd.exe', + USERPROFILE: 'C:\\Users\\test', + SystemRoot: 'C:\\Windows', + ProgramW6432: 'C:\\Program Files', + ProgramFiles: 'C:\\Program Files', + 'ProgramFiles(x86)': undefined +} +const ORIGINAL_PLATFORM = process.platform +const savedEnv = new Map() +let remoteSpawns = 0 + +/** Main's process.platform for a case; Windows also gets the env and .exe probes its shells read. */ +export function setMainPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + if (platform !== 'win32') { + return + } + for (const [key, value] of Object.entries(WINDOWS_ENV)) { + if (!savedEnv.has(key)) { + savedEnv.set(key, process.env[key]) + } + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + statSyncMock.mockImplementation((target: string) => { + const isExe = /\.exe$/i.test(String(target)) + return { isDirectory: () => !isExe, isFile: () => isExe, size: 1024, mode: 0o755 } + }) + existsSyncMock.mockReturnValue(true) +} + +export function restoreMainPlatform(): void { + Object.defineProperty(process, 'platform', { configurable: true, value: ORIGINAL_PLATFORM }) + for (const [key, value] of savedEnv) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + savedEnv.clear() +} + +/** Settings both lanes read on main's current OS; the untrimmed default shell shows which reads trim it. */ +function parityMainSettings(overrides: Record = {}): Record { + return { + // Windows hides the default-shell setting and defaults it to ''. + terminalDefaultShell: process.platform === 'win32' ? '' : ' /bin/zsh ', + terminalDefaultShellArgs: ['-l'], + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'auto', + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + // Why off: a fresh agent launch would otherwise write workspace trust into the agent's home. + agentWorkspaceTrustEnabled: false, + ...overrides + } +} + +function launchScope(workspace: LaunchWorkspace): TerminalWorkspaceLaunchScope { + const connectionId = null + if (workspace.kind === 'repo') { + return { + id: `repo-1::${workspace.path}`, + path: workspace.path, + connectionId, + repo: { + id: 'repo-1', + path: workspace.path, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + connectionId + }, + folderWorkspace: null + } + } + return { + id: 'folder:fw-1', + path: workspace.path, + connectionId, + repo: null, + folderWorkspace: { + id: 'fw-1', + projectGroupId: 'pg-1', + name: 'folder', + folderPath: workspace.path, + connectionId, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + } +} + +type ScopeResolver = { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise +} + +export type ParityLanes = { + runtime: OrcaRuntimeService + /** The local daemon provider's spawn mock. */ + provider: Mock + persistPtyBinding: Mock + reveal: Mock + spawnWindow: (request: Record) => Promise +} + +/** Wires one runtime + handlers for a workspace (its saved project runtime included). */ +export function startParityLanes( + suite: Pick, + args: { + workspace: LaunchWorkspace + settings?: Record + spawnError?: Error + } +): ParityLanes { + const settings = parityMainSettings(args.settings) + const persistPtyBinding = vi.fn(async () => true) + const { repo, folderWorkspace } = launchScope(args.workspace) + const store = { + getSettings: () => settings, + getProjects: () => [ + { + id: 'project-1', + sourceRepoIds: ['repo-1'], + ...(args.workspace.projectRuntime + ? { localWindowsRuntimePreference: args.workspace.projectRuntime } + : {}) + } + ], + getRepo: (id: string) => (repo && id === repo.id ? repo : undefined), + getRepos: () => (repo ? [repo] : []), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getWorkspaceSession: () => ({ tabsByWorktree: {}, terminalLayoutsByTabId: {} }), + getFolderWorkspace: () => folderWorkspace ?? undefined, + getFolderWorkspaces: () => (folderWorkspace ? [folderWorkspace] : []), + getProjectGroups: () => [{ id: 'pg-1', name: 'group', parentPath: null, connectionId: null }], + persistPtyBinding + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launches read only the store members defined above; the rest of the persistence store is unreachable here. + const runtime = new OrcaRuntimeService(store as never) + const provider = vi.fn(async (options: { sessionId?: string }) => { + if (args.spawnError) { + throw args.spawnError + } + // A fresh id per spawn, so one case's hidden mark never reads into the next. + return { id: options.sessionId ?? `remote-pty-${++remoteSpawns}` } + }) + suite.installDaemonTestProvider({ spawn: provider }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read the window, runtime, settings and store members these fakes define. + const handlerArgs = [ + suite.mainWindow, + runtime, + undefined, + () => settings, + undefined, + store + ] as unknown as Parameters + registerPtyHandlers(...handlerArgs) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver is a real (protected) runtime method; the scope is this case's input, not an assertion. + const resolver = runtime as unknown as ScopeResolver + vi.spyOn(resolver, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue( + launchScope(args.workspace) + ) + const reveal = vi.fn().mockResolvedValue({ tabId: HOST_TAB_ID }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: reveal, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + return { + runtime, + provider, + persistPtyBinding, + reveal, + spawnWindow: async (request) => suite.handlers.get('pty:spawn')!(null, request) + } +} + +const PROVIDER_FACT_KEYS = [ + 'command', + 'cwd', + 'cols', + 'rows', + 'shellOverride', + 'terminalShellArgs', + 'terminalWindowsWslDistro', + 'commandDelivery', + 'startupCommandDelivery' +] as const + +// The agent and caller env keys the tables save; their values must reach the provider. +const CASE_ENV_KEYS = ['A', 'CUSTOM_FLAG'] + +/** + * The launch facts the plan names at the provider boundary: `orcaEnv` lists identity keys present, + * `env` the case's own env values, `hidden` whether main marked the PTY hidden before a view. + */ +export async function providerFacts(provider: Mock): Promise> { + const options = recordOf(provider.mock.calls.at(-1)?.[0]) + const env = recordOf(options.env) + const spawned = recordOf(await provider.mock.results.at(-1)?.value) + return { + ...Object.fromEntries( + PROVIDER_FACT_KEYS.filter((k) => k in options).map((k) => [k, options[k]]) + ), + orcaEnv: IDENTITY_ENV_KEYS.filter((key) => key in env), + env: Object.fromEntries(CASE_ENV_KEYS.filter((key) => key in env).map((k) => [k, env[k]])), + hidden: isHiddenRendererPty(String(spawned.id)) + } +} + +export function recordOf(value: unknown): Record { + return value && typeof value === 'object' ? { ...value } : {} +} diff --git a/src/main/ipc/pty-launch-parity.test.ts b/src/main/ipc/pty-launch-parity.test.ts new file mode 100644 index 00000000000..1a053ed0778 --- /dev/null +++ b/src/main/ipc/pty-launch-parity.test.ts @@ -0,0 +1,337 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { classifyErrorMock, trackMock } from './pty-ipc-mock-registry' +import { + providerFacts, + recordOf, + restoreMainPlatform, + setMainPlatform, + startParityLanes, + type ParityLanes +} from './pty-launch-parity.test-fixture' +import { + POSIX_PATH, + WINDOW_LAUNCH_CASES +} from '../../shared/launch-parity-window-cases.test-fixture' +import { + expectedWindowProvider, + launchWorkspaceId, + windowSpawnRequest, + type LaunchWorkspace, + type WindowProviderFacts +} from '../../shared/launch-parity-window-request.test-fixture' +import { + AUTOMATION_LAUNCH_CASES, + automationSpawnRequest +} from '../../shared/launch-parity-automation-cases.test-fixture' +import { + expectedHostProvider, + HOST_LAUNCH_CASES, + type HostLaunchCase +} from './pty-launch-parity-host.test-cases' +import { CALLER_LAUNCH_CASES, OPENCODE_MODEL } from './pty-launch-parity-caller.test-cases' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { DESKTOP_RPC_CALLER } from '../runtime/rpc/rpc-caller-identity' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' +import { TERMINAL_LIFECYCLE_METHODS } from '../runtime/rpc/methods/terminal/terminal-lifecycle-methods' +import { AGENT_SESSION_METHODS } from '../runtime/rpc/methods/agent-session' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' +import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from '../opencode/opencode-model-availability' +import { probeOpenCodeLaunchModelContext } from '../opencode/opencode-launch-model-context' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +// Why: an OpenCode v2 install with the picked model available; the probes would run the binary. +vi.mock('../managed-data-accounts/launch-environment', () => ({ + applyManagedDataAccountEnvironment: vi.fn() +})) +vi.mock('../opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: vi.fn() +})) +vi.mock('../opencode/opencode-model-availability', () => ({ + resolveOpenCodeDirectModelExecutable: vi.fn(), + probeOpenCodeModelAvailability: vi.fn() +})) +vi.mock('../opencode/opencode-launch-model-context', () => ({ + probeOpenCodeLaunchModelContext: vi.fn() +})) +// Why: every folder in the table exists on its host; main checks that before it spawns. +vi.mock('../project-groups/folder-workspace-path-status', async (importOriginal) => ({ + ...(await importOriginal>()), + getFolderWorkspacePathStatus: vi.fn(async () => ({ path: '', exists: true })) +})) +// Why: no Antigravity account is selected on this machine, so the account switch before an agy launch is a no-op. +vi.mock('../antigravity/native-account-launch', () => ({ + prepareAntigravityAccountForLaunch: vi.fn(async () => {}) +})) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +type WindowLaneCase = { + label: string + client: NodeJS.Platform + workspace: LaunchWorkspace + settings?: Record + request: Record + provider: WindowProviderFacts + /** The agent env the case saved, as the provider must receive it. */ + env: Record +} + +const WINDOW_LANE_CASES: WindowLaneCase[] = [ + ...WINDOW_LAUNCH_CASES.map((c) => ({ + ...c, + label: `row ${c.row}: ${c.name}`, + request: windowSpawnRequest(c), + env: c.request.agentEnv ?? {} + })), + ...AUTOMATION_LAUNCH_CASES.map((c) => ({ + ...c, + label: `row 5: ${c.name}`, + request: automationSpawnRequest(c), + env: {} + })) +] + +// Pins main's current launch behaviour as the convergence parity baseline (rows 2, 5 and 6, main +// half): each table request, fed to pty:spawn, reaches the provider with these facts (the pane's +// measured size, never hidden), one agent_started, and a pane binding. +// C5a-e and C6 check the merged spawn pipeline against it. +describe('window lane: pty:spawn request -> provider', () => { + const suite = setupPtyIpcSuite() + afterEach(() => restoreMainPlatform()) + + it.each(WINDOW_LANE_CASES)( + '$label', + async ({ client, workspace, settings, request, provider, env: agentEnv }) => { + // A desktop client's main process runs on the client OS. + setMainPlatform(client) + const lanes = startParityLanes(suite, { workspace, settings }) + await lanes.spawnWindow(request) + + expect(await providerFacts(lanes.provider)).toEqual( + expectedWindowProvider(request, provider, agentEnv) + ) + expect(trackMock.mock.calls).toEqual([['agent_started', request.telemetry]]) + expect(lanes.persistPtyBinding.mock.calls).toEqual([ + [ + { + worktreeId: request.worktreeId, + tabId: request.tabId, + leafId: request.leafId, + ptyId: expect.any(String), + startupCwd: expect.any(String), + ...pick(request, ['placement']), + origin: 'spawn' + } + ] + ]) + } + ) +}) + +function pick(source: Record, keys: string[]): Record { + return Object.fromEntries(keys.filter((key) => key in source).map((key) => [key, source[key]])) +} + +function stubOpenCodeV2(): void { + vi.mocked(applyManagedDataAccountEnvironment).mockReset() + vi.mocked(resolveOpenCodeDirectModelExecutable).mockReset().mockResolvedValue('/bin/opencode') + vi.mocked(probeOpenCodeLaunchCapabilities) + .mockReset() + .mockResolvedValue({ version: '2.0.16', pluginApi: 'v2', promptMode: 'prefill' }) + vi.mocked(probeOpenCodeModelAvailability).mockReset().mockResolvedValue(true) + vi.mocked(probeOpenCodeLaunchModelContext) + .mockReset() + .mockResolvedValueOnce({ + primaryAgent: 'build', + availableModels: [OPENCODE_MODEL], + primaryModel: 'opencode/big-pickle' + }) + .mockResolvedValue({ + primaryAgent: 'build', + availableModels: [OPENCODE_MODEL], + primaryModel: OPENCODE_MODEL + }) +} + +async function runHostCall(lanes: ParityLanes, c: HostLaunchCase): Promise { + if (c.call.kind === 'create') { + return lanes.runtime.createTerminal(`id:${launchWorkspaceId(c.workspace)}`, c.call.options) + } + const { method, params } = c.call + const dispatcher = new RpcDispatcher({ + runtime: lanes.runtime, + methods: [...TERMINAL_LIFECYCLE_METHODS, ...AGENT_SESSION_METHODS] + }) + const response = await dispatcher.dispatch( + { + id: 'parity', + // The transport authenticated the caller before dispatch; the dispatcher only carries it. + authToken: 'token', + method, + params: + method === 'terminal.createAgentSession' + ? { clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`, ...params } + : params + }, + // Row 4 is the desktop's own runtime:call (ipc/runtime.ts); row 7 comes from a paired desktop. + c.row === '4' + ? { + clientId: 'desktop-renderer', + caller: DESKTOP_RPC_CALLER, + clientKind: 'runtime', + connectionId: 'desktop-window-1', + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + } + : { clientId: 'device-1', pairedDeviceId: 'device-1', clientKind: 'runtime' } + ) + if (!response.ok) { + throw Object.assign(new Error(response.error.message), { code: response.error.code }) + } + return response.result +} + +// Pins main's current launch behaviour as the convergence parity baseline (rows 3, 4, 7, 8, 10): +// each host producer's call reaches the provider through the real runtime controller with these +// facts, telemetry, placement and phone tab. C1, C2 and C5a-e check the host path against it. +describe('host lane: producer call -> runtime controller -> provider', () => { + const suite = setupPtyIpcSuite() + beforeEach(() => stubOpenCodeV2()) + afterEach(() => restoreMainPlatform()) + + it.each([...HOST_LAUNCH_CASES, ...CALLER_LAUNCH_CASES])('row $row: $name', async (c) => { + setMainPlatform(c.os) + const lanes = startParityLanes(suite, { workspace: c.workspace, settings: c.settings }) + await runHostCall(lanes, c) + + expect(await providerFacts(lanes.provider)).toEqual(expectedHostProvider(c)) + // The host stamps its own pane identity (the window lane's stale-key case is below). + const spawned = recordOf(lanes.provider.mock.calls.at(-1)?.[0]) + expect(recordOf(spawned.env).ORCA_PANE_KEY).toBe(spawned.paneKey) + expect(trackMock.mock.calls).toEqual(c.telemetry ? [['agent_started', c.telemetry]] : []) + expect(lanes.persistPtyBinding.mock.calls[0]?.[0]).toMatchObject({ + placement: { kind: 'new-tab' } + }) + const phone = await lanes.runtime.listMobileSessionTabs(`id:${launchWorkspaceId(c.workspace)}`) + expect(pick({ ...phone.tabs[0] }, ['title', 'launchAgent', 'isActive'])).toEqual(c.phone) + }) +}) + +const POSIX_REPO = { kind: 'repo', path: POSIX_PATH } as const +const WORKER = HOST_LAUNCH_CASES.find((c) => c.name.startsWith('orchestration worker'))! + +// Pins main's current launch behaviour as the convergence parity baseline (rows 2 and 10; failed +// spawn, reveal, pane identity): the facts no single table row carries. +describe('launch facts outside the tables', () => { + const suite = setupPtyIpcSuite() + afterEach(() => restoreMainPlatform()) + + // C5a (one agent_error place): main today reports a failed window-lane spawn. + it.each([ + { + lane: 'telemetry kind wins over the claude sniff', + request: { command: 'claude', telemetry: { agent_kind: 'opencode' } }, + calls: [['agent_error', { agent_kind: 'opencode', error_class: 'binary_not_found' }]] + }, + { + lane: 'local claude without telemetry', + request: { command: 'claude' }, + calls: [['agent_error', { agent_kind: 'claude-code', error_class: 'binary_not_found' }]] + } + ])('a failed window spawn, $lane, emits agent_error', async (c) => { + setMainPlatform('darwin') + classifyErrorMock.mockReturnValue({ error_class: 'binary_not_found' }) + const spawnError = new Error('spawn boom') + const lanes = startParityLanes(suite, { workspace: POSIX_REPO, spawnError }) + const launch = lanes.spawnWindow({ + cols: 80, + rows: 24, + cwd: POSIX_PATH, + worktreeId: launchWorkspaceId(POSIX_REPO), + ...c.request + }) + await expect(launch).rejects.toThrow('spawn boom') + expect(trackMock.mock.calls.filter(([event]) => event === 'agent_error')).toEqual(c.calls) + }) + + // C5e (presentation merge): a worker asks the window to reveal its titled tab without activating it. + it('row 10: an orchestration worker requests a titled, inactive reveal', async () => { + setMainPlatform(WORKER.os) + const lanes = startParityLanes(suite, { workspace: WORKER.workspace }) + await runHostCall(lanes, WORKER) + expect(lanes.reveal.mock.calls[0]?.[1]).toMatchObject({ + title: 'worker-task-1', + activate: false, + surfaceOwner: false + }) + expect(lanes.reveal.mock.calls[0]?.[1]).not.toHaveProperty('presentation') + }) + + // C5c (pane identity built once in main): main rewrites stale pane keys from the request's tab and + // leaf; workspace keys pass. + it('window lane: the provider gets the pane keys main derives, not the stale ones sent', async () => { + setMainPlatform('darwin') + const tabId = '55555555-5555-4555-8555-555555555555' + const leafId = '66666666-6666-4666-8666-666666666666' + const lanes = startParityLanes(suite, { workspace: POSIX_REPO }) + await lanes.spawnWindow({ + cols: 80, + rows: 24, + cwd: POSIX_PATH, + worktreeId: launchWorkspaceId(POSIX_REPO), + tabId, + leafId, + env: { ORCA_WORKSPACE_ID: 'folder:f1', ORCA_PANE_KEY: 'stale', ORCA_TAB_ID: 'stale' } + }) + const env = recordOf(recordOf(lanes.provider.mock.calls.at(-1)?.[0]).env) + expect(pick(env, ['ORCA_WORKSPACE_ID', 'ORCA_PANE_KEY', 'ORCA_TAB_ID'])).toEqual({ + ORCA_WORKSPACE_ID: 'folder:f1', + ORCA_PANE_KEY: `${tabId}:${leafId}`, + ORCA_TAB_ID: tabId + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts new file mode 100644 index 00000000000..19ba435f20a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts @@ -0,0 +1,196 @@ +import type * as React from 'react' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { createManager, createPane } from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' +import { + launchWorkspaceState, + perClientLoader, + type LaunchStoreHolder +} from '@/lib/launch-parity-renderer.test-fixture' +import { getDefaultSettings } from '../../../../shared/constants' +import { WINDOW_LAUNCH_CASES } from '../../../../shared/launch-parity-window-cases.test-fixture' +import { + LAUNCH_TAB_ID, + LAUNCH_TOKEN, + PANE_GRID, + launchWorkspaceId, + windowSpawnRequest, + type WindowLaunchCase +} from '../../../../shared/launch-parity-window-request.test-fixture' + +const holder = vi.hoisted((): LaunchStoreHolder => ({ store: null })) +vi.mock('@/store', async () => + (await import('@/lib/launch-parity-renderer.test-fixture')).launchStoreModuleMock(holder) +) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) +vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle: () => false })) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), message: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() })) +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useCallback: (fn: T): T => fn } +}) +vi.mock('@/lib/windows-terminal-capabilities', async (importOriginal) => ({ + ...(await importOriginal>()), + hasCachedWindowsTerminalCapabilities: () => true, + getCachedWindowsTerminalCapabilities: () => ({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: false, + gitBashAvailable: false + }) +})) + +const load = perClientLoader(async () => ({ + launch: await import('@/lib/launch-agent-in-new-tab'), + quick: await import('@/lib/run-quick-command-in-new-tab'), + vault: await import('@/lib/launch-ai-vault-session'), + resume: await import('@/lib/ai-vault-resume-command'), + pane: await import('./pty-connection'), + paneOptions: await import('./terminal-pane-manager-options'), + defaults: await import('@/lib/pane-manager/pane-terminal-options'), + lifecycle: await import('./terminal-pane-lifecycle-primitives') +})) + +/** Runs the case's real producer against a real store; returns the tab it opened. */ +async function openTab(c: WindowLaunchCase) { + const modules = await load(c.client) + await installTerminalTestGlobals() + Object.assign(window.api.platform, { get: () => ({ platform: c.client, osRelease: '10' }) }) + const store = modules.createStore() + holder.store = store + store.setState( + launchWorkspaceState(c.workspace, { ...getDefaultSettings('/home/alice'), ...c.settings }) + ) + const worktreeId = launchWorkspaceId(c.workspace) + const producer = c.producer + if (producer.kind === 'quick-command') { + modules.quick.runQuickCommandInNewTab({ + command: { id: 'qc-1', label: 'Fix', action: 'agent-prompt', ...producer }, + worktreeId + }) + } else if (producer.kind === 'continuation') { + // What launchAgentSessionContinuation passes once the agent is detected. + modules.launch.launchAgentInNewTab({ + requestId: `parity-${c.name}`, + agent: producer.agent, + worktreeId, + prompt: producer.prompt, + promptDelivery: 'draft', + launchSource: 'terminal_context_menu', + initialCwd: producer.initialCwd + }) + } else { + // The sidebar's "continue from transcript" builds the startup, then opens the tab. + const startup = modules.resume.buildAiVaultResumeStartupForWorktree({ + state: store.getState(), + worktreeId, + session: { + agent: 'antigravity', + sessionId: 'ide-id', + codexHome: null, + executionHostId: 'local', + filePath: producer.transcript, + cwd: producer.cwd + } + }) + modules.vault.launchAiVaultSessionInNewTab({ agent: 'antigravity', worktreeId, ...startup }) + } + const tab = store.getState().tabsByWorktree[worktreeId]?.at(-1) + if (!tab) { + throw new Error(`${c.name}: the producer opened no tab`) + } + return { modules, store, worktreeId, tab } +} + +/** Mounts the tab's pane the way TerminalPane does and returns the pty:spawn request it sends. */ +async function paneSpawnRequest(c: WindowLaunchCase): Promise { + const { modules, store, worktreeId, tab } = await openTab(c) + const spawn = vi.fn(async (_request: Record) => ({ id: 'pty-1' })) + const noop = (): (() => void) => () => {} + Object.assign(window.api.pty, { + spawn, + onData: noop, + onReplay: noop, + onExit: noop, + onWriteUnavailable: noop, + onSideEffect: noop, + resize: vi.fn(), + claimViewport: vi.fn() + }) + // As TerminalPane mounts: a queued initial cwd, else the tab's startup cwd, else the workspace. + const cwd = modules.lifecycle.resolveQueuedInitialCwd( + undefined, + () => store.getState().consumeTabInitialCwd(tab.id), + tab.startupCwd ?? c.workspace.path + ).startupCwd + const startup = store.getState().pendingStartupByTabId[tab.id] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real store state includes every StoreState member the pane reads. + const deps = buildPaneConnectionDeps(() => store.getState() as never, { + tabId: tab.id, + worktreeId, + cwd, + startup + }) + const pane = createPane(1) + // A measured grid no lane defaults to, so the request shows the pane's size reached it. + Object.assign(pane.terminal, PANE_GRID) + // The xterm keyboard options the real TerminalPane builds for this tab, over the defaults (pane-dom-creation.ts). + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: building the bag and terminalOptions read only these deps, ptyDeps.startup and startupCwd. + const managerOptions = modules.paneOptions.createTerminalPaneManagerOptions({ + deps: { + tabId: tab.id, + worktreeId, + settingsRef: { current: store.getState().settings }, + isVisibleRef: { current: true }, + effectiveMacOptionAsAltRef: { current: 'false' } + }, + ptyDeps: { startup }, + startupCwd: cwd + } as never) + const { vtExtensions } = { + ...modules.defaults.buildDefaultTerminalOptions(), + ...managerOptions.terminalOptions?.(pane.id) + } + pane.terminal.options.vtExtensions = { kittyKeyboard: vtExtensions?.kittyKeyboard === true } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane fixtures model every member connectPanePty reads, as in pty-connection-startup-command-delivery.test.ts. + modules.pane.connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + const [request] = spawn.mock.calls[0] ?? [] + // Fixed ids so the table can name them; the color replies are this harness's theme. + const { terminalColorQueryReplies: _theme, ...rest } = request ?? {} + return JSON.parse( + JSON.stringify(rest) + .replaceAll(tab.id, LAUNCH_TAB_ID) + .replaceAll(String(rest.launchToken), LAUNCH_TOKEN) + ) +} + +// Pins main's current launch behaviour as the convergence parity baseline (rows 2 and 6, window +// half): the real producer, store and pane build the pty:spawn request each case's table row names. +// C2 (typed-prompt tabs onto the pane trigger) and C3 (Antigravity continue) check against it. +describe('window-tab producers: the pty:spawn request a real pane sends', () => { + // Why: the first import transforms the whole pane graph; keep that out of the first case. + beforeAll(async () => { + await load('darwin') + await load('win32') + }, 240_000) + + afterEach(async () => { + await restoreTerminalTestGlobals() + vi.unstubAllGlobals() + }) + + it.each(WINDOW_LAUNCH_CASES)('row $row: $name', async (c) => { + expect(await paneSpawnRequest(c)).toEqual(windowSpawnRequest(c)) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-background-session-launch-parity.test.ts b/src/renderer/src/lib/launch-agent-background-session-launch-parity.test.ts new file mode 100644 index 00000000000..22c561117de --- /dev/null +++ b/src/renderer/src/lib/launch-agent-background-session-launch-parity.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { + launchWorkspaceState, + perClientLoader, + type LaunchStoreHolder +} from '@/lib/launch-parity-renderer.test-fixture' +import { getDefaultSettings } from '../../../shared/constants' +import { + AUTOMATION_LAUNCH_CASES, + AUTOMATION_PROMPT, + automationPhoneTab, + automationSpawnRequest, + type AutomationLaunchCase +} from '../../../shared/launch-parity-automation-cases.test-fixture' +import { + LAUNCH_LEAF_ID, + LAUNCH_TAB_ID, + LAUNCH_TOKEN, + launchWorkspaceId +} from '../../../shared/launch-parity-window-request.test-fixture' + +const holder = vi.hoisted((): LaunchStoreHolder => ({ store: null })) +vi.mock('@/store', async () => + (await import('@/lib/launch-parity-renderer.test-fixture')).launchStoreModuleMock(holder) +) +// The stdin-after-start paste is pinned by launch-agent-background-session.test.ts. +vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn() })) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + registerEagerPtyBuffer: vi.fn(), + subscribeToPtyExit: vi.fn(() => () => {}) +})) +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: vi.fn(() => () => {}) +})) + +const load = perClientLoader(async () => ({ + launch: await import('./launch-agent-background-session'), + phone: await import('@/runtime/sync-runtime-graph/mobile-session-snapshots') +})) + +/** Runs one automation in a local workspace. */ +async function runAutomation(c: AutomationLaunchCase) { + const modules = await load(c.client) + const store = modules.createStore() + holder.store = store + store.setState( + launchWorkspaceState(c.workspace, { ...getDefaultSettings('/home/alice'), ...c.settings }) + ) + const spawn = vi.fn(async (_request: Record) => ({ id: 'pty-1' })) + vi.stubGlobal('window', { + dispatchEvent: vi.fn(), + api: { pty: { spawn, write: vi.fn(), kill: vi.fn() }, runtime: { call: vi.fn() } } + }) + await modules.launch.launchAgentBackgroundSession({ + agent: c.agent, + worktreeId: launchWorkspaceId(c.workspace), + prompt: AUTOMATION_PROMPT, + // Automation dispatch always passes 'unknown'. + launchSource: 'unknown', + title: c.title + }) + const phone = modules.phone.buildMobileSessionTabSnapshots(store.getState(), false) + return { spawn, phone } +} + +/** Swaps the random tab, leaf and token ids for the table's fixed ones. */ +function withFixedIds(value: unknown): unknown { + const text = JSON.stringify(value) + const ids = [ + ...new Set(text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g)) + ] + const fixed = [LAUNCH_TAB_ID, LAUNCH_LEAF_ID, LAUNCH_TOKEN] + return JSON.parse(ids.reduce((out, id, index) => out.replaceAll(id, fixed[index] ?? id), text)) +} + +// Pins main's current launch behaviour as the convergence parity baseline (row 5): the pty:spawn +// request a desktop automation sends and its phone tab. C7 replaces this producer. +describe('row 5: desktop automation pty:spawn request', () => { + beforeAll(async () => { + await load('darwin') + await load('linux') + await load('win32') + }, 240_000) + afterEach(() => vi.unstubAllGlobals()) + + it.each(AUTOMATION_LAUNCH_CASES)('$name', async (c) => { + const { spawn, phone } = await runAutomation(c) + expect(withFixedIds(spawn.mock.calls[0]?.[0])).toEqual(automationSpawnRequest(c)) + expect( + phone.map(({ tabs }) => + tabs.map((tab) => ({ + title: tab.title, + launchAgent: recordOf(tab).launchAgent, + isActive: tab.isActive + })) + ) + ).toEqual([[automationPhoneTab(c)]]) + }) +}) + +function recordOf(value: unknown): Record { + return value && typeof value === 'object' ? { ...value } : {} +} diff --git a/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts b/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts new file mode 100644 index 00000000000..b42581fa0a6 --- /dev/null +++ b/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts @@ -0,0 +1,148 @@ +// Test-only harness shared by the renderer launch-parity suites: per-client module loading and a +// real store seeded with one workspace. Each suite still declares its own vi.mock calls. +import { vi } from 'vitest' +import type * as StoreHelpersNamespace from '@/store/slices/store-test-helpers' +import type * as CapabilitiesNamespace from '@/runtime/local-runtime-capabilities' +import { + LAUNCH_FOLDER_ID, + LAUNCH_REPO_ID, + launchWorkspaceId, + type LaunchClient, + type LaunchWorkspace +} from '../../../shared/launch-parity-window-request.test-fixture' + +const USER_AGENT: Record = { + darwin: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Orca', + linux: 'Mozilla/5.0 (X11; Linux x86_64) Orca', + win32: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Orca' +} + +type StoreHelpers = typeof StoreHelpersNamespace +export type LaunchTestStore = ReturnType + +/** Hoist with vi.hoisted so the `@/store` mock and the suite share the live store. */ +export type LaunchStoreHolder = { store: LaunchTestStore | null } + +export function launchStoreModuleMock(holder: LaunchStoreHolder): Record { + const live = (): LaunchTestStore => { + if (!holder.store) { + throw new Error('launch-parity store not seeded') + } + return holder.store + } + return { + useAppStore: { + getState: () => live().getState(), + setState: (...args: Parameters) => live().setState(...args), + subscribe: (...args: Parameters) => live().subscribe(...args) + } + } +} + +/** + * Imports `load` once per client OS. Why: CLIENT_PLATFORM reads the user agent at import and + * the renderer app platform caches its first answer, so each client needs its own module graph. + */ +export function perClientLoader( + load: () => Promise +): (client: LaunchClient) => Promise { + const cache = new Map() + return async (client) => { + vi.stubGlobal('navigator', { userAgent: USER_AGENT[client] }) + const cached = cache.get(client) + if (cached) { + return cached + } + vi.resetModules() + const helpers: StoreHelpers = await import('@/store/slices/store-test-helpers') + const capabilities: typeof CapabilitiesNamespace = + await import('@/runtime/local-runtime-capabilities') + // The local runtime has answered (without structured support), so no launch waits on it. + capabilities.setLocalRuntimeCapabilitiesForTests([]) + const loaded = { ...(await load()), createStore: helpers.createTestStore } + cache.set(client, loaded) + return loaded + } +} + +/** The store state one workspace needs; `settings` is the full settings object the case runs with. */ +export function launchWorkspaceState( + workspace: LaunchWorkspace, + settings: Record +): Record { + const repoPath = workspace.kind === 'repo' ? workspace.path : workspace.repoPath + const repo = repoPath + ? { + id: LAUNCH_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + kind: 'git', + connectionId: null, + executionHostId: 'local' + } + : null + const worktree = { + id: `${LAUNCH_REPO_ID}::${repoPath}`, + repoId: LAUNCH_REPO_ID, + path: repoPath, + hostId: 'local', + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'wt', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + return { + settings, + repos: repo ? [repo] : [], + projects: repo + ? [ + { + id: LAUNCH_REPO_ID, + displayName: 'repo', + badgeColor: '#000000', + sourceRepoIds: [LAUNCH_REPO_ID], + createdAt: 0, + updatedAt: 0, + ...(workspace.projectRuntime + ? { localWindowsRuntimePreference: workspace.projectRuntime } + : {}) + } + ] + : [], + worktreesByRepo: repo ? { [LAUNCH_REPO_ID]: [worktree] } : {}, + folderWorkspaces: + workspace.kind === 'folder' + ? [ + { + id: LAUNCH_FOLDER_ID.slice('folder:'.length), + projectGroupId: 'pg-1', + name: 'folder', + folderPath: workspace.path, + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + ] + : [], + projectGroups: [{ id: 'pg-1', name: 'group', parentPath: null, parentGroupId: null }], + activeWorktreeId: launchWorkspaceId(workspace) + } +} diff --git a/src/renderer/src/store/terminals/terminal-startup-queue-restart.test.ts b/src/renderer/src/store/terminals/terminal-startup-queue-restart.test.ts new file mode 100644 index 00000000000..8a8f703132f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-startup-queue-restart.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { createTestStore, makeWorktree, seedStore } from '@/store/slices/store-test-helpers' + +const WORKTREE_ID = 'repo1::/wt-1' +const STARTUP_COMMAND = "claude '--dangerously-skip-permissions'" + +function seededStore() { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/wt-1' })] + } + }) + return store +} + +// Pins main's current launch behaviour as the convergence parity baseline (B6; window-lane new tabs, +// rows 1-2 and 6): a queued startup that never reached a pane does not survive an app restart. +// C2 (D-b) must keep a never-mounted tab reopening as a plain shell with no launch record read. +describe('queued tab startup across an app restart on main', () => { + it('persists the agent tab but not its queued startup command', () => { + const before = seededStore() + const tab = before.getState().createTab(WORKTREE_ID, undefined, undefined, { + launchAgent: 'claude' + }) + before.getState().queueTabStartupCommand(tab.id, { + command: STARTUP_COMMAND, + launchConfig: { + agentCommand: STARTUP_COMMAND, + agentArgs: '--dangerously-skip-permissions', + agentEnv: {} + } + }) + expect(before.getState().pendingStartupByTabId[tab.id]?.command).toBe(STARTUP_COMMAND) + + const payload = buildWorkspaceSessionPayload(before.getState()) + const written = JSON.stringify(payload) + expect(written).not.toContain(STARTUP_COMMAND) + expect(written).not.toContain('pendingStartup') + + const parsed = parseWorkspaceSession(JSON.parse(written)) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + return + } + const after = seededStore() + after.getState().hydrateWorkspaceSession(parsed.value) + + // The tab comes back as the agent's tab with no PTY and nothing queued, so its pane spawns a shell. + expect(after.getState().pendingStartupByTabId).toEqual({}) + expect(after.getState().tabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: tab.id, launchAgent: 'claude', ptyId: null }) + ]) + }) +}) diff --git a/src/shared/launch-parity-automation-cases.test-fixture.ts b/src/shared/launch-parity-automation-cases.test-fixture.ts new file mode 100644 index 00000000000..b48520532b3 --- /dev/null +++ b/src/shared/launch-parity-automation-cases.test-fixture.ts @@ -0,0 +1,133 @@ +// Test-only (never imported by production code): the row 5 desktop-automation parity table. +// The renderer suite asserts launchAgentBackgroundSession sends `request`; the main suite feeds the +// same request to pty:spawn. C7 (automations onto agent.launch) and C5 check against it. +import { + LAUNCH_LEAF_ID, + LAUNCH_TAB_ID, + LAUNCH_TOKEN, + launchWorkspaceId, + type LaunchClient, + type LaunchWorkspace, + type WindowProviderFacts +} from './launch-parity-window-request.test-fixture' +import { POSIX_PATH, WIN_PATH, WSL_PATH } from './launch-parity-window-cases.test-fixture' + +export type AutomationLaunchCase = { + name: string + client: LaunchClient + workspace: LaunchWorkspace + settings?: Record + agent: 'claude' | 'codex' | 'aider' + /** The run title; every dispatch passes " run ". */ + title: string + request: { command: string; agentCommand: string; shellOverride?: string } + provider: WindowProviderFacts +} + +// Why an apostrophe: it is the byte that tells POSIX and PowerShell quoting apart. +export const AUTOMATION_PROMPT = "don't stop" +const SKIP = '--dangerously-skip-permissions' +const POSIX_CLAUDE = `claude '${SKIP}'` +const posix = { command: `${POSIX_CLAUDE} 'don'"'"'t stop'`, agentCommand: POSIX_CLAUDE } +const powershell = { command: `${POSIX_CLAUDE} 'don''t stop'`, agentCommand: POSIX_CLAUDE } +const CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'" +const AGENT_COMMAND = { + codex: { command: `${CODEX} 'don'"'"'t stop'`, agentCommand: CODEX }, + aider: { command: "aider '--yes-always'", agentCommand: "aider '--yes-always'" } +} as const +const ZSH = { shellOverride: '/bin/zsh' } +const PWSH = { shellOverride: 'powershell.exe', terminalWindowsWslDistro: null } +const WSL_EXE = { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' } +const repo = (path: string): LaunchWorkspace => ({ kind: 'repo', path }) +type AutomationCaseFields = Omit & + Partial> +const automation = (name: string, fields: AutomationCaseFields): AutomationLaunchCase => ({ + name, + agent: 'claude', + title: 'Nightly audit run 3', + ...fields +}) + +// Only a local \\wsl$ workspace sends a shellOverride. Quoting follows the execution host's OS, the +// same rule a window tab uses; the shell does not. +export const AUTOMATION_LAUNCH_CASES: AutomationLaunchCase[] = [ + automation('macOS repo', { + client: 'darwin', + workspace: repo(POSIX_PATH), + request: posix, + provider: ZSH + }), + automation('Windows C: repo, default PowerShell', { + client: 'win32', + workspace: repo(WIN_PATH), + request: powershell, + provider: PWSH + }), + automation('Windows wsl$ repo, no saved runtime', { + client: 'win32', + workspace: repo(WSL_PATH), + request: { ...posix, shellOverride: 'wsl.exe' }, + provider: WSL_EXE + }), + automation('Linux repo, codex', { + client: 'linux', + agent: 'codex', + workspace: repo(POSIX_PATH), + request: AGENT_COMMAND.codex, + provider: ZSH + }), + // stdin-after-start: the prompt is pasted after launch, never on argv. + automation('Linux repo, aider', { + client: 'linux', + agent: 'aider', + workspace: repo(POSIX_PATH), + request: AGENT_COMMAND.aider, + provider: ZSH + }) +] + +const AGENT_KIND = { claude: 'claude-code', codex: 'codex', aider: 'aider' } as const +const AGENT_ARGS = { + claude: SKIP, + codex: '--dangerously-bypass-approvals-and-sandbox', + aider: '--yes-always' +} as const + +/** The full pty:spawn request launchAgentBackgroundSession sends for this case. */ +export function automationSpawnRequest(c: AutomationLaunchCase): Record { + const worktreeId = launchWorkspaceId(c.workspace) + return { + cols: 120, + rows: 40, + cwd: c.workspace.path, + command: c.request.command, + ...(c.request.shellOverride ? { shellOverride: c.request.shellOverride } : {}), + ...(c.agent === 'codex' ? { startupCommandDelivery: 'shell-ready' } : {}), + env: { + ORCA_PANE_KEY: `${LAUNCH_TAB_ID}:${LAUNCH_LEAF_ID}`, + ORCA_TAB_ID: LAUNCH_TAB_ID, + ORCA_WORKTREE_ID: worktreeId, + ORCA_AGENT_LAUNCH_TOKEN: LAUNCH_TOKEN + }, + launchConfig: { + agentCommand: c.request.agentCommand, + agentArgs: AGENT_ARGS[c.agent], + agentEnv: {} + }, + launchToken: LAUNCH_TOKEN, + launchAgent: c.agent, + connectionId: null, + worktreeId, + tabId: LAUNCH_TAB_ID, + leafId: LAUNCH_LEAF_ID, + placement: { kind: 'new-tab', row: { customTitle: c.title } }, + telemetry: { agent_kind: AGENT_KIND[c.agent], launch_source: 'unknown', request_kind: 'new' } + } +} + +/** The phone's view of a run tab: hidden under its run title, no agent named, active. */ +export function automationPhoneTab( + c: Pick +): Record { + return { title: c.title, launchAgent: undefined, isActive: true } +} diff --git a/src/shared/launch-parity-window-cases.test-fixture.ts b/src/shared/launch-parity-window-cases.test-fixture.ts new file mode 100644 index 00000000000..6bdf5fe313d --- /dev/null +++ b/src/shared/launch-parity-window-cases.test-fixture.ts @@ -0,0 +1,219 @@ +// Test-only (never imported by production code): the window-lane launch parity table for rows 2 +// (typed prompt into a new tab) and 6 (continue from transcript). Renderer suites assert a real +// producer + pane sends `request`; main suites feed it to pty:spawn and assert `provider`. +import type { + LaunchClient, + LaunchWorkspace, + WindowLaunchCase, + WindowLaunchProducer, + WindowProviderFacts, + WindowRequestFacts +} from './launch-parity-window-request.test-fixture' + +export const POSIX_PATH = '/home/alice/repo' +export const WIN_PATH = String.raw`C:\Users\alice\repo` +export const WSL_PATH = String.raw`\\wsl$\Ubuntu\home\alice\repo` + +const SKIP = '--dangerously-skip-permissions' +const POSIX_CLAUDE = `claude '${SKIP}'` +const CMD_CLAUDE = `claude "${SKIP}"` +// The auto-submitted prompt rides on argv. +const posix = { command: `${POSIX_CLAUDE} 'fix Bob'"'"'s bug'`, agentCommand: POSIX_CLAUDE } +const ps = { command: `${POSIX_CLAUDE} 'fix Bob''s bug'`, agentCommand: POSIX_CLAUDE } +const cmd = { command: `${CMD_CLAUDE} "fix Bob's bug"`, agentCommand: CMD_CLAUDE } + +const BRAIN = '.gemini/antigravity-ide/brain/ide-id/transcript_full.jsonl' +const MAC_T = `/Users/alice/${BRAIN}` +const WIN_T = `C:/Users/alice/${BRAIN}` +const LINUX_T = `/home/alice/${BRAIN}` +const reference = (path: string): string => + `Review the chat transcript at "${path}". Confirm your understanding of the previous conversation and continue from where it left off. This starts a new CLI conversation using the original transcript as a reference.` +const agy = (path: string): WindowRequestFacts => ({ + command: `agy '${SKIP}' --prompt-interactive '${reference(path)}'`, + agentCommand: `agy '${SKIP}'` +}) + +const repo = (path: string, extra: Partial = {}): LaunchWorkspace => ({ + kind: 'repo', + path, + ...extra +}) +const folder = (path: string, extra: Partial = {}): LaunchWorkspace => ({ + kind: 'folder', + path, + ...extra +}) +const WSL_UBUNTU = { kind: 'wsl', distro: 'Ubuntu' } as const +const HOST_SENT = { projectRuntime: { kind: 'windows-host', reason: 'global-default' } } as const +const WSL_SENT = { + projectRuntime: { kind: 'wsl', reason: 'project-override', distro: 'Ubuntu' } +} as const +const IN_PWSH = { shellOverride: 'powershell.exe' } +const IN_WSL = { shellOverride: 'wsl.exe' } +// Main runs a local pane in the settings shell; Windows adds the WSL distro it derived. +const ZSH = { shellOverride: '/bin/zsh' } +const PWSH = { shellOverride: 'powershell.exe', terminalWindowsWslDistro: null } +const WSL_EXE = { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' } +const CMD_EXE = { shellOverride: 'cmd.exe', terminalWindowsWslDistro: null } +const CMD = { terminalWindowsShell: 'cmd.exe' } + +function launch( + row: 2 | 6, + producer: WindowLaunchProducer, + name: string, + client: LaunchClient, + workspace: LaunchWorkspace, + request: WindowRequestFacts, + provider: WindowProviderFacts, + settings?: Record +): WindowLaunchCase { + return { name, row, client, workspace, producer, request, provider, settings } +} +// A quick command's prompt; the apostrophe shows which shell the command was quoted for. +const quick = { kind: 'quick-command', agent: 'claude', prompt: "fix Bob's bug" } as const +const typed = launch.bind(null, 2, quick) +const resume = (transcript: string, cwd: string | null) => + launch.bind(null, 6, { kind: 'transcript-continue', transcript, cwd }) + +export const WINDOW_LAUNCH_CASES: WindowLaunchCase[] = [ + // C2 (typed-prompt tabs onto the pane trigger) and C5a-e (assembler merge) check against rows 2. + typed( + 'macOS repo, saved default env', + 'darwin', + repo(POSIX_PATH), + { ...posix, agentEnv: { A: '1' } }, + ZSH, + { agentDefaultEnv: { claude: { A: '1' } } } + ), + typed('macOS folder', 'darwin', folder(POSIX_PATH), posix, ZSH), + // The draft rides --prefill. + launch( + 2, + { + kind: 'continuation', + agent: 'claude', + prompt: "fix Bob's bug", + initialCwd: `${POSIX_PATH}/pkg` + }, + 'macOS repo, terminal continuation in the pane cwd', + 'darwin', + repo(POSIX_PATH), + { + command: `${POSIX_CLAUDE} --prefill 'fix Bob'"'"'s bug'`, + agentCommand: POSIX_CLAUDE, + cwd: `${POSIX_PATH}/pkg` + }, + ZSH + ), + launch( + 2, + { ...quick, agent: 'codex' }, + 'macOS repo, codex auto-submit waits for the shell', + 'darwin', + repo(POSIX_PATH), + { + command: `codex '--dangerously-bypass-approvals-and-sandbox' 'fix Bob'"'"'s bug'`, + agentCommand: `codex '--dangerously-bypass-approvals-and-sandbox'` + }, + ZSH + ), + // C1 reproduces this Windows platform/shell rule on the host planner. + typed( + 'Windows C: repo, default PowerShell', + 'win32', + repo(WIN_PATH), + { ...ps, ...IN_PWSH, ...HOST_SENT }, + PWSH + ), + typed( + 'Windows C: repo, cmd.exe', + 'win32', + repo(WIN_PATH), + { ...cmd, shellOverride: 'cmd.exe', ...HOST_SENT }, + CMD_EXE, + CMD + ), + // A never-saved \\wsl$ project reads as a WSL project override from its path. + typed( + 'Windows wsl$ repo, no saved runtime', + 'win32', + repo(WSL_PATH), + { ...posix, ...IN_WSL, ...WSL_SENT }, + WSL_EXE + ), + typed( + 'Windows C: repo, project WSL', + 'win32', + repo(WIN_PATH, { projectRuntime: WSL_UBUNTU }), + { ...posix, ...IN_WSL, ...WSL_SENT }, + WSL_EXE + ), + typed( + 'Windows C: repo, global default WSL', + 'win32', + repo(WIN_PATH), + { + ...posix, + ...IN_WSL, + projectRuntime: { kind: 'wsl', reason: 'global-default', distro: 'Ubuntu' } as const + }, + WSL_EXE, + { localWindowsRuntimeDefault: WSL_UBUNTU } + ), + typed('Windows C: folder', 'win32', folder(WIN_PATH), { ...ps, ...IN_PWSH }, PWSH), + // The folder's \\wsl$ path makes its host Linux, matching the wsl.exe createTab picks from the path. + typed('Windows wsl$ folder', 'win32', folder(WSL_PATH), { ...posix, ...IN_WSL }, WSL_EXE), + // A folder borrows the runtime of a repo it contains (or equals). + typed( + 'Windows folder containing a WSL-project repo', + 'win32', + folder(String.raw`C:\Users\alice`, { + repoPath: WIN_PATH, + projectRuntime: WSL_UBUNTU + }), + { ...posix, ...IN_WSL, ...WSL_SENT }, + WSL_EXE + ), + // C3 (Antigravity continue) checks against rows 6. + resume(MAC_T, `${POSIX_PATH}/pkg`)( + 'continue, macOS repo, transcript cwd', + 'darwin', + repo(POSIX_PATH), + { + ...agy(MAC_T), + cwd: `${POSIX_PATH}/pkg` + }, + ZSH + ), + resume(MAC_T, null)( + 'continue, macOS repo, no transcript cwd', + 'darwin', + repo(POSIX_PATH), + agy(MAC_T), + ZSH + ), + // cmd escapes the prompt's inner quotes with a caret. + resume(WIN_T, WIN_PATH)( + 'continue, Windows C: repo, cmd.exe', + 'win32', + repo(WIN_PATH), + { + command: `agy "${SKIP}" --prompt-interactive "${reference(WIN_T).replace(/"/g, '^"')}"`, + agentCommand: `agy "${SKIP}"`, + shellOverride: 'cmd.exe', + ...HOST_SENT + }, + CMD_EXE, + CMD + ), + // The \\wsl$ transcript path is rewritten to its Linux spelling; the Linux cwd goes out as is. + resume(`//wsl.localhost/Ubuntu/home/alice/${BRAIN}`, POSIX_PATH)( + 'continue, Windows wsl$ WSL-project repo', + 'win32', + repo(WSL_PATH, { + projectRuntime: WSL_UBUNTU + }), + { ...agy(LINUX_T), cwd: POSIX_PATH, ...IN_WSL, ...WSL_SENT }, + WSL_EXE + ) +] diff --git a/src/shared/launch-parity-window-request.test-fixture.ts b/src/shared/launch-parity-window-request.test-fixture.ts new file mode 100644 index 00000000000..17b700b586d --- /dev/null +++ b/src/shared/launch-parity-window-request.test-fixture.ts @@ -0,0 +1,183 @@ +// Test-only (never imported by production code): turns a window-lane parity case into the exact +// pty:spawn request a real pane sends, with fixed ids so renderer and main compare the same bytes. +import type { LocalWindowsRuntimePreference } from './project-execution-runtime' + +export type LaunchClient = 'darwin' | 'linux' | 'win32' +export const LAUNCH_REPO_ID = 'repo-1' +export const LAUNCH_FOLDER_ID = 'folder:fw-1' +export const LAUNCH_TAB_ID = 'tab-parity' +// The pane fixture's first leaf (pty-connection-test-pane-fixtures.ts). +export const LAUNCH_LEAF_ID = '11111111-1111-4111-8111-111111111111' +export const LAUNCH_TOKEN = 'launch-token-parity' +/** The window pane's measured grid: no lane defaults to it, so it shows the pane size reached the provider. */ +export const PANE_GRID = { cols: 97, rows: 31 } as const + +export type LaunchWorkspace = { + kind: 'repo' | 'folder' + path: string + /** The project's saved Windows runtime; absent = never saved. */ + projectRuntime?: LocalWindowsRuntimePreference + /** Folder workspaces only: a local repo at this path, to show which repos a folder borrows. */ + repoPath?: string +} + +export function launchWorkspaceId(workspace: LaunchWorkspace): string { + return workspace.kind === 'folder' ? LAUNCH_FOLDER_ID : `${LAUNCH_REPO_ID}::${workspace.path}` +} + +export type WindowLaunchProducer = + /** An agent-prompt quick command (run-quick-command-in-new-tab.ts): Claude and Codex auto-submit. */ + | { kind: 'quick-command'; agent: 'claude' | 'codex'; prompt: string } + /** A terminal's "continue in new session": Claude gets a draft in the pane's cwd. */ + | { kind: 'continuation'; agent: 'claude'; prompt: string; initialCwd: string } + | { kind: 'transcript-continue'; transcript: string; cwd: string | null } + +type ProjectRuntimeSent = + | { kind: 'windows-host'; reason: 'global-default' | 'project-override' } + | { kind: 'wsl'; reason: 'global-default' | 'project-override'; distro: string } + +/** What the pane sends (renderer half). Unset fields take the defaults in windowSpawnRequest. */ +export type WindowRequestFacts = { + command: string + agentCommand: string + shellOverride?: string + projectRuntime?: ProjectRuntimeSent + cwd?: string + /** The agent's saved default env, sent in the pane env and the launch config. */ + agentEnv?: Record +} + +/** What reaches the provider for that request (main half). */ +export type WindowProviderFacts = { + shellOverride: string | undefined + terminalWindowsWslDistro?: string | null +} + +export type WindowLaunchCase = { + name: string + /** The launch-convergence producer row. */ + row: 2 | 6 + client: LaunchClient + workspace: LaunchWorkspace + settings?: Record + producer: WindowLaunchProducer + request: WindowRequestFacts + provider: WindowProviderFacts +} + +const AGENT_ARGS = { + claude: '--dangerously-skip-permissions', + codex: '--dangerously-bypass-approvals-and-sandbox', + aider: '--yes-always', + antigravity: '--dangerously-skip-permissions' +} as const + +function sentRuntime(runtime: ProjectRuntimeSent): Record { + const base = { projectId: LAUNCH_REPO_ID, reason: runtime.reason } + return { + status: 'resolved', + runtime: + runtime.kind === 'wsl' + ? { + ...base, + kind: 'wsl', + hostPlatform: 'wsl', + distro: runtime.distro, + cacheKey: `${LAUNCH_REPO_ID}:wsl:${runtime.distro}` + } + : { + ...base, + kind: 'windows-host', + hostPlatform: 'win32', + cacheKey: `${LAUNCH_REPO_ID}:windows-host` + } + } +} + +/** The full pty:spawn request a real pane sends for this case. */ +export function windowSpawnRequest(c: WindowLaunchCase): Record { + const { workspace, producer, request } = c + const worktreeId = launchWorkspaceId(workspace) + const agent = producer.kind === 'transcript-continue' ? 'antigravity' : producer.agent + const cwd = request.cwd ?? workspace.path + // A local Windows ConPTY pane withholds the kitty keyboard flag (terminal-keyboard-protocol.ts). + const localConpty = + c.client === 'win32' && request.shellOverride !== 'wsl.exe' && !cwd.startsWith('\\\\wsl') + return { + ...PANE_GRID, + cwd, + cwdFallback: 'worktree', + env: { + ...request.agentEnv, + ORCA_WORKSPACE_ID: worktreeId, + ...(workspace.kind === 'folder' + ? { ORCA_PROJECT_GROUP_ID: 'pg-1', ORCA_WORKSPACE_ROOT: workspace.path } + : {}), + ORCA_PANE_KEY: `${LAUNCH_TAB_ID}:${LAUNCH_LEAF_ID}`, + ORCA_TAB_ID: LAUNCH_TAB_ID, + ORCA_WORKTREE_ID: worktreeId, + ORCA_AGENT_LAUNCH_TOKEN: LAUNCH_TOKEN + }, + command: request.command, + launchConfig: { + agentCommand: request.agentCommand, + agentArgs: AGENT_ARGS[agent], + agentEnv: request.agentEnv ?? {} + }, + launchToken: LAUNCH_TOKEN, + launchAgent: agent, + ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' } : {}), + worktreeId, + tabId: LAUNCH_TAB_ID, + leafId: LAUNCH_LEAF_ID, + ...(request.shellOverride ? { shellOverride: request.shellOverride } : {}), + ...(request.projectRuntime ? { projectRuntime: sentRuntime(request.projectRuntime) } : {}), + ...(localConpty ? {} : { terminalKittyKeyboardProtocol: true }), + telemetry: + producer.kind === 'transcript-continue' + ? { agent_kind: 'antigravity', launch_source: 'sidebar', request_kind: 'resume' } + : { + agent_kind: agent === 'claude' ? 'claude-code' : agent, + launch_source: + producer.kind === 'quick-command' ? 'quick_command' : 'terminal_context_menu', + request_kind: 'new' + } + } +} + +// The pane identity keys the plan names; agent hook and CLI keys vary by host and are not launch facts. +export const IDENTITY_ENV_KEYS = [ + 'ORCA_WORKSPACE_ID', + 'ORCA_PROJECT_GROUP_ID', + 'ORCA_WORKSPACE_ROOT', + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + 'ORCA_TERMINAL_HANDLE' +] as const + +/** What main hands the provider for a window-lane pty:spawn request (rows 2, 5 and 6). */ +export function expectedWindowProvider( + request: Record, + provider: WindowProviderFacts, + agentEnv: Record +): Record { + // main adds the terminal handle to the pane's env. + const env = { ...(typeof request.env === 'object' ? request.env : {}), ORCA_TERMINAL_HANDLE: '' } + return { + command: request.command, + // main normalizes the cwd to forward slashes (resolveRuntimePath). + cwd: String(request.cwd).replaceAll('\\', '/'), + // The pane's measured size, and never marked hidden before its pane shows. + cols: request.cols, + rows: request.rows, + hidden: false, + env: agentEnv, + ...provider, + ...(request.startupCommandDelivery + ? { startupCommandDelivery: request.startupCommandDelivery } + : {}), + orcaEnv: IDENTITY_ENV_KEYS.filter((key) => key in env) + } +}