fix(terminal): quote agent resume for the tab's real Windows shell (cmd.exe) (#12476)
* fix(terminal): quote agent resume commands for the tab's real Windows shell
Cold restore and sleeping-agent resume built their launch line without the
host shell family, so win32 fell back to PowerShell argv quoting. On cmd.exe
tabs those quotes arrived literally and agent CLIs rejected the resume argv
and permission flags after a reboot ("unexpected argument ''<uuid>'' found").
Both call sites now share resolveAgentResumeLaunchTarget, which resolves the
launch platform and the live shell family together via
resolveLocalWindowsAgentStartupShell, honoring a per-tab shell override for
cold restore and leaving SSH / remote-runtime / WSL workspaces on their own
default quoting.
Fixes #12320
Co-authored-by: Orca <help@stably.ai>
* test(shared): cover cmd.exe resume quoting at the plan layer
Adopted from #12321 by @CountClaw.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
094d6821ef
commit
940f2ff1e4
|
|
@ -8361,6 +8361,124 @@ describe('connectPanePty', () => {
|
|||
}
|
||||
})
|
||||
|
||||
// Regression (#12320): a cold restore after reboot typed PowerShell single quotes into
|
||||
// cmd.exe tabs, so the agent CLI rejected the resume argv ("unexpected argument").
|
||||
async function runWindowsColdRestoreResume(args: {
|
||||
terminalWindowsShell: string
|
||||
tabShellOverride?: string
|
||||
}): Promise<string | undefined> {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
)
|
||||
const pendingTimeouts: (() => void)[] = []
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
globalThis.setTimeout = vi.fn((fn: () => void) => {
|
||||
pendingTimeouts.push(fn)
|
||||
return 999 as unknown as ReturnType<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout
|
||||
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const paneKey = makePaneKey('tab-1', LEAF_2)
|
||||
let activePtyId: string | null = 'restored-session'
|
||||
const transport = createMockTransport('restored-session')
|
||||
transport.getPtyId.mockImplementation(() => activePtyId)
|
||||
transport.disconnect.mockImplementation(() => {
|
||||
activePtyId = null
|
||||
})
|
||||
transport.connect.mockImplementation(async (opts: { sessionId?: string }) => {
|
||||
if (opts.sessionId) {
|
||||
activePtyId = opts.sessionId
|
||||
return {
|
||||
id: opts.sessionId,
|
||||
isReattach: true,
|
||||
snapshot: undefined,
|
||||
replay: undefined,
|
||||
coldRestore: undefined
|
||||
}
|
||||
}
|
||||
activePtyId = 'fresh-resume-pty'
|
||||
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
|
||||
| ((ptyId: string) => void)
|
||||
| undefined
|
||||
onPtySpawn?.('fresh-resume-pty')
|
||||
return 'fresh-resume-pty'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'restored-session',
|
||||
...(args.tabShellOverride ? { shellOverride: args.tabShellOverride } : {})
|
||||
}
|
||||
]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings,
|
||||
agentCmdOverrides: {},
|
||||
terminalWindowsShell: args.terminalWindowsShell
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'done',
|
||||
origin: 'worktree-sleep',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
} as StoreState
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_2,
|
||||
restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' }
|
||||
})
|
||||
vi.mocked(window.api.pty.declarePendingPaneSerializer)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(2)
|
||||
|
||||
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
for (const fn of pendingTimeouts) {
|
||||
fn()
|
||||
}
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
return (transport.connect.mock.calls.at(-1)?.[0] as { command?: string } | undefined)?.command
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
restoreNavigator()
|
||||
}
|
||||
}
|
||||
|
||||
it('quotes a cold-restore resume command for a cmd.exe Windows tab', async () => {
|
||||
await expect(runWindowsColdRestoreResume({ terminalWindowsShell: 'cmd.exe' })).resolves.toBe(
|
||||
'codex "--dangerously-bypass-approvals-and-sandbox" "resume" "codex-session-1"'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the tab shell override over the global Windows shell on cold restore', async () => {
|
||||
await expect(
|
||||
runWindowsColdRestoreResume({
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
tabShellOverride: 'cmd.exe'
|
||||
})
|
||||
).resolves.toBe('codex "--dangerously-bypass-approvals-and-sandbox" "resume" "codex-session-1"')
|
||||
})
|
||||
|
||||
it('keeps PowerShell quoting for a cold-restore resume on a PowerShell Windows tab', async () => {
|
||||
await expect(
|
||||
runWindowsColdRestoreResume({ terminalWindowsShell: 'powershell.exe' })
|
||||
).resolves.toBe("codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'")
|
||||
})
|
||||
|
||||
it('keeps a contentless reattach when the sleeping record represents a live session', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const paneKey = makePaneKey('tab-1', LEAF_2)
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ import {
|
|||
} from '@/lib/worktree-runtime-owner'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { resolveAgentResumeLaunchTarget } from '@/lib/agent-resume-launch-target'
|
||||
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
|
|
@ -308,7 +309,6 @@ import {
|
|||
recognizeAgentProcessFromCommandLine
|
||||
} from '../../../../shared/agent-process-recognition'
|
||||
import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types'
|
||||
import { isWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
|
||||
import { createDraftPasteReadyScanner } from '../../../../shared/draft-paste-ready-scanner'
|
||||
import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content'
|
||||
|
|
@ -4931,18 +4931,6 @@ export function connectPanePty(
|
|||
sessionRestoredBannerShown = reason
|
||||
deps.onShowSessionRestoredBanner(pane.id, reason)
|
||||
}
|
||||
const getColdRestoreAgentResumePlatform = (): NodeJS.Platform => {
|
||||
if (projectRuntime?.status === 'repair-required') {
|
||||
return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') {
|
||||
return 'linux'
|
||||
}
|
||||
if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) {
|
||||
return 'linux'
|
||||
}
|
||||
return CLIENT_PLATFORM
|
||||
}
|
||||
const buildColdRestoreAgentResumeStartup = (): ColdRestoreAgentResumeStartup | null => {
|
||||
if (pendingStartupCommand) {
|
||||
return null
|
||||
|
|
@ -4975,7 +4963,16 @@ export function connectPanePty(
|
|||
const launchConfig =
|
||||
(useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ??
|
||||
matchingSleepingLaunchConfig
|
||||
const resumePlatform = getColdRestoreAgentResumePlatform()
|
||||
// Why: the resume line is typed into this pane's live shell, so its quoting must
|
||||
// follow the tab's effective Windows shell, not the win32 PowerShell default.
|
||||
const resumeTarget = resolveAgentResumeLaunchTarget({
|
||||
projectRuntime,
|
||||
connectionId,
|
||||
executionHostId,
|
||||
worktreePath: worktree?.path,
|
||||
terminalWindowsShell: state.settings?.terminalWindowsShell,
|
||||
tabShellOverride: shellOverride
|
||||
})
|
||||
const startupPlan = buildAgentResumeStartupPlan({
|
||||
agent,
|
||||
providerSession,
|
||||
|
|
@ -4992,7 +4989,8 @@ export function connectPanePty(
|
|||
...(launchConfig?.ompResumeFilePath
|
||||
? { ompResumeFilePath: launchConfig.ompResumeFilePath }
|
||||
: {}),
|
||||
platform: resumePlatform
|
||||
platform: resumeTarget.platform,
|
||||
shell: resumeTarget.shell
|
||||
})
|
||||
if (!startupPlan) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentResumeLaunchTargetArgs } from './agent-resume-launch-target'
|
||||
|
||||
function setNavigatorUserAgent(userAgent: string): () => void {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
platform: userAgent.includes('Windows') ? 'Win32' : 'MacIntel',
|
||||
userAgent
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(globalThis, 'navigator', original)
|
||||
} else {
|
||||
delete (globalThis as { navigator?: Navigator }).navigator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_WINDOWS_ARGS: AgentResumeLaunchTargetArgs = {
|
||||
projectRuntime: undefined,
|
||||
connectionId: null,
|
||||
executionHostId: 'local',
|
||||
worktreePath: 'C:\\Users\\neil\\orca\\workspaces\\orca\\feature',
|
||||
terminalWindowsShell: null
|
||||
}
|
||||
|
||||
async function resolveWith(
|
||||
overrides: Partial<AgentResumeLaunchTargetArgs>
|
||||
): Promise<{ platform: NodeJS.Platform; shell: string | undefined }> {
|
||||
const { resolveAgentResumeLaunchTarget } = await import('./agent-resume-launch-target')
|
||||
return resolveAgentResumeLaunchTarget({
|
||||
...LOCAL_WINDOWS_ARGS,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveAgentResumeLaunchTarget on a Windows client', () => {
|
||||
let restoreNavigator = (): void => {}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
restoreNavigator = setNavigatorUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreNavigator()
|
||||
})
|
||||
|
||||
it('quotes for cmd.exe when the global Windows shell is cmd.exe', async () => {
|
||||
await expect(resolveWith({ terminalWindowsShell: 'cmd.exe' })).resolves.toEqual({
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the per-tab shell override over the global Windows shell', async () => {
|
||||
await expect(
|
||||
resolveWith({
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
tabShellOverride: 'C:\\WINDOWS\\system32\\cmd.exe'
|
||||
})
|
||||
).resolves.toEqual({ platform: 'win32', shell: 'cmd' })
|
||||
})
|
||||
|
||||
it('quotes for POSIX on a Git Bash tab', async () => {
|
||||
await expect(resolveWith({ terminalWindowsShell: 'git-bash' })).resolves.toEqual({
|
||||
platform: 'win32',
|
||||
shell: 'posix'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps PowerShell quoting when no Windows shell is configured', async () => {
|
||||
await expect(resolveWith({})).resolves.toEqual({
|
||||
platform: 'win32',
|
||||
shell: 'powershell'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an SSH workspace on its own default quoting', async () => {
|
||||
await expect(
|
||||
resolveWith({
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
terminalWindowsShell: 'cmd.exe'
|
||||
})
|
||||
).resolves.toEqual({ platform: 'linux', shell: undefined })
|
||||
})
|
||||
|
||||
it('does not describe a remote runtime host with the local Windows shell setting', async () => {
|
||||
await expect(
|
||||
resolveWith({
|
||||
executionHostId: 'runtime:prod-box',
|
||||
terminalWindowsShell: 'cmd.exe'
|
||||
})
|
||||
).resolves.toEqual({ platform: 'win32', shell: undefined })
|
||||
})
|
||||
|
||||
it('keeps POSIX quoting for a WSL UNC worktree', async () => {
|
||||
await expect(
|
||||
resolveWith({
|
||||
worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\neil\\repo',
|
||||
terminalWindowsShell: 'cmd.exe'
|
||||
})
|
||||
).resolves.toEqual({ platform: 'linux', shell: undefined })
|
||||
})
|
||||
|
||||
it('keeps POSIX quoting for a project pinned to a WSL runtime', async () => {
|
||||
await expect(
|
||||
resolveWith({
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
distro: 'Ubuntu',
|
||||
projectId: 'repo-1',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo-1:wsl:Ubuntu'
|
||||
}
|
||||
} as AgentResumeLaunchTargetArgs['projectRuntime'],
|
||||
terminalWindowsShell: 'cmd.exe'
|
||||
})
|
||||
).resolves.toEqual({ platform: 'linux', shell: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAgentResumeLaunchTarget off Windows', () => {
|
||||
let restoreNavigator = (): void => {}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
restoreNavigator = setNavigatorUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreNavigator()
|
||||
})
|
||||
|
||||
it('ignores a stale Windows shell setting on a mac client', async () => {
|
||||
await expect(
|
||||
resolveWith({
|
||||
worktreePath: '/Users/neil/repo',
|
||||
terminalWindowsShell: 'cmd.exe'
|
||||
})
|
||||
).resolves.toEqual({ platform: 'darwin', shell: undefined })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { resolveWindowsShellOverride } from '@/lib/pane-manager/windows-pty-compatibility'
|
||||
import { parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import { isWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
|
||||
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
|
||||
|
||||
export type AgentResumeLaunchTarget = {
|
||||
platform: NodeJS.Platform
|
||||
/** undefined keeps the platform default: PowerShell on win32, POSIX elsewhere. */
|
||||
shell: AgentStartupShell | undefined
|
||||
}
|
||||
|
||||
export type AgentResumeLaunchTargetArgs = {
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined
|
||||
/** SSH connection owning the workspace, if any. */
|
||||
connectionId: string | null | undefined
|
||||
/** Pane/workspace execution owner; only a 'local' host is the one `terminalWindowsShell` describes. */
|
||||
executionHostId: string | null
|
||||
worktreePath: string | null | undefined
|
||||
terminalWindowsShell: string | null | undefined
|
||||
/** Per-tab Windows shell override, which beats the global setting at spawn time. */
|
||||
tabShellOverride?: string | null
|
||||
}
|
||||
|
||||
function resolveResumeLaunchPlatform(args: AgentResumeLaunchTargetArgs): NodeJS.Platform {
|
||||
if (args.projectRuntime?.status === 'repair-required') {
|
||||
return args.projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') {
|
||||
return 'linux'
|
||||
}
|
||||
if (args.connectionId || (args.worktreePath && isWslUncPath(args.worktreePath))) {
|
||||
return 'linux'
|
||||
}
|
||||
return CLIENT_PLATFORM
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform *and* live shell family a queued agent-resume command must be quoted for.
|
||||
* Why the shell half matters: resume lines are typed into a real terminal, so on a
|
||||
* cmd.exe tab the win32 PowerShell default sends literal quotes and the agent CLI
|
||||
* rejects the resume argv ("unexpected argument ''<uuid>'' found", #12320).
|
||||
*/
|
||||
export function resolveAgentResumeLaunchTarget(
|
||||
args: AgentResumeLaunchTargetArgs
|
||||
): AgentResumeLaunchTarget {
|
||||
const platform = resolveResumeLaunchPlatform(args)
|
||||
return {
|
||||
platform,
|
||||
shell: resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote:
|
||||
Boolean(args.connectionId) || parseExecutionHostId(args.executionHostId)?.kind !== 'local',
|
||||
terminalWindowsShell: resolveWindowsShellOverride(
|
||||
args.tabShellOverride,
|
||||
args.terminalWindowsShell
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
// Windows shell-quoting coverage for the sleeping-agent resume launch (#12320):
|
||||
// the queued resume line is typed into the new tab's shell, so cmd.exe tabs must
|
||||
// not receive PowerShell single quotes.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
|
||||
const mockCreateTab = vi.fn()
|
||||
const mockQueueTabStartupCommand = vi.fn()
|
||||
|
||||
const store = {
|
||||
settings: {
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: {} as Record<string, string>,
|
||||
agentDefaultEnv: {} as Record<string, Record<string, string>>,
|
||||
activeRuntimeEnvironmentId: null as string | null
|
||||
} as {
|
||||
agentCmdOverrides: Record<string, string>
|
||||
agentDefaultArgs: Record<string, string>
|
||||
agentDefaultEnv: Record<string, Record<string, string>>
|
||||
activeRuntimeEnvironmentId: string | null
|
||||
terminalWindowsShell?: string
|
||||
},
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
connectionId: null as string | null,
|
||||
path: 'C:\\Users\\neil\\repo'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: 'C:\\Users\\neil\\repo\\feature',
|
||||
displayName: 'feature'
|
||||
}
|
||||
]
|
||||
} as Record<string, { id: string; repoId: string; path: string; displayName: string }[]>,
|
||||
getKnownWorktreeById: (id: string) =>
|
||||
Object.values(store.worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === id),
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
openFiles: [] as { id: string; worktreeId: string }[],
|
||||
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
tabBarOrderByWorktree: {} as Record<string, string[]>,
|
||||
createTab: mockCreateTab,
|
||||
queueTabStartupCommand: mockQueueTabStartupCommand,
|
||||
claimAutomaticAgentResume: vi.fn(),
|
||||
clearSleepingAgentSession: vi.fn(),
|
||||
setActiveTabType: vi.fn(),
|
||||
setTabBarOrder: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
|
||||
vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' }))
|
||||
vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/telemetry', () => ({
|
||||
track: vi.fn(),
|
||||
tuiAgentToAgentKind: (agent: string) => agent
|
||||
}))
|
||||
vi.mock('@/components/tab-bar/reconcile-order', () => ({
|
||||
reconcileTabOrder: vi.fn((_stored, termIds: string[]) => [...termIds])
|
||||
}))
|
||||
|
||||
const SESSION_ID = '0199f7a1-0000-7000-8000-000000000001'
|
||||
|
||||
const record: SleepingAgentSessionRecord = {
|
||||
paneKey: 'tab-1::leaf-1',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: SESSION_ID },
|
||||
prompt: 'finish the task',
|
||||
state: 'done',
|
||||
origin: 'worktree-sleep',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
async function launch(): Promise<string | undefined> {
|
||||
const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch')
|
||||
launchSleepingAgentSession(record)
|
||||
const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as
|
||||
| { command: string }
|
||||
| undefined
|
||||
return queued?.command
|
||||
}
|
||||
|
||||
describe('launchSleepingAgentSession Windows shell quoting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.settings = {
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: {},
|
||||
agentDefaultEnv: {},
|
||||
activeRuntimeEnvironmentId: null
|
||||
}
|
||||
store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\neil\\repo' }]
|
||||
store.worktreesByRepo = {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: 'C:\\Users\\neil\\repo\\feature',
|
||||
displayName: 'feature'
|
||||
}
|
||||
]
|
||||
}
|
||||
mockCreateTab.mockReturnValue({ id: 'tab-1' })
|
||||
})
|
||||
|
||||
it('quotes the resume argv for a cmd.exe tab', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
|
||||
await expect(launch()).resolves.toBe(
|
||||
`codex "--dangerously-bypass-approvals-and-sandbox" "resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps PowerShell quoting for a powershell tab', async () => {
|
||||
store.settings.terminalWindowsShell = 'powershell.exe'
|
||||
|
||||
await expect(launch()).resolves.toBe(
|
||||
`codex '--dangerously-bypass-approvals-and-sandbox' 'resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes the resume argv for a Git Bash tab', async () => {
|
||||
store.settings.terminalWindowsShell = 'git-bash'
|
||||
|
||||
await expect(launch()).resolves.toBe(
|
||||
`codex '--dangerously-bypass-approvals-and-sandbox' 'resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the local Windows shell setting for an SSH workspace', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/home/neil/repo' }]
|
||||
store.worktreesByRepo = {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/home/neil/repo/feature',
|
||||
displayName: 'feature'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
await expect(launch()).resolves.toBe(
|
||||
`codex '--dangerously-bypass-approvals-and-sandbox' 'resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
|
||||
import { isWslUncPath } from '../../../shared/wsl-paths'
|
||||
import {
|
||||
resolveAgentResumeLaunchTarget,
|
||||
type AgentResumeLaunchTarget
|
||||
} from '@/lib/agent-resume-launch-target'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
|
|
@ -25,21 +28,18 @@ export type ResumeSleepingAgentSessionsOptions = {
|
|||
onSessionLaunched?: (tabId: string) => void
|
||||
}
|
||||
|
||||
function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform {
|
||||
function getResumeLaunchTarget(worktreeId: string): AgentResumeLaunchTarget {
|
||||
const state = useAppStore.getState()
|
||||
const worktree = state.getKnownWorktreeById(worktreeId)
|
||||
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
|
||||
const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId)
|
||||
if (projectRuntime?.status === 'repair-required') {
|
||||
return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') {
|
||||
return 'linux'
|
||||
}
|
||||
if (repo?.connectionId || (worktree?.path && isWslUncPath(worktree.path))) {
|
||||
return 'linux'
|
||||
}
|
||||
return CLIENT_PLATFORM
|
||||
// The resume tab is created without a shell override, so the global Windows shell wins.
|
||||
return resolveAgentResumeLaunchTarget({
|
||||
projectRuntime: getLocalProjectExecutionRuntimeContext(state, worktreeId),
|
||||
connectionId: repo?.connectionId,
|
||||
executionHostId: getExecutionHostIdForWorktree(state, worktreeId),
|
||||
worktreePath: worktree?.path,
|
||||
terminalWindowsShell: state.settings?.terminalWindowsShell
|
||||
})
|
||||
}
|
||||
|
||||
function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void {
|
||||
|
|
@ -68,6 +68,7 @@ export function launchSleepingAgentSession(
|
|||
): boolean {
|
||||
const state = useAppStore.getState()
|
||||
const launchConfig = record.launchConfig
|
||||
const resumeTarget = getResumeLaunchTarget(record.worktreeId)
|
||||
const startupPlan = buildAgentResumeStartupPlan({
|
||||
agent: record.agent,
|
||||
providerSession: record.providerSession,
|
||||
|
|
@ -84,7 +85,8 @@ export function launchSleepingAgentSession(
|
|||
...(launchConfig?.ompResumeFilePath
|
||||
? { ompResumeFilePath: launchConfig.ompResumeFilePath }
|
||||
: {}),
|
||||
platform: getResumeLaunchPlatform(record.worktreeId)
|
||||
platform: resumeTarget.platform,
|
||||
shell: resumeTarget.shell
|
||||
})
|
||||
if (!startupPlan) {
|
||||
toast.error(
|
||||
|
|
|
|||
|
|
@ -593,6 +593,40 @@ describe('tui agent startup plans', () => {
|
|||
expect(plan?.launchCommand).toBe("codex 'resume' 's1'")
|
||||
})
|
||||
|
||||
it('quotes Windows resume argv for cmd.exe when shell is cmd', () => {
|
||||
const plan = buildAgentResumeStartupPlan({
|
||||
agent: 'grok',
|
||||
providerSession: { key: 'session_id', id: '019fc272-80fa-7a91-80a2-9c461ef1a9da' },
|
||||
cmdOverrides: {},
|
||||
agentArgs: '--permission-mode bypassPermissions',
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})
|
||||
|
||||
// Why: cmd.exe treats single quotes as literal characters. Resume must use
|
||||
// double quotes (or unquoted tokens) so the CLI receives clean argv.
|
||||
expect(plan?.launchCommand).toBe(
|
||||
'grok "--permission-mode" "bypassPermissions" "--resume" "019fc272-80fa-7a91-80a2-9c461ef1a9da"'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps cmd-quoted agentCommand aligned with cmd resume suffix', () => {
|
||||
const plan = buildAgentResumeStartupPlan({
|
||||
agent: 'grok',
|
||||
providerSession: { key: 'session_id', id: '019fc272-80fa-7a91-80a2-9c461ef1a9da' },
|
||||
cmdOverrides: {},
|
||||
agentCommand: 'grok "--permission-mode" "bypassPermissions"',
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})
|
||||
|
||||
// Regression: agentCommand from a prior cmd launch + PowerShell-default resume
|
||||
// suffix produced mixed quoting and broke reboot restore on cmd.exe tabs.
|
||||
expect(plan?.launchCommand).toBe(
|
||||
'grok "--permission-mode" "bypassPermissions" "--resume" "019fc272-80fa-7a91-80a2-9c461ef1a9da"'
|
||||
)
|
||||
})
|
||||
|
||||
it('honors command overrides when building POSIX resume plans', () => {
|
||||
const plan = buildAgentResumeStartupPlan({
|
||||
agent: 'codex',
|
||||
|
|
|
|||
Loading…
Reference in New Issue