diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 5b7ba8aa733..85a6f048eb5 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -201,6 +201,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/orchestration/orchestration-party-location.test.ts', 'src/main/runtime/orchestration/structured-worker-journal-page.test.ts', 'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts', + 'src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts', 'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts', 'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts', 'src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts', diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index b917dff661b..9356dda7da4 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -28,6 +28,8 @@ function harness(options: { /** Whether the surface reports that its typed line took the offered prompt. */ lineCarriesPrompt?: boolean onSurfacePublished?: AgentLaunchExecution['onSurfacePublished'] + /** A worktree-only factory, as orchestration workers and `worktree.create` pass. */ + worktreeOnlyFactory?: boolean }) { const calls: string[] = [] const carried = (startupPrompt: string | undefined) => @@ -47,6 +49,10 @@ function harness(options: { } } ) + const createFolderWorkspace = vi.fn(async (_args: { create: Record }) => { + calls.push('createFolderWorkspace') + return { worktreeId: 'folder:fw-new', connectionId: null } + }) const getStructuredAgentSessionCreateSupport = vi.fn(async () => { calls.push('createSupport') if (options.createSupportThrows) { @@ -81,6 +87,7 @@ function harness(options: { return { calls, createWorktree, + createFolderWorkspace, createStructuredSession, createTerminalAgent, deliverStructuredPrompt, @@ -96,7 +103,9 @@ function harness(options: { deliverStructuredPrompt, deliverTerminalPrompt }, - workspaces: { createWorktree }, + workspaces: options.worktreeOnlyFactory + ? { createWorktree } + : { createWorktree, createFolderWorkspace }, ...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {}) }) } @@ -220,6 +229,41 @@ describe('a structured launch that creates its own worktree', () => { ]) }) + it('creates a folder workspace, then opens its structured session there', async () => { + const h = harness({}) + const result = await h.run({ + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } + }) + + expect(h.calls).toEqual(['createFolderWorkspace', 'createSupport', 'createStructuredSession']) + expect(h.createFolderWorkspace).toHaveBeenCalledWith({ create: { projectGroupId: 'group-1' } }) + expect(result.worktreeId).toBe('folder:fw-new') + expect(result.outcome.kind).toBe('structured') + }) + + it('starts a terminal agent in a new folder workspace, not agent-first', async () => { + const h = harness({ settings: null }) + const result = await h.run({ + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } + }) + + expect(h.calls).toEqual(['createFolderWorkspace', 'createTerminalAgent']) + expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'terminal' } }) + }) + + it('refuses a folder create from a factory that cannot make one, before anything is created', async () => { + const h = harness({ worktreeOnlyFactory: true }) + await expect( + h.run({ + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } + }) + ).rejects.toThrow('agent_launch_workspace_factory_required') + expect(h.calls).toEqual([]) + }) + it('strips a stale startupAgent out of a migrated create payload', async () => { const h = harness({}) await h.run({ diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 618f1eb1e49..e153e34b542 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -42,10 +42,7 @@ import { settledAtCreation, settleLaunchPromptDisposal } from './agent-launch-prompt-delivery' -import { - workspaceKindForWorktreeId, - type WorkspaceLaunchKind -} from '../../shared/workspace-launch-kind' +import { workspaceKindForLaunchTarget } from '../../shared/workspace-launch-kind' import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal' import { decideAgentLaunchMode, @@ -86,7 +83,7 @@ export async function executeAgentLaunch( decideAgentLaunchMode({ placement: { agent: intent.agent, - workspaceKind: launchWorkspaceKind(intent.target), + workspaceKind: workspaceKindForLaunchTarget(intent.target), ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}), ...(intent.cwd ? { cwd: intent.cwd } : {}), ...(intent.target.kind === 'existing' && intent.target.workspacePath @@ -251,6 +248,13 @@ async function resolveWorkspace( throw new Error('agent_launch_workspace_factory_required') } execution.onStage?.('worktree_create') + if (intent.target.kind === 'create-folder-workspace') { + if (!workspaces.createFolderWorkspace) { + throw new Error('agent_launch_workspace_factory_required') + } + const created = await workspaces.createFolderWorkspace({ create: intent.target.create }) + return { ...created, startupTerminalHandle: undefined } + } const created = await workspaces.createWorktree({ // A caller migrating from `worktree.create` passes its existing params; a stale `startupAgent` // in there would re-create the agent-first path this executor exists to replace. The launch @@ -383,12 +387,3 @@ function combineLaunchWarnings( 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' -} diff --git a/src/main/agent-launch/agent-launch-opencode-model.test.ts b/src/main/agent-launch/agent-launch-opencode-model.test.ts index 78446330fdd..879f27ab1b5 100644 --- a/src/main/agent-launch/agent-launch-opencode-model.test.ts +++ b/src/main/agent-launch/agent-launch-opencode-model.test.ts @@ -35,7 +35,12 @@ function harness() { }, deliverTerminalPrompt: deliverPrompt }, - workspaces: { createWorktree } + workspaces: { + createWorktree, + createFolderWorkspace: async () => { + throw new Error('folder_create_not_expected') + } + } }) } } diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index c4b913180fa..9166bba4fa8 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -134,4 +134,9 @@ export type AgentLaunchWorkspaceFactory = { /** Reported by the create that built the startup command's typed line. */ promptRodeLaunchCommand?: boolean }> + /** A folder workspace has no startup terminal: the launch starts its agent there afterwards. + * Only `agent.launch` creates folders; callers that only create worktrees omit it. */ + createFolderWorkspace?(args: { + create: Readonly> + }): Promise<{ worktreeId: string; connectionId: string | null }> } diff --git a/src/main/project-groups/folder-workspace-create-refusal.ts b/src/main/project-groups/folder-workspace-create-refusal.ts new file mode 100644 index 00000000000..ae03f55aed5 --- /dev/null +++ b/src/main/project-groups/folder-workspace-create-refusal.ts @@ -0,0 +1,3 @@ +// Marks a folder workspace create refused before anything was stored. Its message is the code the +// create has always thrown, so every caller reads the same refusal it did before. +export class FolderWorkspaceCreateRefusedError extends Error {} diff --git a/src/main/project-groups/folder-workspace-path-status.ts b/src/main/project-groups/folder-workspace-path-status.ts index dbbd34a7034..ef146482f32 100644 --- a/src/main/project-groups/folder-workspace-path-status.ts +++ b/src/main/project-groups/folder-workspace-path-status.ts @@ -212,18 +212,26 @@ export async function getFolderWorkspacePathStatus( ) } -export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void { +/** The code a folder workspace refuses this path with, or null when the path is usable. */ +export function folderWorkspacePathRefusal(status: FolderWorkspacePathStatus): string | null { if (status.exists) { - return + return null } if (status.reason === 'missing') { - throw new Error(`folder_workspace_path_missing:${status.path}`) + return `folder_workspace_path_missing:${status.path}` } if (status.reason === 'not-directory') { - throw new Error(`folder_workspace_path_not_directory:${status.path}`) + return `folder_workspace_path_not_directory:${status.path}` } if (status.reason === 'ambiguous-connection') { - throw new Error(`folder_workspace_connection_ambiguous:${status.path}`) + return `folder_workspace_connection_ambiguous:${status.path}` + } + return `folder_workspace_path_unavailable:${status.path}` +} + +export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void { + const refusal = folderWorkspacePathRefusal(status) + if (refusal) { + throw new Error(refusal) } - throw new Error(`folder_workspace_path_unavailable:${status.path}`) } diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 6be04e9feba..3c8dd53f5c2 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -34,6 +34,7 @@ import { WORKTREE_CREATE_COLLISION_CODE } from '../../../shared/new-workspace/wo import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../shared/agent-launch-pane-already-live' import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../shared/agent-launch-session-already-exists' import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' +import { AGENT_LAUNCH_TARGET_FORBIDDEN_CODE } from '../../../shared/agent-launch-target-forbidden' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -68,6 +69,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE, AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE, AGENT_LAUNCH_TAB_CLOSED_CODE, + AGENT_LAUNCH_TARGET_FORBIDDEN_CODE, 'agent_launch_replay_unsupported', 'runtime_unavailable', 'selector_not_found', diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts index 76afbc09334..50cbb63c1ef 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -1,6 +1,6 @@ /** * A launch from a paired client moves that client's view to the new tab and nobody else's: the view - * intent belongs to the connection that asked. In-process callers and workspace-creating launches + * intent belongs to the connection that asked. In-process callers and worktree-creating launches * keep today's behaviour. */ @@ -45,6 +45,10 @@ const CREATE_LAUNCH = { agent: 'claude', target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } } +const FOLDER_LAUNCH = { + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } +} const CALLER = 'device-1' const REVEAL_WARNING = 'Terminal term_1 is running, but Orca could not make it discoverable. Run `orca terminal focus --terminal term_1` to reveal and focus it.' @@ -150,6 +154,21 @@ describe('a paired client launching into an existing workspace', () => { expect(result.warning).toBeUndefined() }) + // A folder workspace has no setup for a host activation to run, so its create moves only the caller. + it('selects the chat in a folder workspace it creates without activating it on the host', async () => { + const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) + + // A desktop client of a remote server; a phone may not create folder workspaces at all. + await launch(FOLDER_LAUNCH, runtime, { ...CAPABLE_CLIENT, clientKind: 'runtime' }) + + expect(chatActivation()).toBe(false) + expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledExactlyOnceWith( + 'folder:fw-new', + { sessionId: 'sess-1' }, + CALLER + ) + }) + it('selects nothing when the runtime reported no pane for the terminal', async () => { const runtime = selectionRuntime({ settings: {} }) @@ -190,7 +209,7 @@ describe('launches that keep the host-wide behaviour', () => { expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() }) - it('a workspace-creating launch from a paired client keeps the create navigation', async () => { + it('a worktree-creating launch from a paired client keeps the create navigation', async () => { const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) await launch(CREATE_LAUNCH, runtime) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index 04480ac091c..40a2300b5a9 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -2,10 +2,12 @@ * Which view a launch moves: the requesting connection's, never the host's or another client's. * * A paired client (a phone, or a desktop client of a remote server) that launches into an existing - * workspace gets the new tab as its own selection, recorded the way `session.tabs.createTerminal` - * selects the tab it creates for its caller. In-process callers keep today's behaviour, and their - * launch that creates its workspace keeps `worktree.create`'s navigation. A paired client's create - * never activates the host window: its setup and default tabs are provisioned in the background. + * workspace, or into a folder workspace it creates, gets the new tab as its own selection, recorded + * the way `session.tabs.createTerminal` selects the tab it creates for its caller. In-process + * callers keep today's behaviour, and their launch that creates a worktree keeps + * `worktree.create`'s navigation. A paired client's worktree create never activates the host + * window: its setup and default tabs are provisioned in the background. A folder workspace has + * neither, and its create activates nothing, so it is selected for its caller like an existing one. */ import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' @@ -19,7 +21,7 @@ export function agentLaunchCallerNavigationId( target: AgentLaunchTarget, context: Pick ): string | null { - return target.kind === 'existing' && context.caller?.kind === 'paired-device' + return target.kind !== 'create-worktree' && context.caller?.kind === 'paired-device' ? context.caller.deviceId : null } diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts index bf3b4a2ceaa..55e242e7919 100644 --- a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -13,9 +13,12 @@ import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../../shared/agent-launch-session-already-exists' import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' +import { FolderWorkspaceCreateRefusedError } from '../../../project-groups/folder-workspace-create-refusal' /** Long enough for every code this path raises, with room for one a later guard adds. */ const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 +/** A path as long as Linux's PATH_MAX, after its code. */ +const FOLDER_CREATE_REFUSAL_MAX_LENGTH = 4096 + LAUNCH_FAILURE_CODE_MAX_LENGTH /** * This path raises its refusals as the thrown code, the way the method's own guards do — and the @@ -33,10 +36,24 @@ export function agentLaunchFailureCode(error: unknown): string { return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed' } +/** + * A refused folder create names its folder, and the user is shown that path, so a replay carries all + * of it or none: a path cut short names a different folder. Any path a filesystem stat accepts + * fits; past that, only the code before the path is kept. + */ +function folderCreateRefusalCode(error: FolderWorkspaceCreateRefusedError): string { + const { message } = error + const pathStart = message.indexOf(':') + return message.length <= FOLDER_CREATE_REFUSAL_MAX_LENGTH || pathStart === -1 + ? message + : message.slice(0, pathStart) +} + /** * Only a typed refusal raised before anything was created proves the claimed launch had no effects. - * A live reserved pane or an existing reserved session proves it only for an existing workspace; on - * create-worktree the workspace already exists by the time the surface is refused. + * A refused create proves it for any target, since the create is the launch's first effect. A live + * reserved pane or an existing reserved session proves it only for an existing workspace; on a + * create target the workspace already exists by the time the surface is refused. */ export function launchFailureWithoutEffectsCode( error: unknown, @@ -46,6 +63,9 @@ export function launchFailureWithoutEffectsCode( if (error instanceof WorktreeCreateCollisionError) { return WORKTREE_CREATE_COLLISION_CODE } + if (error instanceof FolderWorkspaceCreateRefusedError) { + return folderCreateRefusalCode(error) + } if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') { return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } diff --git a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts new file mode 100644 index 00000000000..a9cdf37db55 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts @@ -0,0 +1,255 @@ +/** + * `agent.launch` with a `create-folder-workspace` target: the host creates the folder workspace, + * then starts the agent in it exactly as it would in a folder workspace that already existed. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_LAUNCH_TARGET_FORBIDDEN_CODE } from '../../../../shared/agent-launch-target-forbidden' +import { FolderWorkspaceCreateRefusedError } from '../../../project-groups/folder-workspace-create-refusal' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import type { RpcContext } from '../core' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + setAgentLaunchRecordStore, + type AgentLaunchRuntimeStub +} from './agent-launch.test-fixture' + +const createStructuredSession = vi.fn(async (_args: Record) => ({ + ok: true as const, + value: { sessionId: 'sess-1' } +})) + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: (args: Record) => + createStructuredSession(args) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +const FOLDER_LAUNCH = { + agent: 'claude', + target: { + kind: 'create-folder-workspace', + create: { projectGroupId: 'group-1', name: 'notes', createdWithAgent: 'codex' } + } +} +// A desktop client of a remote server: paired, but not a phone, so it may create folder workspaces. +const PAIRED_DESKTOP: Partial = { ...CAPABLE_CLIENT, clientKind: 'runtime' } + +function folderRuntime(options: Parameters[0] = {}) { + return Object.assign(runtimeStub(options), { + selectCreatedMobileSessionTabForClient: vi.fn(() => true) + }) +} + +function launch( + params: unknown, + runtime: AgentLaunchRuntimeStub, + context: Partial = PAIRED_DESKTOP +) { + return AGENT_LAUNCH.handler(AGENT_LAUNCH.params.parse(params), rpcContext(runtime, context)) +} + +beforeEach(() => { + createStructuredSession.mockClear() +}) + +describe('agent.launch creating a folder workspace', () => { + it('creates the workspace, then starts a terminal agent in it', async () => { + const runtime = folderRuntime({ settings: {} }) + + const result = await launch(FOLDER_LAUNCH, runtime) + + expect(runtime.createFolderWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectGroupId: 'group-1', + name: 'notes', + // The launch's agent, not the one the create payload named. + createdWithAgent: 'claude', + creatorProvenance: { kind: 'paired-device', deviceId: 'device-1' } + }) + ) + expect(runtime.showTerminalWorkspaceLaunchScope).toHaveBeenCalledWith('id:folder:fw-new') + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + // A paired caller's terminal is not surfaced on the host window. + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:folder:fw-new', + expect.objectContaining({ surfaceOwner: false }) + ) + expect(result).toMatchObject({ + worktreeId: 'folder:fw-new', + outcome: { kind: 'terminal', handle: 'term_1' } + }) + }) + + it('opens a structured chat in the new workspace when that is the default', async () => { + const runtime = folderRuntime() + + const result = await launch(FOLDER_LAUNCH, runtime) + + expect(createStructuredSession).toHaveBeenCalledWith( + expect.objectContaining({ worktree: 'id:folder:fw-new' }) + ) + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'structured' } }) + }) + + it.each([ + ['an SSH', 'ssh-1'], + ['a local', null] + ] as const)( + 'keeps %s folder workspace connection through to the agent it starts', + async (_where, connectionId) => { + const runtime = folderRuntime({ createSupport: { supported: false, reason: 'remote' } }) + runtime.showTerminalWorkspaceLaunchScope.mockImplementationOnce(async (selector: string) => ({ + id: selector.replace(/^id:/, ''), + path: '/srv/notes', + connectionId, + repo: null, + folderWorkspace: null + })) + const create = { ...FOLDER_LAUNCH.target.create, connectionId } + + await launch( + { ...FOLDER_LAUNCH, agent: 'opencode', target: { ...FOLDER_LAUNCH.target, create } }, + runtime + ) + + expect(runtime.createFolderWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ connectionId }) + ) + expect(runtime.createTerminal).toHaveBeenCalledWith('id:folder:fw-new', expect.anything()) + } + ) + + it('rejects a folder create with no project group, the same as folderWorkspace.create', () => { + const parsed = AGENT_LAUNCH.params.safeParse({ + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { name: 'notes' } } + }) + expect(parsed.success).toBe(false) + }) +}) + +describe('a phone asking for a folder workspace', () => { + // `folderWorkspace.create` is not on the mobile allowlist, so the launch may not do it either. + const PHONE_LAUNCH = { + ...FOLDER_LAUNCH, + operationId: `${Date.now()}-000000000000000000000000000000ee` + } + + it.each([ + [ + 'agent.launch', + (runtime: AgentLaunchRuntimeStub) => launch(PHONE_LAUNCH, runtime, CAPABLE_CLIENT) + ], + [ + 'agent.launchReplay', + (runtime: AgentLaunchRuntimeStub) => + AGENT_LAUNCH_REPLAY.handler( + AGENT_LAUNCH_REPLAY.params.parse(PHONE_LAUNCH), + rpcContext(runtime, CAPABLE_CLIENT) + ) + ] + ])('%s refuses it before admitting or creating anything', async (_method, send) => { + const runtime = folderRuntime() + + await expect(send(runtime)).rejects.toThrow(AGENT_LAUNCH_TARGET_FORBIDDEN_CODE) + expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() + expect(runtime.createFolderWorkspace).not.toHaveBeenCalled() + }) +}) + +describe('a replayed folder launch', () => { + // The ledger admits against `Date.now()`, so the id must be dated now. + const OPERATION_ID = `${Date.now()}-000000000000000000000000000000ff` + const PATH_UNAVAILABLE = 'folder_workspace_path_unavailable:/srv/notes' + let directory: string + let store: AgentSessionRecordStore + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-folder-')) + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) + }) + + afterEach(async () => { + setAgentLaunchRecordStore(null) + await rm(directory, { recursive: true, force: true }) + }) + + function replay(runtime: AgentLaunchRuntimeStub) { + const params = AGENT_LAUNCH_REPLAY.params.parse({ ...FOLDER_LAUNCH, operationId: OPERATION_ID }) + return AGENT_LAUNCH_REPLAY.handler(params, rpcContext(runtime, PAIRED_DESKTOP)) + } + + function outcome() { + return store.listOperationRows().find((row) => row.operationId === OPERATION_ID)?.outcome + } + + it('answers a retry from the record without creating a second workspace', async () => { + await replay(folderRuntime({ settings: {} })) + + const retry = folderRuntime({ settings: {} }) + const result = await replay(retry) + + expect(retry.createFolderWorkspace).not.toHaveBeenCalled() + expect(retry.createTerminal).not.toHaveBeenCalled() + expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'terminal' } }) + }) + + it('records a create refused before anything was stored as failed, and replays that', async () => { + const first = folderRuntime({ settings: {} }) + first.createFolderWorkspace.mockRejectedValueOnce( + new FolderWorkspaceCreateRefusedError(PATH_UNAVAILABLE) + ) + + await expect(replay(first)).rejects.toThrow(PATH_UNAVAILABLE) + expect(outcome()).toMatchObject({ status: 'failed', code: PATH_UNAVAILABLE }) + + const retry = folderRuntime({ settings: {} }) + await expect(replay(retry)).rejects.toThrow(PATH_UNAVAILABLE) + expect(retry.createFolderWorkspace).not.toHaveBeenCalled() + }) + + // The path is shown to the user, so a replay names the whole folder or none of it. + it.each([ + ['repeats a long path in full', `/srv/${'nested/'.repeat(30)}notes`, true], + ['drops a path no filesystem accepts', `/srv/${'n'.repeat(5000)}`, false] + ])('%s when it replays a refused create', async (_case, path, keepsPath) => { + const refusal = `folder_workspace_path_missing:${path}` + const first = folderRuntime({ settings: {} }) + first.createFolderWorkspace.mockRejectedValueOnce( + new FolderWorkspaceCreateRefusedError(refusal) + ) + await expect(replay(first)).rejects.toMatchObject({ message: refusal }) + + const replayed = keepsPath ? refusal : 'folder_workspace_path_missing' + await expect(replay(folderRuntime({ settings: {} }))).rejects.toMatchObject({ + message: replayed + }) + }) + + it('stays unknown when the failure came after the workspace was created', async () => { + const first = folderRuntime({ settings: {} }) + // The same words as a refused create, but untyped: only the create's own refusal proves nothing. + first.showTerminalWorkspaceLaunchScope.mockRejectedValueOnce(new Error(PATH_UNAVAILABLE)) + + await expect(replay(first)).rejects.toThrow('agent_session_operation_unknown') + expect(first.createFolderWorkspace).toHaveBeenCalledOnce() + expect(outcome()?.status).not.toBe('failed') + + const retry = folderRuntime({ settings: {} }) + await expect(replay(retry)).rejects.toThrow('agent_session_operation_unknown') + expect(retry.createFolderWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-creation.ts b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-creation.ts new file mode 100644 index 00000000000..560a94c07e3 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-creation.ts @@ -0,0 +1,38 @@ +/** + * Creating the folder workspace an `agent.launch` asks for. A folder workspace is metadata only — + * no git, no setup, no startup terminal — so the launch then starts its agent there exactly as it + * would in a folder workspace that already existed. + */ + +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentLaunchWorkspaceFactory } from '../../../agent-launch/agent-launch-surface-factories' +import type { RpcContext } from '../core' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' +import type { AgentLaunchParams } from './agent-launch-schemas' + +type FolderWorkspaceCreateParams = Extract< + AgentLaunchParams['target'], + { kind: 'create-folder-workspace' } +>['create'] + +export function agentLaunchFolderWorkspaceCreator( + context: RpcContext, + agent: TuiAgent +): AgentLaunchWorkspaceFactory['createFolderWorkspace'] { + return async ({ create }) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`, and the intent copies the parsed payload unchanged. + const params = create as FolderWorkspaceCreateParams + const workspace = await context.runtime.createFolderWorkspace({ + ...params, + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context), + // The launch owns the agent, as it does for a worktree it creates. + createdWithAgent: agent + }) + // The same resolution an `existing` target gets, so the id and connection match it exactly. + const scope = await context.runtime.showTerminalWorkspaceLaunchScope( + `id:${folderWorkspaceKey(workspace.id)}` + ) + return { worktreeId: scope.id, connectionId: scope.connectionId } + } +} diff --git a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts index b172a1b62b8..8fad5220e4c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts +++ b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts @@ -24,8 +24,8 @@ async function agentLaunchTarget( params: AgentLaunchParams, runtime: Pick ): Promise { - if (params.target.kind === 'create-worktree') { - return { kind: 'create-worktree', create: { ...params.target.create } } + if (params.target.kind !== 'existing') { + return { kind: params.target.kind, create: { ...params.target.create } } } const workspace = await runtime.showTerminalWorkspaceLaunchScope(params.target.worktree) return { diff --git a/src/main/runtime/rpc/methods/agent-launch-target-authorization.ts b/src/main/runtime/rpc/methods/agent-launch-target-authorization.ts new file mode 100644 index 00000000000..3f10768a162 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-target-authorization.ts @@ -0,0 +1,29 @@ +/** + * A launch that creates its workspace performs that create, so it needs the create method's own + * authorization. The mobile allowlist is enforced per method name before dispatch, and `agent.launch` + * being on it must not let a phone make a workspace its create method would be refused. Reading the + * same list keeps the two from drifting. + */ + +import { AgentLaunchTargetForbiddenError } from '../../../../shared/agent-launch-target-forbidden' +import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' +import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist' +import type { RpcContext } from '../core' + +const CREATE_METHOD_BY_TARGET_KIND = { + 'create-worktree': 'worktree.create', + 'create-folder-workspace': 'folderWorkspace.create' +} as const satisfies Record, string> + +export function assertAgentLaunchTargetAuthorized( + target: Pick, + context: Pick +): void { + // `clientKind` is the paired device's scope, stamped from its validated token. + if (target.kind === 'existing' || context.clientKind !== 'mobile') { + return + } + if (!MOBILE_RPC_METHOD_ALLOWLIST.has(CREATE_METHOD_BY_TARGET_KIND[target.kind])) { + throw new AgentLaunchTargetForbiddenError() + } +} diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index ff79e44b764..e04ff3ceb13 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -23,6 +23,7 @@ import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-conte import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import type { AgentLaunchParams } from './agent-launch-schemas' +import { agentLaunchFolderWorkspaceCreator } from './agent-launch-folder-workspace-creation' import { agentLaunchMovesHostWindow } from './agent-launch-tab-publication' type WorktreeCreateParams = Extract< @@ -37,6 +38,7 @@ export function agentLaunchWorkspaceFactory( agent: TuiAgent ): AgentLaunchWorkspaceFactory { return { + createFolderWorkspace: agentLaunchFolderWorkspaceCreator(context, agent), createWorktree: async ({ create, startupAgent, 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 348cfed86d9..62b5f735e58 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -11,6 +11,7 @@ import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch import type { RpcContext } from '../core' import { resolveRpcCallerIdentity } from '../rpc-caller-identity' import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import type { AgentLaunchTabPublished, AgentLaunchTabPublishRequest @@ -98,6 +99,7 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { } ), showRepo: vi.fn(async () => ({ id: 'repo-1' })), + createFolderWorkspace: vi.fn(async (_input: Record) => ({ id: 'fw-new' })), createManagedWorktree: vi.fn(async (args: Record) => { reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt) if (args.startupAgent && options.startupTerminalPaneKey) { @@ -138,13 +140,15 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { })), // 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 - })), + showTerminalWorkspaceLaunchScope: vi.fn( + async (selector: string): Promise => ({ + id: selector.replace(/^id:/, ''), + path: '/tmp/wt-7', + connectionId: null, + repo: null, + folderWorkspace: null + }) + ), ensureStructuredAgentSessionHost: vi.fn(async () => {}), openAgentSessionRecordStore: vi.fn(async (): Promise => { if (!launchRecordStore) { diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 0de59791663..442e01a0371 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -54,6 +54,7 @@ import { selectAgentLaunchTabForCaller } from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import { assertAgentLaunchTargetAuthorized } from './agent-launch-target-authorization' import { clientRendersStructuredAgent } from './structured-agent-session-policy' import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution' import { @@ -311,6 +312,7 @@ export const AGENT_LAUNCH_METHODS = [ if (!supportsAgentLaunch(context)) { throw new Error('agent_launch_replay_unsupported') } + assertAgentLaunchTargetAuthorized(params.target, context) try { return await runReplaySafeAgentLaunch(params, context) } catch (error) { @@ -341,6 +343,7 @@ export const AGENT_LAUNCH_METHODS = [ if (!supportsAgentLaunch(context)) { throw new Error('agent_launch_unsupported') } + assertAgentLaunchTargetAuthorized(params.target, context) if (!params.operationId) { return runLegacyAgentLaunch(params, context) } diff --git a/src/main/runtime/runtime-project-group-controller-folder-create.test.ts b/src/main/runtime/runtime-project-group-controller-folder-create.test.ts new file mode 100644 index 00000000000..70022e8c625 --- /dev/null +++ b/src/main/runtime/runtime-project-group-controller-folder-create.test.ts @@ -0,0 +1,75 @@ +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { RuntimeProjectGroupController } from './runtime-project-group-controller' +import type { RuntimeStore } from './runtime-store-contract' +import { FolderWorkspaceCreateRefusedError } from '../project-groups/folder-workspace-create-refusal' + +const MISSING_PATH = join(tmpdir(), 'orca-folder-create-missing-9c1f') + +// The store members a folder create never reaches. +const unused = (): never => { + throw new Error('unused') +} + +function createController() { + const createFolderWorkspace = vi.fn>() + const notifyReposChanged = vi.fn() + const store: RuntimeStore = { + getProjectGroups: () => [ + { + id: 'group-1', + name: 'Notes', + parentPath: MISSING_PATH, + connectionId: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + getRepos: () => [], + createFolderWorkspace, + getRepo: unused, + addRepo: unused, + updateRepo: unused, + getAllWorktreeMeta: unused, + getWorktreeMeta: unused, + setWorktreeMeta: unused, + removeWorktreeMeta: unused, + getGitHubCache: unused, + getSettings: unused + } + const controller = new RuntimeProjectGroupController({ + getStore: () => store, + resolveRepo: async () => { + throw new Error('unused') + }, + notifyReposChanged, + resolveFolderConnectionId: () => null, + teardownFolderWorkspacePtys: async () => undefined, + cleanupRemovedFolderWorkspaceState: () => undefined + }) + return { controller, createFolderWorkspace, notifyReposChanged } +} + +// A launch reads these as "nothing was created", so each must come before the store write and keep +// the code `folderWorkspace.create` has always answered with. +describe('RuntimeProjectGroupController.createFolderWorkspace refusals', () => { + it.each([ + ['a group that is gone', 'group-2', 'folder_workspace_project_group_not_found'], + ['a folder that is missing', 'group-1', `folder_workspace_path_missing:${MISSING_PATH}`] + ])('refuses %s as a typed refusal without storing anything', async (_case, groupId, code) => { + const deps = createController() + + const refused = deps.controller.createFolderWorkspace({ projectGroupId: groupId }) + + await expect(refused).rejects.toBeInstanceOf(FolderWorkspaceCreateRefusedError) + await expect(refused).rejects.toMatchObject({ message: code }) + expect(deps.createFolderWorkspace).not.toHaveBeenCalled() + expect(deps.notifyReposChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index f866ccfde86..0e5ea78bb72 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -8,9 +8,11 @@ import type { } from '../../shared/folder-workspace-path-status' import { assertFolderWorkspacePathUsable, + folderWorkspacePathRefusal, getFolderWorkspacePathStatus, getFolderWorkspacePathStatusForPath } from '../project-groups/folder-workspace-path-status' +import { FolderWorkspaceCreateRefusedError } from '../project-groups/folder-workspace-create-refusal' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { RuntimeStore } from './runtime-store-contract' import { folderWorkspaceKey } from '../../shared/workspace-scope' @@ -134,9 +136,10 @@ export class RuntimeProjectGroupController { createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }): Promise { + // Every refusal below comes before the store write, so it proves nothing was created. const store = this.deps.getStore() if (!store?.createFolderWorkspace) { - throw new Error('runtime_unavailable') + throw new FolderWorkspaceCreateRefusedError('runtime_unavailable') } const projectGroups = store.getProjectGroups?.() ?? [] const group = projectGroups.find((entry) => entry.id === input.projectGroupId) @@ -145,7 +148,7 @@ export class RuntimeProjectGroupController { ? input.folderPath : group?.parentPath if (!group || !folderPath) { - throw new Error('folder_workspace_project_group_not_found') + throw new FolderWorkspaceCreateRefusedError('folder_workspace_project_group_not_found') } const status = await getFolderWorkspacePathStatusForPath( { @@ -157,7 +160,10 @@ export class RuntimeProjectGroupController { }, { getSshFilesystemProvider } ) - assertFolderWorkspacePathUsable(status) + const pathRefusal = folderWorkspacePathRefusal(status) + if (pathRefusal) { + throw new FolderWorkspaceCreateRefusedError(pathRefusal) + } const workspace = store.createFolderWorkspace({ ...input, creatorProvenance: input.creatorProvenance ?? { kind: 'host' } diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index b16287660f7..0083293cdbd 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -52,6 +52,8 @@ export type AgentLaunchTarget = /** A worktree this launch creates. `create` is the `worktree.create` request minus its agent * fields — the launch owns those, so a caller cannot set a startup agent behind the router. */ | { kind: 'create-worktree'; create: Readonly> } + /** A folder workspace this launch creates. `create` is the `folderWorkspace.create` request. */ + | { kind: 'create-folder-workspace'; create: Readonly> } /** An existing terminal the caller wants reused rather than a fresh surface. Always resolves to a * terminal agent: a running PTY keeps its execution transport. */ diff --git a/src/shared/agent-launch-operation.ts b/src/shared/agent-launch-operation.ts index fcd4b191172..920841d568c 100644 --- a/src/shared/agent-launch-operation.ts +++ b/src/shared/agent-launch-operation.ts @@ -31,6 +31,7 @@ export type AgentLaunchFingerprintInput = { target: | { kind: 'existing'; worktree: string } | { kind: 'create-worktree'; create: Readonly> } + | { kind: 'create-folder-workspace'; create: Readonly> } prompt?: { text: string; delivery: string } sessionOptions?: Readonly> reuseTerminal?: { handle: string } diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 060766828e2..b751c13b9c4 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -38,6 +38,13 @@ export const AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY = // may publish the launch's tab before it admits the launch. export const AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY = 'agent.launch.placement.v1' as const +// Host-advertised: accepts `target.kind: 'create-folder-workspace'`. An older host refuses the +// unknown target kind, so a client offers the new-folder-workspace launch only on this. It is not +// enough on its own: the target also needs `folderWorkspace.create` authorization, which a +// mobile-scope device lacks, so a phone folder launch needs its own gate. +export const AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY = + 'agent.launch.create-folder-workspace.v1' as const + // Client-advertised only: the client reads a listed launch tab with no terminal yet as "not // started", never as proof its agent runs. The host publishes a paired caller's tab before the // spawn only for a client that says so. @@ -54,5 +61,6 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY, AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, - AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY + AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY, + AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/agent-launch-target-forbidden.ts b/src/shared/agent-launch-target-forbidden.ts new file mode 100644 index 00000000000..b433c5b5b5a --- /dev/null +++ b/src/shared/agent-launch-target-forbidden.ts @@ -0,0 +1,10 @@ +export const AGENT_LAUNCH_TARGET_FORBIDDEN_CODE = 'agent_launch_target_forbidden' as const + +// The caller may not call the create method this target stands for; refused before admission. +// Not `forbidden`: the replay classifier reads that as an older host and falls back to its old path. +export class AgentLaunchTargetForbiddenError extends Error { + constructor() { + super(AGENT_LAUNCH_TARGET_FORBIDDEN_CODE) + this.name = 'AgentLaunchTargetForbiddenError' + } +} diff --git a/src/shared/rpc-contract/agent-launch-params.test.ts b/src/shared/rpc-contract/agent-launch-params.test.ts index f425920d498..cd38efeb276 100644 --- a/src/shared/rpc-contract/agent-launch-params.test.ts +++ b/src/shared/rpc-contract/agent-launch-params.test.ts @@ -7,6 +7,7 @@ */ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { AgentLaunch, AgentLaunchFields } from './agent-launch-params' const BASE = { agent: 'claude', target: { kind: 'existing', worktree: 'wt-1' } } @@ -76,6 +77,19 @@ describe('agent.launch params', () => { }) }) + it('has a host from before folder creates refuse that target rather than misread it', () => { + const [existing, createWorktree] = AgentLaunchFields.shape.target.options + const olderHost = AgentLaunchFields.extend({ + target: z.discriminatedUnion('kind', [existing, createWorktree]) + }) + expect( + olderHost.safeParse({ + ...BASE, + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } + }).success + ).toBe(false) + }) + it('lets a host from before placement drop it rather than refuse the launch', () => { const olderHost = AgentLaunchFields.omit({ placement: true, presentation: true }) const parsed = olderHost.safeParse({ diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index d95f113f4c9..641f2eb4eb1 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -18,6 +18,7 @@ import { isValidHostTerminalTabId } from '../terminal-tab-id' import { isTuiAgent } from '../tui-agent-config' import type { TuiAgent } from '../tui-agent' import { WorktreeCreate } from './worktree-create-params' +import { FolderWorkspaceCreate } from './folder-workspace-params' import { LaunchSourceParam } from './launch-source-param' import { TerminalTabIdParam } from './agent-session-params' import { SessionId } from './structured-agent-session-params' @@ -69,6 +70,11 @@ export const AgentLaunchFields = z.object({ /** The `worktree.create` request verbatim, so a caller migrating to this method keeps its * existing payload; the agent fields in it are stripped rather than honoured. */ create: WorktreeCreate + }), + z.object({ + kind: z.literal('create-folder-workspace'), + /** The `folderWorkspace.create` request verbatim; its `createdWithAgent` is the launch's own. */ + create: FolderWorkspaceCreate }) ]), prompt: z diff --git a/src/shared/workspace-launch-kind.ts b/src/shared/workspace-launch-kind.ts index a81621bc28b..d4b6a999663 100644 --- a/src/shared/workspace-launch-kind.ts +++ b/src/shared/workspace-launch-kind.ts @@ -10,6 +10,7 @@ * never take the answer from a caller, so it derives it here from the id it resolved itself. */ +import type { AgentLaunchTarget } from './agent-launch-intent' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { parseWorkspaceKey } from './workspace-scope' @@ -21,3 +22,15 @@ export function workspaceKindForWorktreeId(worktreeId: string): WorkspaceLaunchK } return parseWorkspaceKey(worktreeId)?.type === 'folder' ? 'folder' : 'git-worktree' } + +/** + * Read from the id rather than carried alongside it, so the kind cannot disagree with the workspace + * it describes. An existing target's `worktree` is already resolved to an id, never a caller's + * selector; a create's kind is what it creates. + */ +export function workspaceKindForLaunchTarget(target: AgentLaunchTarget): WorkspaceLaunchKind { + if (target.kind === 'existing') { + return workspaceKindForWorktreeId(target.worktree) + } + return target.kind === 'create-folder-workspace' ? 'folder' : 'git-worktree' +}