diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 8910ccd1e60..7d7de2b2b59 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -15,6 +15,7 @@ import { type AgentLaunchExecution } from './agent-launch-executor' import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' const STRUCTURED_PREFERENCE = { experimentalNativeChat: true, @@ -247,3 +248,50 @@ describe('the prompt receipt', () => { expect((await h.run(CREATE_INTENT)).prompt).toBeUndefined() }) }) + +/** + * The kind is read off the resolved workspace id, so a workspace with nowhere to keep a session is + * decided here rather than offered to a host probe that cannot answer for it. + */ +describe('a launch into an existing workspace, by workspace kind', () => { + it('runs the floating workspace as a terminal, never a structured session', async () => { + const h = harness({}) + const result = await h.run({ + agent: 'claude', + target: { kind: 'existing', worktree: FLOATING_TERMINAL_WORKTREE_ID } + }) + + // The invariant, not the call order: the floating sentinel has no session store to open into. + expect(h.createStructuredSession).not.toHaveBeenCalled() + expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) + expect(result.receipt).toMatchObject({ + mode: 'terminal', + reason: 'structured_unsupported_on_host' + }) + }) + + it('still opens a structured session in a folder workspace', async () => { + const h = harness({}) + const result = await h.run({ + agent: 'claude', + target: { kind: 'existing', worktree: 'folder:fw-1' } + }) + + // A folder workspace has no git worktree either; it must not be swept up with the sentinel. + expect(h.createTerminalAgent).not.toHaveBeenCalled() + expect(result.outcome).toEqual({ + kind: 'structured', + sessionId: 'sess-1', + handle: 'handle_structured' + }) + expect(result.receipt).toMatchObject({ mode: 'structured' }) + }) + + it('still opens a structured session in a git worktree', async () => { + const h = harness({}) + const result = await h.run({ agent: 'claude', target: { kind: 'existing', worktree: 'wt-7' } }) + + expect(result.outcome.kind).toBe('structured') + expect(result.receipt).toMatchObject({ mode: 'structured' }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index c65f63061fa..a92874b3505 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -32,6 +32,10 @@ import type { } from '../../shared/agent-launch-intent' import { withoutReservedAgentCreateFields } from '../../shared/agent-launch-intent' import type { TuiAgent } from '../../shared/tui-agent' +import { + workspaceKindForWorktreeId, + type WorkspaceLaunchKind +} from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal' import { @@ -107,6 +111,7 @@ export async function executeAgentLaunch( const preflight = decideAgentLaunchMode({ placement: { agent: intent.agent, + workspaceKind: launchWorkspaceKind(intent.target), ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}) }, settings, @@ -279,6 +284,15 @@ function existingWorktreeId(target: AgentLaunchTarget): string { return target.kind === 'existing' ? target.worktree : '' } +/** + * Read from the id rather than carried alongside it, so the kind cannot disagree with the workspace + * it describes. `worktree` here is never a caller's selector — the method resolved it to an id + * before building the intent — and a create always produces a git worktree. + */ +function launchWorkspaceKind(target: AgentLaunchTarget): WorkspaceLaunchKind { + return target.kind === 'existing' ? workspaceKindForWorktreeId(target.worktree) : 'git-worktree' +} + /** Prompt delivery is the caller's, not the executor's: a PTY paste is observed by whoever owns * the pane, and a structured first turn is sent through the session. The executor reports the * requested delivery back as not delivered so a caller cannot mistake silence for delivery. */ diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 7533c0f2a01..1aee0e65cc3 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -32,6 +32,7 @@ import { } from '../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../shared/tui-agent' import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' +import type { WorkspaceLaunchKind } from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' // The receipt is part of the launch contract, so it is declared with the rest of it; re-exported @@ -67,6 +68,10 @@ export type AgentLaunchModePlacement = { on?: string /** An existing terminal being reused. */ terminal?: string + /** Which kind of workspace the launch lands in, derived by the host from the workspace it + * resolved — never accepted from a caller, which would let one route around this decision. + * Absent means the kind was never established, and is not read as any particular kind. */ + workspaceKind?: WorkspaceLaunchKind } const DOWNGRADE_DETAIL: Record, string> = { @@ -131,9 +136,11 @@ export function decideAgentLaunchMode(args: { executionHostId: placement.on ? `runtime:${placement.on}` : 'local', reusesTerminal: Boolean(placement.terminal), hostCapabilities: RUNTIME_CAPABILITIES, - // A resolved managed worktree or folder workspace is never a floating terminal. WSL is left to - // the executing host's own create-support probe, which reads the resolved workspace rather - // than guessing from a client-side project runtime. + // The floating workspace has nowhere to keep a session, so it is decided here rather than left + // to the host probe below, which cannot answer for a workspace with no record. WSL still is: + // the create-support probe reads the resolved workspace rather than guessing from a + // client-side project runtime. + ...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}), requiresTuiLaunchCommand: hasExplicitTuiLaunchCommand(settings, agent) }) if (!support.supported) { diff --git a/src/main/runtime/orca-runtime-list-managed-worktrees.ts b/src/main/runtime/orca-runtime-list-managed-worktrees.ts index 69aa37b6ef0..7fe067e9276 100644 --- a/src/main/runtime/orca-runtime-list-managed-worktrees.ts +++ b/src/main/runtime/orca-runtime-list-managed-worktrees.ts @@ -8,6 +8,7 @@ import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconc import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import type { WorktreeVisibilitySourceMatcher } from '../../shared/worktree/visibility-sources' import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import type { WorkspacePortKillRequest, WorkspacePortKillResult, @@ -118,6 +119,10 @@ export class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreS return await this.resolveWorktreeSelector(worktreeSelector) } + /** + * The git worktree record behind a terminal workspace. Refuses the floating sentinel, which has + * no such record — callers that only need to address the workspace want the scope below instead. + */ async showManagedTerminalWorkspace(worktreeSelector: string) { const target = await this.resolveTerminalWorkspaceLaunchTarget(worktreeSelector) if (!target.managedWorktree) { @@ -126,6 +131,18 @@ export class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreS return target.managedWorktree } + /** + * Where a terminal workspace is, for every kind one can be: a git worktree, a folder workspace, + * or the floating sentinel. This is the general answer — `id` and `path` are resolved the same + * way for all three — so a caller that reads only those must ask for this rather than demand a + * worktree record it never reads and lose the floating workspace to a `selector_not_found`. + */ + async showTerminalWorkspaceLaunchScope( + worktreeSelector: string + ): Promise { + return await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + } + async scanWorkspacePorts(repoId?: string): Promise { return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId)) } diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts new file mode 100644 index 00000000000..5551c4de354 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -0,0 +1,79 @@ +import { homedir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { OrcaRuntimeService } from '../../orca-runtime' +import { AGENT_LAUNCH_METHODS } from './agent-launch' +import { CAPABLE_CLIENT, methodNamed, STRUCTURED_PREFERENCE } from './agent-launch.test-fixture' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const launch = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') +const selectors = [FLOATING_TERMINAL_WORKTREE_ID, `id:${FLOATING_TERMINAL_WORKTREE_ID}`] + +afterEach(() => vi.restoreAllMocks()) + +describe('agent.launch with the real floating workspace resolver', () => { + it.each(selectors)('resolves %s without a managed worktree record', async (selector) => { + const runtime = new OrcaRuntimeService() + + await expect(runtime.showManagedTerminalWorkspace(selector)).rejects.toThrow( + 'selector_not_found' + ) + await expect(runtime.showTerminalWorkspaceLaunchScope(selector)).resolves.toEqual({ + id: FLOATING_TERMINAL_WORKTREE_ID, + path: homedir(), + connectionId: null, + repo: null, + folderWorkspace: null + }) + }) + + describe.each([true, false])('structured preference %s', (structuredPreference) => { + it.each(selectors)('launches a terminal through %s', async (selector) => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockReturnValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only these preferences and optional agentCmdOverrides; no other settings consumer runs because terminal creation is stubbed. + { + ...STRUCTURED_PREFERENCE, + openAgentTabsInChatByDefault: structuredPreference + } as ReturnType + ) + const scope = vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope') + const createSupport = vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport') + const structuredHost = vi.spyOn(runtime, 'ensureStructuredAgentSessionHost') + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_floating', + tabId: 'tab_floating', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + title: 'Claude', + surface: 'background' + }) + + const result = await launch.handler( + launch.params.parse({ agent: 'claude', target: { kind: 'existing', worktree: selector } }), + { runtime, ...CAPABLE_CLIENT } + ) + + expect(scope).toHaveBeenCalledExactlyOnceWith(selector) + expect(createSupport).not.toHaveBeenCalled() + expect(structuredHost).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledExactlyOnceWith( + `id:${FLOATING_TERMINAL_WORKTREE_ID}`, + { startupAgent: 'claude' } + ) + expect(result).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + outcome: { kind: 'terminal', handle: 'term_floating' }, + receipt: { + mode: 'terminal', + reason: structuredPreference ? 'structured_unsupported_on_host' : 'user_default' + } + }) + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 6787bcfcf10..3d6981ed4a1 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -339,7 +339,7 @@ describe('an uncertain launch stays uncertain', () => { it('records a failure that happened before anything could be created', async () => { const runtime = runtimeStub() - runtime.showManagedTerminalWorkspace.mockRejectedValueOnce(new Error('worktree_not_found')) + runtime.showTerminalWorkspaceLaunchScope.mockRejectedValueOnce(new Error('worktree_not_found')) await expect( launch( @@ -481,7 +481,7 @@ describe('an unreadable launch payload costs one replay, never the store', () => describe('a recorded failure replays as the failure it was', () => { it('answers with the code the launch actually raised, not the ledger vocabulary', async () => { const runtime = runtimeStub() - runtime.showManagedTerminalWorkspace.mockRejectedValue(new Error('worktree_not_found')) + runtime.showTerminalWorkspaceLaunchScope.mockRejectedValue(new Error('worktree_not_found')) const params = createLaunch({ operationId: OPERATION_ID, target: { kind: 'existing', worktree: 'gone' } @@ -493,14 +493,14 @@ describe('a recorded failure replays as the failure it was', () => { // malformed" signal, which tells a client to mint a fresh id when the truthful answer is that // this launch definitively did not run. const replayed = runtimeStub() - replayed.showManagedTerminalWorkspace.mockRejectedValue(new Error('worktree_not_found')) + replayed.showTerminalWorkspaceLaunchScope.mockRejectedValue(new Error('worktree_not_found')) await expect(launch(params, replayed)).rejects.toThrow('worktree_not_found') - expect(replayed.showManagedTerminalWorkspace).not.toHaveBeenCalled() + expect(replayed.showTerminalWorkspaceLaunchScope).not.toHaveBeenCalled() }) it('bounds the code it persists, because a code is an identifier and a message is not', async () => { const runtime = runtimeStub() - runtime.showManagedTerminalWorkspace.mockRejectedValue( + runtime.showTerminalWorkspaceLaunchScope.mockRejectedValue( new Error(`ENOENT: no such file or directory, stat '${'/very/long/path'.repeat(400)}'`) ) diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 7272b5bb7af..6b52635fc72 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -73,6 +73,15 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { showManagedTerminalWorkspace: vi.fn(async (selector: string) => ({ id: selector.replace(/^id:/, '') })), + // The scope resolves for every workspace kind, so unlike the worktree record above it never + // refuses the floating sentinel — which is the whole reason the launch asks for this one. + showTerminalWorkspaceLaunchScope: vi.fn(async (selector: string) => ({ + id: selector.replace(/^id:/, ''), + path: '/tmp/wt-7', + connectionId: null, + repo: null, + folderWorkspace: null + })), ensureStructuredAgentSessionHost: vi.fn(async () => {}), waitForSetupTerminalCompletion } diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 6806726f0d2..2175aab0234 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -449,7 +449,9 @@ describe('the terminal factory', () => { ) expect(runtime.createManagedWorktree).not.toHaveBeenCalled() - expect(runtime.showManagedTerminalWorkspace).toHaveBeenCalledWith('id:wt-7') + // The scope, not the worktree record: asking for the record refused any workspace without one. + expect(runtime.showTerminalWorkspaceLaunchScope).toHaveBeenCalledWith('id:wt-7') + expect(runtime.showManagedTerminalWorkspace).not.toHaveBeenCalled() // Resolved to an id first: everything below re-prefixes it, so a raw selector reaches the // runtime as `id:id:wt-7`. expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { startupAgent: 'grok' }) diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index af6e018a36d..787652fa293 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -59,17 +59,22 @@ export function supportsAgentLaunch( /** * A client addresses a workspace by selector, but the result's `worktreeId` is an id and every * step below the executor re-prefixes it as `id:`. Resolving here is what keeps a - * caller's `id:wt-7` from reaching the runtime as `id:id:wt-7`; the terminal-workspace resolver is - * used rather than the git-worktree one so a folder workspace is addressable too. + * caller's `id:wt-7` from reaching the runtime as `id:id:wt-7`. + * + * The launch *scope* is what is asked for, because the id below is the only thing read off it. The + * git-worktree record is the narrower answer — it does not exist for the floating workspace, so + * asking for one refused a launch this method can perfectly well run, on a workspace whose id it + * had already resolved. A folder workspace survived that only because the resolver fabricates a + * worktree row for it; the scope is the answer that is real for all three kinds. */ async function agentLaunchTarget( params: AgentLaunchParams, - runtime: Pick + runtime: Pick ): Promise { if (params.target.kind === 'create-worktree') { return { kind: 'create-worktree', create: { ...params.target.create } } } - const workspace = await runtime.showManagedTerminalWorkspace(params.target.worktree) + const workspace = await runtime.showTerminalWorkspaceLaunchScope(params.target.worktree) return { kind: 'existing', worktree: workspace.id } } diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index 85fbf2e8ca3..2f0c1e083f4 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -1,11 +1,10 @@ -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, toRuntimeExecutionHostId } from '../../../shared/execution-host' import type { TuiAgent } from '../../../shared/tui-agent' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { workspaceKindForWorktreeId } from '../../../shared/workspace-launch-kind' import { hasExplicitTuiLaunchCommand, type AgentLaunchRoutingInput @@ -57,12 +56,7 @@ export type AgentLaunchRouteArgs = { initialSessionOptions?: Readonly> } -export function workspaceKindForWorktreeId(worktreeId: string): ProspectiveWorkspaceKind { - if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - return 'floating' - } - return parseWorkspaceKey(worktreeId)?.type === 'folder' ? 'folder' : 'git-worktree' -} +export { workspaceKindForWorktreeId } function resolveExecutionHostId(store: AgentLaunchRouteStore, workspace: ProspectiveWorkspace) { if (workspace.worktreeId) { diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index ba614053131..67d47cf1d23 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -5,6 +5,7 @@ import { resolveStructuredNativeChatSupport } from '../../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../../shared/tui-agent' +import type { WorkspaceLaunchKind } from '../../../shared/workspace-launch-kind' import { decideInitialAgentTabViewMode, type NativeChatLaunchPromptDelivery @@ -28,7 +29,7 @@ export type AgentLaunchRoutingInput = { executionHostId: string /** Capabilities of the target host; `null` = not yet established. */ hostCapabilities: readonly string[] | null - workspaceKind?: 'git-worktree' | 'folder' | 'floating' + workspaceKind?: WorkspaceLaunchKind projectRuntime?: ProjectExecutionRuntimeResolution | null promptDelivery?: NativeChatLaunchPromptDelivery launchText?: string diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index bde8de06c0a..5ae855ea47e 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -13,6 +13,7 @@ import type { GlobalSettings } from './global-settings-types' import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' import type { TuiAgent } from './tui-agent' +import type { WorkspaceLaunchKind } from './workspace-launch-kind' export type NativeChatDefaultSettings = Pick< GlobalSettings, @@ -44,7 +45,8 @@ export type StructuredNativeChatSupportInput = { executionHostId: string /** Capabilities of the host this launch would run on. `null` = not yet established. */ hostCapabilities: readonly string[] | null - workspaceKind?: 'git-worktree' | 'folder' | 'floating' + /** Host-derived. Absent means the kind was never established, which is not evidence of any kind. */ + workspaceKind?: WorkspaceLaunchKind projectRuntime?: ProjectExecutionRuntimeResolution | null requiresTuiLaunchCommand?: boolean /** An existing PTY agent keeps its execution transport. */ diff --git a/src/shared/workspace-launch-kind.ts b/src/shared/workspace-launch-kind.ts new file mode 100644 index 00000000000..b4c03ef96dc --- /dev/null +++ b/src/shared/workspace-launch-kind.ts @@ -0,0 +1,23 @@ +/** + * Which kind of workspace a launch lands in, read from the workspace's own id. + * + * The three kinds are not interchangeable to a launch: only a git worktree and a folder workspace + * have somewhere a structured session can live, and the floating terminal — a sentinel with no + * backing repo, worktree or folder row — can host a PTY and nothing else. + * + * It lives in `shared` because both sides of the launch ask the same question: the renderer when a + * user opens an agent tab, and the host when it resolves an `agent.launch` target. A host must + * never take the answer from a caller, so it derives it here from the id it resolved itself. + */ + +import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' +import { parseWorkspaceKey } from './workspace-scope' + +export type WorkspaceLaunchKind = 'git-worktree' | 'folder' | 'floating' + +export function workspaceKindForWorktreeId(worktreeId: string): WorkspaceLaunchKind { + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return 'floating' + } + return parseWorkspaceKey(worktreeId)?.type === 'folder' ? 'folder' : 'git-worktree' +}