diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index c0451a4f6b2..a95e71a30ef 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -463,6 +463,29 @@ describe('caller-supplied launch inputs', () => { expect(h.createStructuredSession).not.toHaveBeenCalled() }) + it('still opens a structured session when the cwd names the workspace root', async () => { + // The root the RPC layer resolved rides on the target, so a cwd spelled as the root is not a + // custom directory and does not decide the route. + const h = harness({}) + const result = await h.run({ + agent: 'claude', + target: { kind: 'existing', worktree: 'wt-7', workspacePath: '/repo' }, + cwd: '/repo/' + }) + expect(result.outcome.kind).toBe('structured') + expect(result.receipt).toMatchObject({ mode: 'structured' }) + }) + + it('still downgrades for a subdirectory of a resolved root', async () => { + const h = harness({}) + const result = await h.run({ + agent: 'claude', + target: { kind: 'existing', worktree: 'wt-7', workspacePath: '/repo' }, + cwd: '/repo/packages/api' + }) + expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'tui_launch_command' }) + }) + it('still opens a structured session when the cwd is only whitespace', async () => { const h = harness({}) const result = await h.run({ agent: 'claude', target: EXISTING, cwd: ' ' }) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index be85a4168ba..090a65f7650 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -83,7 +83,10 @@ export async function executeAgentLaunch( agent: intent.agent, workspaceKind: launchWorkspaceKind(intent.target), ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}), - ...(intent.cwd ? { cwd: intent.cwd } : {}) + ...(intent.cwd ? { cwd: intent.cwd } : {}), + ...(intent.target.kind === 'existing' && intent.target.workspacePath + ? { workspacePath: intent.target.workspacePath } + : {}) }, settings, vocabulary diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 99711cc1293..b3d954944b5 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -17,6 +17,7 @@ * records; every other surface says "chat session" / "terminal agent". */ +import { requestsCwdOutsideWorkspaceRoot } from '../../shared/terminal-startup-cwd' import type { AgentLaunchMode, AgentLaunchModeReason, @@ -72,10 +73,14 @@ export type AgentLaunchModePlacement = { * 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 - /** A start directory other than the workspace root. It belongs here, unlike `model` or `effort`, - * because a structured session has no way to apply one — it runs in its workspace — so honouring - * it and honouring the chat preference are mutually exclusive rather than merely awkward. */ + /** A requested start directory. It belongs here, unlike `model` or `effort`, because a structured + * session has no way to apply one — it runs in its workspace — so honouring it and honouring the + * chat preference are mutually exclusive rather than merely awkward. Read against + * `workspacePath`: a cwd that names the root asks for nothing and decides nothing. */ cwd?: string + /** The root of the workspace the launch lands in, when the host has resolved it. Without it a + * requested `cwd` cannot be proven to name the root and is read as custom. */ + workspacePath?: string } const DOWNGRADE_DETAIL: Record, string> = { @@ -145,10 +150,11 @@ export function decideAgentLaunchMode(args: { // the create-support probe reads the resolved workspace rather than guessing from a // client-side project runtime. ...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}), - // Mirrors the renderer's own route input (`agent-launch-route-input.ts`), which has always - // treated a requested cwd as terminal-only; the host simply had no way to be told about one. + // Mirrors the renderer's own route input (`agent-launch-route-input.ts`): a cwd is terminal-only + // when it names somewhere other than the workspace root, by the same shared rule. requiresTuiLaunchCommand: - Boolean(placement.cwd?.trim()) || hasExplicitTuiLaunchCommand(settings, agent) + requestsCwdOutsideWorkspaceRoot(placement.workspacePath, placement.cwd) || + hasExplicitTuiLaunchCommand(settings, agent) }) if (!support.supported) { return downgraded(BLOCKER_REASON[support.blocker], vocabulary) diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index f592c4fe1b7..f44ddb4978b 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -579,6 +579,15 @@ describe('launch inputs that cross the wire', () => { expect(terminalOptions(runtime)).not.toHaveProperty('telemetry') }) + it('keeps a structured preference when the cwd names the workspace root', async () => { + // The scope the handler resolves for the target carries the root the fixture reports. + const runtime = runtimeStub({}) + const result = await launch({ ...EXISTING_LAUNCH, cwd: '/tmp/wt-7/' }, runtime) + + expect(result.outcome.kind).toBe('structured') + expect(result.receipt).toMatchObject({ mode: 'structured' }) + }) + it('routes a structured preference to a terminal when the launch names a cwd', async () => { const runtime = runtimeStub({}) const result = await launch({ ...EXISTING_LAUNCH, cwd: '/repo/packages/api' }, runtime) @@ -587,4 +596,18 @@ describe('launch inputs that cross the wire', () => { expect(result.receipt).toMatchObject({ preferred: 'structured', reason: 'tui_launch_command' }) expect(createStructuredSession).not.toHaveBeenCalled() }) + + it('ignores a caller-supplied root, so a subdirectory cannot claim to be one', async () => { + const runtime = runtimeStub({}) + const result = await launch( + { + ...EXISTING_LAUNCH, + target: { ...EXISTING_LAUNCH.target, workspacePath: '/repo/packages/api' }, + cwd: '/repo/packages/api' + }, + runtime + ) + + expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'tui_launch_command' }) + }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index eed47a746af..eee9f2a48c1 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -83,7 +83,7 @@ async function agentLaunchTarget( return { kind: 'create-worktree', create: { ...params.target.create } } } const workspace = await runtime.showTerminalWorkspaceLaunchScope(params.target.worktree) - return { kind: 'existing', worktree: workspace.id } + return { kind: 'existing', worktree: workspace.id, workspacePath: workspace.path } } async function agentLaunchIntent( diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 2ea4c568243..6eb91bbc72e 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -33,6 +33,7 @@ vi.mock('@/lib/structured-agent-launch-settlement', () => ({ settleStructuredAgentLaunch: vi.fn() })) +import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { buildAgentLaunchRouteInput, workspaceKindForWorktreeId, @@ -74,17 +75,19 @@ function store(settings: Record = STRUCTURED_SETTINGS): AgentLa return { settings } as unknown as AgentLaunchRouteStore } +function stageLocalStructuredHost(): void { + vi.clearAllMocks() + mocks.getExecutionHostIdForWorktree.mockReturnValue('local') + mocks.getConnectionIdFromState.mockReturnValue(null) + mocks.getLocalProjectExecutionRuntimeContext.mockReturnValue(undefined) + mocks.getLocalRepoProjectExecutionRuntimeContext.mockReturnValue(undefined) + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReturnValue([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) +} + describe('buildAgentLaunchRouteInput', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.getExecutionHostIdForWorktree.mockReturnValue('local') - mocks.getConnectionIdFromState.mockReturnValue(null) - mocks.getLocalProjectExecutionRuntimeContext.mockReturnValue(undefined) - mocks.getLocalRepoProjectExecutionRuntimeContext.mockReturnValue(undefined) - mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReturnValue([ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ]) - }) + beforeEach(stageLocalStructuredHost) it('gathers the full input set for an existing local git worktree', () => { mocks.getLocalProjectExecutionRuntimeContext.mockReturnValue(WSL_RUNTIME) @@ -343,3 +346,62 @@ describe('workspaceKindForWorktreeId', () => { expect(workspaceKindForWorktreeId(worktreeId)).toBe(kind) }) }) + +describe('a cwd that names the workspace root', () => { + // "Continue in New Session…" always names a cwd; at the root it must not force a terminal. + beforeEach(stageLocalStructuredHost) + + const withRoot = (): AgentLaunchRouteStore => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route store is the app state narrowed to the slices the input reads; only those are staged. + ({ + settings: STRUCTURED_SETTINGS, + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', path: '/repo/app' }] }, + folderWorkspaces: [{ id: 'folder-1', folderPath: '/srv/notes' }] + }) as unknown as AgentLaunchRouteStore + + it.each(['/repo/app', '/repo/app/', '.'])( + 'routes structured under the chat default for cwd %s', + (cwd) => { + const args = { + agent: 'codex' as const, + workspace: { kind: 'git-worktree' as const, worktreeId: 'wt-1' }, + tuiCustomization: { cwd } + } + expect(buildAgentLaunchRouteInput(withRoot(), args).requiresTuiLaunchCommand).toBe(false) + expect(routeFor(withRoot(), args)).toBe('structured-native-chat') + } + ) + + it('still requires a terminal for a subdirectory, which a structured session cannot start in', () => { + const args = { + agent: 'codex' as const, + workspace: { kind: 'git-worktree' as const, worktreeId: 'wt-1' }, + tuiCustomization: { cwd: '/repo/app/packages/web' } + } + expect(buildAgentLaunchRouteInput(withRoot(), args).requiresTuiLaunchCommand).toBe(true) + expect(routeFor(withRoot(), args)).not.toBe('structured-native-chat') + }) + + it('reads a folder workspace root the same way', () => { + const workspaceId = folderWorkspaceKey('folder-1') + const at = (cwd: string) => + buildAgentLaunchRouteInput(withRoot(), { + agent: 'codex', + workspace: { kind: 'folder', worktreeId: workspaceId }, + tuiCustomization: { cwd } + }).requiresTuiLaunchCommand + expect(at('/srv/notes/')).toBe(false) + expect(at('/srv/notes/drafts')).toBe(true) + }) + + it('keeps a cwd custom when the store holds no root for the workspace', () => { + // The existing "requires a terminal for a cwd" case above pins this against an empty store. + expect( + buildAgentLaunchRouteInput(store(), { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + tuiCustomization: { cwd: '/repo/app' } + }).requiresTuiLaunchCommand + ).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index 2f0c1e083f4..9e46494fa0c 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -3,6 +3,9 @@ import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import type { AppState } from '@/store/types' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { requestsCwdOutsideWorkspaceRootForWorkspace } from '../../../shared/terminal-startup-cwd' import type { TuiAgent } from '../../../shared/tui-agent' import { workspaceKindForWorktreeId } from '../../../shared/workspace-launch-kind' import { @@ -40,11 +43,15 @@ export type ProspectiveWorkspace = { runtimeEnvironmentId?: string | null } -export type AgentLaunchRouteStore = Parameters[0] & +export type AgentLaunchRouteStore = { + settings?: AgentLaunchRoutingInput['settings'] + /** Where each workspace's root is, so a cwd naming it is not read as a custom directory. First + * in the intersection so these lookups resolve to the full records. */ + worktreesByRepo?: AppState['worktreesByRepo'] + folderWorkspaces?: AppState['folderWorkspaces'] +} & Parameters[0] & Parameters[0] & - Parameters[0] & { - settings?: AgentLaunchRoutingInput['settings'] - } + Parameters[0] export type AgentLaunchRouteArgs = { agent: TuiAgent @@ -123,8 +130,18 @@ export function buildAgentLaunchRouteInput( workspace, executionHostId ), + // A cwd decides the route only when it names somewhere other than the workspace root; the + // host applies the same rule (`agent-launch-mode.ts`), so the two never disagree on it. requiresTuiLaunchCommand: - Boolean(tuiCustomization?.cwd?.trim()) || hasExplicitTuiLaunchCommand(store.settings, agent), + requestsCwdOutsideWorkspaceRootForWorkspace({ + workspaceId: workspace.worktreeId, + requestedCwd: tuiCustomization?.cwd, + workspacePath: workspace.worktreeId + ? findWorktreeById(store.worktreesByRepo ?? {}, workspace.worktreeId)?.path + : undefined, + resolveFolderWorkspacePath: (folderWorkspaceId) => + store.folderWorkspaces?.find((entry) => entry.id === folderWorkspaceId)?.folderPath + }) || hasExplicitTuiLaunchCommand(store.settings, agent), initialSessionOptions: args.initialSessionOptions } } diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 87b5fc9ef68..7e68340fdba 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -39,7 +39,13 @@ export type AgentLaunchPrompt = { */ export type AgentLaunchTarget = /** A workspace that already exists, addressed by any selector the runtime resolves. */ - | { kind: 'existing'; worktree: string } + | { + kind: 'existing' + worktree: string + /** The workspace root the host resolved for that selector. Host-set, never accepted from a + * caller: it decides whether a requested `cwd` names the root or somewhere else. */ + workspacePath?: string + } /** 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> } diff --git a/src/shared/terminal-startup-cwd.test.ts b/src/shared/terminal-startup-cwd.test.ts index 879465e4435..9e8f477c4a5 100644 --- a/src/shared/terminal-startup-cwd.test.ts +++ b/src/shared/terminal-startup-cwd.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { + requestsCwdOutsideWorkspaceRoot, + requestsCwdOutsideWorkspaceRootForWorkspace, resolveTerminalStartupCwd, resolveTerminalStartupCwdForWorkspace } from './terminal-startup-cwd' @@ -217,3 +219,106 @@ describe('resolveTerminalStartupCwd', () => { ).toBe('/repo/other') }) }) + +describe('requestsCwdOutsideWorkspaceRoot', () => { + it.each([ + ['the root itself', '/repo/app', '/repo/app'], + ['the root with a trailing slash', '/repo/app', '/repo/app/'], + ['the root as a relative dot', '/repo/app', '.'], + ['a relative path that lands back on the root', '/repo/app', 'packages/..'], + ['a Windows root spelled with forward slashes', 'C:\\repo\\app', 'C:/repo/app/'], + ['a Windows root in another case', 'C:\\Repo\\App', 'c:\\repo\\app'], + [ + 'the other WSL UNC alias of the root', + '\\\\wsl$\\Ubuntu\\home\\ada\\app', + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\app\\' + ], + ['an SSH-side root spelled with a trailing slash', '/home/ada/app', '/home/ada/app//'], + [ + 'a WSL root spelled as its Linux path', + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\app', + '/home/ada/app/' + ] + ])('reads %s as no custom cwd', (_name, root, cwd) => { + expect(requestsCwdOutsideWorkspaceRoot(root, cwd)).toBe(false) + }) + + it.each([ + ['a subdirectory', '/repo/app', '/repo/app/packages/web'], + ['a relative subdirectory', '/repo/app', 'packages/web'], + ['a directory outside the root', '/repo/app', '/repo/other'], + [ + 'a POSIX root in another case, which is a different directory', + '/home/ada/app', + '/home/Ada/app' + ], + [ + 'a Linux subdirectory of a WSL root', + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\app', + '/home/ada/app/src' + ] + ])('reads %s as a custom cwd', (_name, root, cwd) => { + expect(requestsCwdOutsideWorkspaceRoot(root, cwd)).toBe(true) + }) + + it('reads no cwd, or only whitespace, as no custom cwd', () => { + expect(requestsCwdOutsideWorkspaceRoot('/repo/app', undefined)).toBe(false) + expect(requestsCwdOutsideWorkspaceRoot('/repo/app', ' ')).toBe(false) + }) + + it('reads a cwd against an unknown root as custom, because it cannot prove otherwise', () => { + expect(requestsCwdOutsideWorkspaceRoot(undefined, '/repo/app')).toBe(true) + }) +}) + +describe('requestsCwdOutsideWorkspaceRootForWorkspace', () => { + it('takes the root from the caller when it has one, else from the worktree id', () => { + expect( + requestsCwdOutsideWorkspaceRootForWorkspace({ + workspaceId: 'wt-1', + workspacePath: '/repo/app', + requestedCwd: '/repo/app/' + }) + ).toBe(false) + expect( + requestsCwdOutsideWorkspaceRootForWorkspace({ + workspaceId: 'repo-1::/repo/app', + requestedCwd: '/repo/app' + }) + ).toBe(false) + expect( + requestsCwdOutsideWorkspaceRootForWorkspace({ + workspaceId: 'repo-1::/repo/app', + requestedCwd: '/repo/app/packages/web' + }) + ).toBe(true) + }) + + it('resolves a folder workspace root through the caller', () => { + const workspaceId = folderWorkspaceKey('folder-1') + const resolveFolderWorkspacePath = (id: string) => (id === 'folder-1' ? '/srv/notes' : null) + expect( + requestsCwdOutsideWorkspaceRootForWorkspace({ + workspaceId, + requestedCwd: '/srv/notes/', + resolveFolderWorkspacePath + }) + ).toBe(false) + expect( + requestsCwdOutsideWorkspaceRootForWorkspace({ + workspaceId, + requestedCwd: '/srv/notes/drafts', + resolveFolderWorkspacePath + }) + ).toBe(true) + }) + + it('reads any cwd in the floating workspace as custom, since it has no root', () => { + expect( + requestsCwdOutsideWorkspaceRootForWorkspace({ + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + requestedCwd: '/tmp' + }) + ).toBe(true) + }) +}) diff --git a/src/shared/terminal-startup-cwd.ts b/src/shared/terminal-startup-cwd.ts index 8344abf07cc..828e8e8ef65 100644 --- a/src/shared/terminal-startup-cwd.ts +++ b/src/shared/terminal-startup-cwd.ts @@ -1,6 +1,11 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' -import { resolveRuntimePath } from './cross-platform-path' +import { + isWslUncPathForCallerLinuxPath, + normalizeRuntimePathForComparison, + resolveRuntimePath +} from './cross-platform-path' import { parseWorkspaceKey } from './workspace-scope' +import { parseWslUncPath } from './wsl-paths' import { splitWorktreeIdForFilesystem } from './worktree/id' export type TerminalStartupCwdMissingDirFallback = { @@ -80,3 +85,58 @@ function resolveTerminalWorkspacePath( const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId return splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? null } + +/** + * Whether a requested cwd would start the agent somewhere other than the workspace root. + * + * Only such a cwd is a reason to route a launch to a terminal: a structured session runs in its + * workspace and cannot honour any other directory. A cwd that names the root, however it is + * spelled — trailing slash, `.`, a relative path back to it, Windows separators or case, either WSL + * UNC alias or the distro's own Linux path — asks for nothing a structured session cannot give. An + * unknown root is read as a custom cwd: the launch cannot prove the request names the root, so it + * keeps the surface that can honour it. + */ +export function requestsCwdOutsideWorkspaceRoot( + workspacePath: string | null | undefined, + requestedCwd: string | null | undefined +): boolean { + const trimmedCwd = requestedCwd?.trim() + if (!trimmedCwd) { + return false + } + if (!workspacePath) { + return true + } + const resolved = resolveTerminalStartupCwd(workspacePath, trimmedCwd) ?? trimmedCwd + if ( + normalizeRuntimePathForComparison(resolved) === normalizeRuntimePathForComparison(workspacePath) + ) { + return false + } + // Why: an agent inside a WSL workspace records its cwd as a Linux path, which the workspace's + // own distro reads as the root. + const wslRoot = parseWslUncPath(workspacePath) + return !(wslRoot && isWslUncPathForCallerLinuxPath(workspacePath, trimmedCwd, wslRoot.distro)) +} + +/** `requestsCwdOutsideWorkspaceRoot` for a workspace named by id, with the root taken from the + * caller's own record of it when one exists, else derived the way terminal creation derives it. + * The floating workspace has no root, so any cwd there is custom. */ +export function requestsCwdOutsideWorkspaceRootForWorkspace(args: { + workspaceId?: string + requestedCwd?: string | null + /** The root as the caller already knows it; consulted before the id is parsed for one. */ + workspacePath?: string | null + resolveFolderWorkspacePath?: (folderWorkspaceId: string) => string | null | undefined +}): boolean { + if (!args.requestedCwd?.trim()) { + return false + } + if (args.workspaceId === FLOATING_TERMINAL_WORKTREE_ID) { + return true + } + const workspacePath = + args.workspacePath ?? + resolveTerminalWorkspacePath(args.workspaceId, args.resolveFolderWorkspacePath) + return requestsCwdOutsideWorkspaceRoot(workspacePath, args.requestedCwd) +}