fix(agent-launch): a cwd at the workspace root no longer forces a terminal (#22729)

"Continue in New Session…" always names a cwd, and both the renderer route
input and the host launch-mode decision read any cwd as a custom start
directory, so the continuation opened a terminal agent even when chat was
the user's default. Both now share one rule: only a cwd outside the
workspace root (after normalising slashes, Windows case, WSL aliases and
the distro's Linux spelling) requires a terminal. A subdirectory still
does, because a structured session cannot start there.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Brennan Benson
2026-09-24 16:57:38 -07:00
committed by GitHub
co-authored by Claude
parent 5e6fcca0b3
commit 1069bb053f
10 changed files with 330 additions and 25 deletions
@@ -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: ' ' })
@@ -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
+12 -6
View File
@@ -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<Exclude<AgentLaunchModeReason, 'user_default'>, 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)
@@ -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' })
})
})
+1 -1
View File
@@ -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(
@@ -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<string, unknown> = 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)
})
})
@@ -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<typeof getExecutionHostIdForWorktree>[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<typeof getExecutionHostIdForWorktree>[0] &
Parameters<typeof getLocalProjectExecutionRuntimeContext>[0] &
Parameters<typeof getConnectionIdFromState>[0] & {
settings?: AgentLaunchRoutingInput['settings']
}
Parameters<typeof getConnectionIdFromState>[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
}
}
+7 -1
View File
@@ -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<Record<string, unknown>> }
+105
View File
@@ -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)
})
})
+61 -1
View File
@@ -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)
}