Fix Windows AI Vault resumes using the wrong shell (#13420)
* fix: use project shell for AI Vault resumes * fix: preserve WSL cwd fallback semantics --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
parent
ce8c3267b7
commit
bdd763188f
|
|
@ -16,6 +16,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a
|
|||
import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
|
||||
import type * as Wsl from '../wsl'
|
||||
|
||||
const isWindowsHost = process.platform === 'win32'
|
||||
const posixOnlyIt = isWindowsHost ? it.skip : it
|
||||
|
|
@ -57,6 +58,7 @@ const {
|
|||
piBuildPtyEnvMock,
|
||||
piClearPtyMock,
|
||||
isPwshAvailableMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
trackMock,
|
||||
classifyErrorMock,
|
||||
registerPtyMock,
|
||||
|
|
@ -86,6 +88,7 @@ const {
|
|||
openCodeBuildPtyEnvMock: vi.fn(),
|
||||
mimoCodeBuildPtyEnvMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
wslUncDirectoryExistsAsyncMock: vi.fn(),
|
||||
openCodeClearPtyMock: vi.fn(),
|
||||
buildAgentHookEnvMock: vi.fn(),
|
||||
clearAgentHookPaneStateMock: vi.fn(),
|
||||
|
|
@ -183,6 +186,11 @@ vi.mock('../pwsh', () => ({
|
|||
isPwshAvailableAsync: isPwshAvailableMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof Wsl>()),
|
||||
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
|
@ -384,6 +392,8 @@ describe('registerPtyHandlers', () => {
|
|||
piBuildPtyEnvMock.mockReset()
|
||||
piClearPtyMock.mockReset()
|
||||
isPwshAvailableMock.mockReset()
|
||||
wslUncDirectoryExistsAsyncMock.mockReset()
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
|
||||
trackMock.mockReset()
|
||||
classifyErrorMock.mockReset()
|
||||
registerPtyMock.mockReset()
|
||||
|
|
@ -12125,6 +12135,272 @@ describe('registerPtyHandlers', () => {
|
|||
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
|
||||
})
|
||||
|
||||
it('keeps an existing POSIX startup cwd for the selected WSL runtime', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
|
||||
statSyncMock.mockImplementation((target: string) => {
|
||||
if (target === '/home/alice/repo') {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
return { isDirectory: () => true, mode: 0o755 }
|
||||
})
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::C:\\Users\\alice\\repo',
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
projectId: 'repo-1',
|
||||
distro: 'Ubuntu-24.04',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo-1:wsl'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo')
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo'
|
||||
)
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/home/alice/repo', shellOverride: 'wsl.exe' })
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back when the selected WSL runtime reports a POSIX cwd missing', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-cwd' })
|
||||
const worktreePath = 'C:/Users/alice/repo'
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/deleted',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: `repo-1::${worktreePath}`,
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
projectId: 'repo-1',
|
||||
distro: 'Ubuntu-24.04',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo-1:wsl'
|
||||
}
|
||||
}
|
||||
})) as { startupCwdFallback?: { kind: string; cwd: string } }
|
||||
|
||||
expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: worktreePath }))
|
||||
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::C:/Users/alice/repo',
|
||||
shellOverride: 'wsl.exe'
|
||||
})) as { startupCwdFallback?: unknown }
|
||||
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/home/alice/repo' })
|
||||
)
|
||||
expect(result.startupCwdFallback).toBeUndefined()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a POSIX cwd when WSL owns it but no distro can be resolved', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-no-distro-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
_setWslCachesForTests({ available: true, distros: [] })
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::C:/Users/alice/repo',
|
||||
shellOverride: 'wsl.exe'
|
||||
})) as { startupCwdFallback?: unknown }
|
||||
|
||||
expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo')
|
||||
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/home/alice/repo' })
|
||||
)
|
||||
expect(result.startupCwdFallback).toBeUndefined()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ exists: true, expectedCwd: '/home/alice/repo', expectedFallback: undefined },
|
||||
{
|
||||
exists: false,
|
||||
expectedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice',
|
||||
expectedFallback: {
|
||||
kind: 'worktree',
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice'
|
||||
}
|
||||
}
|
||||
])('resolves POSIX cwd existence for a WSL UNC workspace ($exists)', async (testCase) => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-unc-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(testCase.exists)
|
||||
if (!testCase.exists) {
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(true)
|
||||
}
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice'
|
||||
})) as { startupCwdFallback?: { kind: string; cwd: string } }
|
||||
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: testCase.expectedCwd })
|
||||
)
|
||||
expect(result.startupCwdFallback).toEqual(testCase.expectedFallback)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a missing POSIX cwd when its WSL workspace root is also missing', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-root' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/deleted',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice'
|
||||
})) as { startupCwdFallback?: unknown }
|
||||
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/home/alice/deleted' })
|
||||
)
|
||||
expect(result.startupCwdFallback).toBeUndefined()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('validates a /mnt drive cwd through its native Windows path', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-mnt-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/mnt/c/Users/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::C:/Users/alice/repo',
|
||||
shellOverride: 'wsl.exe'
|
||||
})
|
||||
|
||||
expect(statSyncMock).toHaveBeenCalledWith('C:\\Users\\alice\\repo')
|
||||
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/mnt/c/Users/alice/repo' })
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('still falls back a missing Windows cwd when the selected runtime is WSL', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-windows-cwd' })
|
||||
const worktreePath = 'C:/Users/alice/repo'
|
||||
const missingCwd = `${worktreePath}/deleted-folder`
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
statSyncMock.mockImplementation((target: string) => {
|
||||
if (target === missingCwd) {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
return { isDirectory: () => true, mode: 0o755 }
|
||||
})
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: missingCwd,
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: `repo-1::${worktreePath}`,
|
||||
shellOverride: 'wsl.exe'
|
||||
})) as { startupCwdFallback?: { kind: string; cwd: string } }
|
||||
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: worktreePath, shellOverride: 'wsl.exe' })
|
||||
)
|
||||
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a missing cwd unchanged without the fallback flag', async () => {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder')
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ import {
|
|||
resolveTerminalStartupCwdForWorkspace,
|
||||
type TerminalStartupCwdMissingDirFallback
|
||||
} from '../../shared/terminal-startup-cwd'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
|
||||
import {
|
||||
|
|
@ -150,7 +150,7 @@ import {
|
|||
clearMigrationUnsupportedPty,
|
||||
clearMigrationUnsupportedPtysForPaneKey
|
||||
} from '../agent-hooks/migration-unsupported-pty-state'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl'
|
||||
import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path'
|
||||
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env'
|
||||
import { PtyProducerFlowController } from './pty-producer-flow-control'
|
||||
|
|
@ -5775,15 +5775,73 @@ export function registerPtyHandlers(
|
|||
await startupPromise
|
||||
}
|
||||
// Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem.
|
||||
const allowMissingCwdFallback =
|
||||
const requestedMissingCwdFallback =
|
||||
!args.connectionId && !args.sessionId && args.cwdFallback === 'worktree'
|
||||
const isWslOwnedPosixCwd =
|
||||
args.cwd?.startsWith('/') === true && !/^\/[A-Za-z](?:\/|$)/.test(args.cwd)
|
||||
const startupWorkspaceCwd =
|
||||
requestedMissingCwdFallback && isWslOwnedPosixCwd
|
||||
? resolvePtySpawnStartupCwd(args.worktreeId, '.')
|
||||
: undefined
|
||||
const initiallyResolvedStartupCwd =
|
||||
requestedMissingCwdFallback && isWslOwnedPosixCwd
|
||||
? resolvePtySpawnStartupCwd(args.worktreeId, args.cwd)
|
||||
: undefined
|
||||
const startupTerminalRuntimeOptions =
|
||||
requestedMissingCwdFallback && process.platform === 'win32'
|
||||
? resolveLocalWindowsTerminalRuntimeOptions({
|
||||
requestedShellOverride: args.shellOverride,
|
||||
settings: getSettings?.(),
|
||||
projectRuntime: args.projectRuntime,
|
||||
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
|
||||
})
|
||||
: undefined
|
||||
const wslRuntimeOwnsStartupCwd =
|
||||
requestedMissingCwdFallback &&
|
||||
isWslOwnedPosixCwd &&
|
||||
(isWslShellName(startupTerminalRuntimeOptions?.shellOverride) ||
|
||||
isWslUncPath(startupWorkspaceCwd ?? ''))
|
||||
const startupWslContext = wslRuntimeOwnsStartupCwd
|
||||
? resolveWslSessionContext({
|
||||
cwd: startupWorkspaceCwd,
|
||||
shellOverride: startupTerminalRuntimeOptions?.shellOverride,
|
||||
terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro
|
||||
})
|
||||
: undefined
|
||||
let wslStartupCwdExists: boolean | null = null
|
||||
let wslWorkspaceCwdExists: boolean | null = null
|
||||
if (startupWslContext && initiallyResolvedStartupCwd) {
|
||||
const validationCwd = toWindowsWslPath(
|
||||
initiallyResolvedStartupCwd,
|
||||
startupWslContext.distro
|
||||
)
|
||||
wslStartupCwdExists = isWslUncPath(validationCwd)
|
||||
? await wslUncDirectoryExistsAsync(validationCwd)
|
||||
: localStartupCwdDirectoryExists(validationCwd)
|
||||
if (wslStartupCwdExists === false && startupWorkspaceCwd) {
|
||||
wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd)
|
||||
? await wslUncDirectoryExistsAsync(startupWorkspaceCwd)
|
||||
: localStartupCwdDirectoryExists(startupWorkspaceCwd)
|
||||
}
|
||||
}
|
||||
const allowMissingCwdFallback =
|
||||
requestedMissingCwdFallback &&
|
||||
(!wslRuntimeOwnsStartupCwd ||
|
||||
(wslStartupCwdExists === false && wslWorkspaceCwdExists === true))
|
||||
let didFallbackToWorkspaceRootCwd = false
|
||||
const cwd = resolvePtySpawnStartupCwd(
|
||||
args.worktreeId,
|
||||
args.cwd,
|
||||
allowMissingCwdFallback
|
||||
? {
|
||||
directoryExists: localStartupCwdDirectoryExists,
|
||||
directoryExists: (path) =>
|
||||
startupWslContext &&
|
||||
wslStartupCwdExists === false &&
|
||||
path === initiallyResolvedStartupCwd
|
||||
? false
|
||||
: startupWslContext && path === startupWorkspaceCwd
|
||||
? wslWorkspaceCwdExists === true
|
||||
: localStartupCwdDirectoryExists(path),
|
||||
onFallbackToWorkspaceRoot: () => {
|
||||
didFallbackToWorkspaceRootCwd = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -978,6 +978,31 @@ describe('LocalPtyProvider', () => {
|
|||
expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian')
|
||||
})
|
||||
|
||||
it('translates a POSIX cwd through the preferred WSL distro', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
|
||||
cwd: '/home/jin/repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[1]).toEqual([
|
||||
'-d',
|
||||
'Debian',
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining("cd '/home/jin/repo'")
|
||||
])
|
||||
expect(spawnCall[2].cwd).not.toBe('/home/jin/repo')
|
||||
})
|
||||
|
||||
it('resolves and persists the default distro for Windows cwd WSL terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const buildSpawnEnv = vi.fn(
|
||||
|
|
|
|||
|
|
@ -239,9 +239,9 @@ function getWslContextFromWorktreeId(
|
|||
*/
|
||||
function getWslContextFromPreferredDistro(
|
||||
distro: string | null | undefined
|
||||
): { distro: string } | undefined {
|
||||
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
|
||||
const trimmed = distro?.trim()
|
||||
return trimmed ? { distro: trimmed } : undefined
|
||||
return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ import {
|
|||
parseWslPath,
|
||||
toLinuxPath,
|
||||
toWindowsWslPath,
|
||||
wslUncDirectoryExists
|
||||
wslUncDirectoryExists,
|
||||
wslUncDirectoryExistsAsync
|
||||
} from './wsl'
|
||||
|
||||
function withPlatform<T>(value: NodeJS.Platform, fn: () => T): T {
|
||||
|
|
@ -805,36 +806,39 @@ describe('wslUncDirectoryExists', () => {
|
|||
})
|
||||
|
||||
it('returns true when the distro reports the directory exists', () => {
|
||||
execFileSyncMock.mockReturnValue('')
|
||||
execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_EXISTS__')
|
||||
const result = withPlatform('win32', () =>
|
||||
wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
expect(execFileSyncMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'test', '-d', '/home/jin/repo'],
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'),
|
||||
'sh',
|
||||
'/home/jin/repo'
|
||||
],
|
||||
expect.objectContaining({ timeout: 5000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when test -d exits non-zero (directory missing)', () => {
|
||||
execFileSyncMock.mockImplementation(() => {
|
||||
// Why: child_process surfaces a non-zero exit as an Error with `status`.
|
||||
const error = new Error('Command failed') as Error & { status: number }
|
||||
error.status = 1
|
||||
throw error
|
||||
})
|
||||
it('returns false when the guest reports the directory missing', () => {
|
||||
execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_MISSING__')
|
||||
const result = withPlatform('win32', () =>
|
||||
wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing')
|
||||
)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null when wsl.exe is unavailable (inconclusive)', () => {
|
||||
it('returns null when wsl.exe or the distro is unavailable', () => {
|
||||
execFileSyncMock.mockImplementation(() => {
|
||||
// No numeric `status` -> spawn failure (ENOENT), not a missing directory.
|
||||
const error = new Error('spawn wsl.exe ENOENT') as Error & { code: string }
|
||||
error.code = 'ENOENT'
|
||||
const error = new Error('distro unavailable') as Error & { status: number }
|
||||
error.status = 4294967295
|
||||
throw error
|
||||
})
|
||||
const result = withPlatform('win32', () =>
|
||||
|
|
@ -851,3 +855,67 @@ describe('wslUncDirectoryExists', () => {
|
|||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('wslUncDirectoryExistsAsync', () => {
|
||||
afterEach(() => {
|
||||
execFileMock.mockReset()
|
||||
})
|
||||
|
||||
it('returns true when the distro reports the directory exists', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) =>
|
||||
callback(null, '__ORCA_DIRECTORY_EXISTS__')
|
||||
)
|
||||
|
||||
await expect(
|
||||
withPlatformAsync('win32', () =>
|
||||
wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'),
|
||||
'sh',
|
||||
'/home/jin/repo'
|
||||
],
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('distinguishes a missing directory from an inconclusive probe', async () => {
|
||||
execFileMock
|
||||
.mockImplementationOnce((_command, _args, _options, callback) =>
|
||||
callback(null, '__ORCA_DIRECTORY_MISSING__')
|
||||
)
|
||||
.mockImplementationOnce((_command, _args, _options, callback) =>
|
||||
callback(Object.assign(new Error('distro unavailable'), { code: 4294967295 }), '')
|
||||
)
|
||||
|
||||
await withPlatformAsync('win32', async () => {
|
||||
await expect(
|
||||
wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing')
|
||||
).resolves.toBe(false)
|
||||
await expect(
|
||||
wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null without spawning for paths outside WSL or off Windows', async () => {
|
||||
await expect(
|
||||
withPlatformAsync('win32', () => wslUncDirectoryExistsAsync('C:\\Users\\jin\\repo'))
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
withPlatformAsync('linux', () =>
|
||||
wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin')
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,33 @@ export type WslPathInfo = {
|
|||
linuxPath: string
|
||||
}
|
||||
|
||||
const WSL_DIRECTORY_EXISTS_MARKER = '__ORCA_DIRECTORY_EXISTS__'
|
||||
const WSL_DIRECTORY_MISSING_MARKER = '__ORCA_DIRECTORY_MISSING__'
|
||||
|
||||
function getWslDirectoryProbeArgs(info: WslPathInfo): string[] {
|
||||
return [
|
||||
'-d',
|
||||
info.distro,
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
`if [ -d "$1" ]; then printf ${WSL_DIRECTORY_EXISTS_MARKER}; else printf ${WSL_DIRECTORY_MISSING_MARKER}; fi`,
|
||||
'sh',
|
||||
info.linuxPath
|
||||
]
|
||||
}
|
||||
|
||||
function parseWslDirectoryProbeOutput(stdout: unknown): boolean | null {
|
||||
const output = String(stdout)
|
||||
if (output.includes(WSL_DIRECTORY_EXISTS_MARKER)) {
|
||||
return true
|
||||
}
|
||||
if (output.includes(WSL_DIRECTORY_MISSING_MARKER)) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a Windows path is a WSL UNC path and extract the distro name
|
||||
* and equivalent Linux path.
|
||||
|
|
@ -49,8 +76,8 @@ export function isWslPath(path: string): boolean {
|
|||
* Why: Win32 fs.statSync against the WSL 9P filesystem (\\wsl.localhost\...)
|
||||
* is unreliable for repos that live on the WSL side — it can report ENOENT for
|
||||
* directories that exist, which made opening a WSL worktree fail with
|
||||
* "Working directory ... does not exist". `wsl.exe -d <distro> test -d` asks
|
||||
* the distro directly, which is the authoritative answer. Returns null (rather
|
||||
* "Working directory ... does not exist". The guest marker probe asks the
|
||||
* distro directly, which is the authoritative answer. Returns null (rather
|
||||
* than false) when wsl.exe is unavailable or errors so callers can fall back to
|
||||
* the fs check instead of falsely rejecting a valid directory.
|
||||
*/
|
||||
|
|
@ -63,22 +90,33 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null {
|
|||
return null
|
||||
}
|
||||
try {
|
||||
execFileSync('wsl.exe', ['-d', info.distro, '--', 'test', '-d', info.linuxPath], {
|
||||
const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000
|
||||
timeout: 5000,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
// A non-zero exit (directory missing) surfaces as an error with a numeric
|
||||
// `status`; treat that as a definitive "does not exist". Any other failure
|
||||
// (wsl.exe missing, distro not running, timeout) is inconclusive -> null.
|
||||
if (typeof (error as { status?: unknown })?.status === 'number') {
|
||||
return false
|
||||
}
|
||||
return parseWslDirectoryProbeOutput(stdout)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function wslUncDirectoryExistsAsync(uncPath: string): Promise<boolean | null> {
|
||||
if (process.platform !== 'win32') {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const info = parseWslUncPath(uncPath)
|
||||
if (!info) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
execFile('wsl.exe', getWslDirectoryProbeArgs(info), { timeout: 5000 }, (_error, stdout) => {
|
||||
// Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative.
|
||||
resolve(parseWslDirectoryProbeOutput(stdout))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Windows path to a Linux path for commands that will execute inside WSL.
|
||||
* Returns the path unchanged if it is already POSIX-style.
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export default function AiVaultSessionDropLayer({
|
|||
agent: payload.agent,
|
||||
worktreeId,
|
||||
command: startup.command,
|
||||
...(payload.sessionCwd ? { cwd: payload.sessionCwd } : {}),
|
||||
...(startup.env ? { env: startup.env } : {}),
|
||||
...(startup.envToDelete ? { envToDelete: startup.envToDelete } : {}),
|
||||
...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ describe('AI Vault OMP cold resume', () => {
|
|||
})
|
||||
|
||||
expect(startup).toMatchObject({
|
||||
command:
|
||||
"cd '/repo' && omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'",
|
||||
command: "omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'",
|
||||
cwd: '/repo',
|
||||
env: { OMP_PROFILE: 'custom' },
|
||||
launchConfig: {
|
||||
agentCommand: "omp '--model' 'custom'",
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ describe('buildAiVaultDropRepinStartup', () => {
|
|||
expect(startup).not.toBeNull()
|
||||
expect(startup?.command).toContain(`CODEX_HOME='${SELECTED_HOME}'`)
|
||||
expect(startup?.command).not.toContain(RECORDED_HOME)
|
||||
expect(startup?.command).toContain("cd '/Users/ada/repo' && ")
|
||||
expect(startup).toMatchObject({ cwd: '/Users/ada/repo' })
|
||||
})
|
||||
|
||||
it('repins a payload whose session has no cwd instead of keeping the wrong-account command', () => {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,30 @@ function buildQueuedAiVaultResumeCommand(
|
|||
}
|
||||
|
||||
describe('ai vault resume command runtime', () => {
|
||||
it('repro: queues a host-runtime resume without configured-WSL shell syntax', () => {
|
||||
const state = makeState({
|
||||
worktreePath: 'C:\\Users\\alice\\repo',
|
||||
localWindowsRuntimePreference: { kind: 'windows-host' },
|
||||
terminalWindowsShell: 'wsl.exe'
|
||||
})
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeStartupForWorktree({
|
||||
state,
|
||||
worktreeId: 'repo-1::worktree-1',
|
||||
session: {
|
||||
agent: 'claude',
|
||||
sessionId: 'session one',
|
||||
cwd: 'C:\\Users\\alice\\repo',
|
||||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toMatchObject({
|
||||
command: "claude '--resume' 'session one'",
|
||||
cwd: 'C:\\Users\\alice\\repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('queues a PowerShell-valid command for the default Windows shell', () => {
|
||||
// Why: the queued command is typed into the live tab shell (default
|
||||
// PowerShell), which mis-parses the cmd `""`-doubled wrapper (#6152).
|
||||
|
|
@ -85,7 +109,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'")
|
||||
).toBe("claude '--resume' 'session one'")
|
||||
})
|
||||
|
||||
it('queues direct cmd syntax when the configured Windows shell is cmd.exe', () => {
|
||||
|
|
@ -105,7 +129,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe('cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"')
|
||||
).toBe('claude "--resume" "session one"')
|
||||
})
|
||||
|
||||
it('queues a POSIX command for the Git Bash Windows shell', () => {
|
||||
|
|
@ -125,7 +149,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'")
|
||||
).toBe("claude '--resume' 'session one'")
|
||||
})
|
||||
|
||||
it('follows the live Windows shell for non-resumable agents in the fallback path', () => {
|
||||
|
|
@ -144,9 +168,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
"Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; cursor-agent --resume 'session one'"
|
||||
)
|
||||
).toBe("cursor-agent --resume 'session one'")
|
||||
})
|
||||
|
||||
it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => {
|
||||
|
|
@ -166,9 +188,7 @@ describe('ai vault resume command runtime', () => {
|
|||
}
|
||||
})
|
||||
|
||||
expect(command).toBe(
|
||||
"Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'"
|
||||
)
|
||||
expect(command).toBe("omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'")
|
||||
expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144')
|
||||
})
|
||||
|
||||
|
|
@ -190,9 +210,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
'cd /d "C:\\Users\\alice\\repo" && omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"'
|
||||
)
|
||||
).toBe('omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"')
|
||||
})
|
||||
|
||||
it('copies syntax that matches the configured cmd shell', () => {
|
||||
|
|
@ -337,8 +355,8 @@ describe('ai vault resume command runtime', () => {
|
|||
}
|
||||
})
|
||||
).toEqual({
|
||||
command:
|
||||
"cd '/home/alice/repo' && claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'",
|
||||
command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'",
|
||||
cwd: '/home/alice/repo',
|
||||
env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' },
|
||||
launchConfig: {
|
||||
agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'",
|
||||
|
|
@ -367,7 +385,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'")
|
||||
).toBe("claude '--resume' 'session one'")
|
||||
})
|
||||
|
||||
it('uses POSIX command wrapping for SSH-owned worktrees on Windows clients', () => {
|
||||
|
|
@ -386,7 +404,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'")
|
||||
).toBe("claude '--resume' 'session one'")
|
||||
})
|
||||
|
||||
it('uses POSIX command wrapping for folder workspaces with their own SSH target', () => {
|
||||
|
|
@ -415,7 +433,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/platform' && claude '--resume' 'session one'")
|
||||
).toBe("claude '--resume' 'session one'")
|
||||
})
|
||||
|
||||
it('uses POSIX command wrapping for WSL UNC folder workspaces on Windows clients', () => {
|
||||
|
|
@ -443,7 +461,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/platform' && claude '--resume' 'session one'")
|
||||
).toBe("claude '--resume' 'session one'")
|
||||
})
|
||||
|
||||
it('keeps WSL UNC worktrees on POSIX command wrapping without an explicit override', () => {
|
||||
|
|
@ -470,7 +488,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'")
|
||||
).toBe("CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'")
|
||||
})
|
||||
|
||||
it('converts WSL UNC OMP transcript paths before building Linux resume commands', () => {
|
||||
|
|
@ -491,9 +509,7 @@ describe('ai vault resume command runtime', () => {
|
|||
codexHome: null
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
"cd '/home/alice/repo' && omp --resume '/home/alice/.omp/agent/sessions/repo/sess.jsonl'"
|
||||
)
|
||||
).toBe("omp --resume '/home/alice/.omp/agent/sessions/repo/sess.jsonl'")
|
||||
})
|
||||
|
||||
it('deletes inherited Codex homes when resuming a real-home session', () => {
|
||||
|
|
@ -511,7 +527,8 @@ describe('ai vault resume command runtime', () => {
|
|||
}
|
||||
})
|
||||
).toMatchObject({
|
||||
command: "Set-Location -LiteralPath '/home/alice/repo'; codex 'resume' 'session one'",
|
||||
command: "codex 'resume' 'session one'",
|
||||
cwd: '/home/alice/repo',
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME']
|
||||
})
|
||||
})
|
||||
|
|
@ -534,7 +551,8 @@ describe('ai vault resume command runtime', () => {
|
|||
}
|
||||
})
|
||||
).toMatchObject({
|
||||
command: "cd '/home/alice/repo' && codex 'resume' 'session one'",
|
||||
command: "codex 'resume' 'session one'",
|
||||
cwd: '/home/alice/repo',
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
|
||||
providerSession: { key: 'session_id', id: 'session one' }
|
||||
})
|
||||
|
|
@ -558,7 +576,7 @@ describe('ai vault resume command runtime', () => {
|
|||
resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'"
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'")
|
||||
).toBe("codex 'resume' 'session one'")
|
||||
})
|
||||
|
||||
it('copies remote real-home Codex commands with explicit environment cleanup', () => {
|
||||
|
|
@ -603,7 +621,7 @@ describe('ai vault resume command runtime', () => {
|
|||
resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'"
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'")
|
||||
).toBe("my-codex 'resume' 'session one'")
|
||||
})
|
||||
|
||||
it('rebuilds overridden remote commands with the recorded remote host platform', () => {
|
||||
|
|
@ -629,9 +647,7 @@ describe('ai vault resume command runtime', () => {
|
|||
'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && set ""CODEX_HOME=C:/Users/alice/.codex"" && codex resume ""session one"""'
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
"Set-Location -LiteralPath 'C:/Users/alice/repo'; $env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'"
|
||||
)
|
||||
).toBe("$env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'")
|
||||
})
|
||||
|
||||
it('ignores a stored resume command for local-host sessions', () => {
|
||||
|
|
@ -651,6 +667,6 @@ describe('ai vault resume command runtime', () => {
|
|||
resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'"
|
||||
}
|
||||
})
|
||||
).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'")
|
||||
).toBe("codex 'resume' 'session one'")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,13 +15,8 @@ import {
|
|||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import { parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell'
|
||||
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
|
||||
import {
|
||||
clearEnvCommand,
|
||||
commandSeparator,
|
||||
resolveStartupShell
|
||||
} from '../../../shared/tui-agent-startup-shell'
|
||||
import { clearEnvCommand, commandSeparator } from '../../../shared/tui-agent-startup-shell'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { AiVaultSessionDragPayload } from '@/lib/ai-vault-session-drag'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
|
|
@ -29,7 +24,10 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
|||
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
getAiVaultResumeWorkspacePath,
|
||||
resolveAiVaultResumeStartupShell
|
||||
} from '@/lib/ai-vault-resume-shell'
|
||||
|
||||
type AiVaultResumeCommandSession = Pick<
|
||||
AiVaultSession,
|
||||
|
|
@ -41,6 +39,7 @@ type AiVaultResumeCommandSession = Pick<
|
|||
|
||||
export type AiVaultResumeStartup = {
|
||||
command: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
|
|
@ -65,7 +64,7 @@ type AiVaultResumeWorktreeArgs = {
|
|||
}
|
||||
|
||||
export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWorktreeArgs): string {
|
||||
const command = buildAiVaultResumeForWorktree(args).command
|
||||
const command = buildAiVaultResumeForWorktree(args, true).command
|
||||
if (args.session.agent !== 'codex' || args.session.codexHome !== null) {
|
||||
return command
|
||||
}
|
||||
|
|
@ -80,7 +79,7 @@ export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWork
|
|||
export function buildAiVaultResumeStartupForWorktree(
|
||||
args: AiVaultResumeWorktreeArgs
|
||||
): AiVaultResumeStartup {
|
||||
return buildAiVaultResumeForWorktree(args)
|
||||
return buildAiVaultResumeForWorktree(args, false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -120,7 +119,10 @@ export function buildAiVaultDropRepinStartup(args: {
|
|||
})
|
||||
}
|
||||
|
||||
function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVaultResumeStartup {
|
||||
function buildAiVaultResumeForWorktree(
|
||||
args: AiVaultResumeWorktreeArgs,
|
||||
embedCwd: boolean
|
||||
): AiVaultResumeStartup {
|
||||
const providerSession = getAiVaultAgentProviderSession(args.session)
|
||||
if (
|
||||
args.session.executionHostId &&
|
||||
|
|
@ -151,9 +153,11 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault
|
|||
const liveShell: AgentStartupShell | undefined =
|
||||
platform === 'win32'
|
||||
? isLocalSession
|
||||
? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell)
|
||||
? resolveAiVaultResumeShell(args)
|
||||
: 'powershell'
|
||||
: undefined
|
||||
const cwd = embedCwd ? args.session.cwd : null
|
||||
const startupCwd = !embedCwd && args.session.cwd ? { cwd: args.session.cwd } : {}
|
||||
if (providerSession && isResumableTuiAgent(args.session.agent)) {
|
||||
const startupPlan = buildAgentResumeStartupPlan({
|
||||
agent: args.session.agent,
|
||||
|
|
@ -181,7 +185,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault
|
|||
agent: args.session.agent,
|
||||
sessionId: args.session.sessionId,
|
||||
resumeFilePath,
|
||||
cwd: args.session.cwd,
|
||||
cwd,
|
||||
platform,
|
||||
commandOverride: startupPlan.launchConfig.agentCommand,
|
||||
codexHome,
|
||||
|
|
@ -189,13 +193,14 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault
|
|||
})
|
||||
: buildAiVaultResumeShellCommand({
|
||||
resumeCommand: startupPlan.launchCommand,
|
||||
cwd: args.session.cwd,
|
||||
cwd,
|
||||
platform,
|
||||
codexHome,
|
||||
shell: liveShell
|
||||
}),
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
...realHomeCodexResumeEnvDeletion(args.session),
|
||||
...startupCwd,
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
providerSession
|
||||
}
|
||||
|
|
@ -210,7 +215,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault
|
|||
// forward it too — otherwise a custom OMP_CODING_AGENT_DIR / WSL-store
|
||||
// session would resume by id against the default store and miss.
|
||||
resumeFilePath,
|
||||
cwd: args.session.cwd,
|
||||
cwd,
|
||||
platform,
|
||||
commandOverride: args.commandOverride,
|
||||
codexHome,
|
||||
|
|
@ -218,6 +223,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault
|
|||
// quote for the live Windows shell like the startup-plan branch above.
|
||||
shell: liveShell
|
||||
}),
|
||||
...startupCwd,
|
||||
...realHomeCodexResumeEnvDeletion(args.session)
|
||||
}
|
||||
}
|
||||
|
|
@ -231,11 +237,12 @@ function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartu
|
|||
: getAiVaultResumePlatform(args.state, args.worktreeId)
|
||||
const isLocalSession =
|
||||
!args.session.executionHostId || args.session.executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
const shell =
|
||||
platform === 'win32' && isLocalSession
|
||||
? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell)
|
||||
: undefined
|
||||
return resolveStartupShell(platform, shell)
|
||||
return resolveAiVaultResumeStartupShell({
|
||||
state: args.state,
|
||||
worktreeId: args.worktreeId,
|
||||
platform,
|
||||
isLocalSession
|
||||
})
|
||||
}
|
||||
|
||||
export function getAiVaultAgentProviderSession(
|
||||
|
|
@ -298,26 +305,3 @@ export function getAiVaultResumePlatform(
|
|||
const workspacePath = getAiVaultResumeWorkspacePath(state, targetWorktreeId)
|
||||
return workspacePath && parseWslUncPath(workspacePath) ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
|
||||
function getAiVaultResumeWorkspacePath(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
|
||||
worktreeId: string | null | undefined
|
||||
): string | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
return (
|
||||
state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId)
|
||||
?.folderPath ?? null
|
||||
)
|
||||
}
|
||||
const targetWorktreeId =
|
||||
workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId
|
||||
return (
|
||||
Object.values(state.worktreesByRepo ?? {})
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === targetWorktreeId)?.path ?? null
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import type { AppState } from '@/store/types'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime'
|
||||
import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell'
|
||||
import {
|
||||
resolveStartupShell,
|
||||
type AgentStartupShell
|
||||
} from '../../../shared/tui-agent-startup-shell'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
|
||||
type AiVaultResumeShellState = Pick<
|
||||
AppState,
|
||||
| 'activeRepoId'
|
||||
| 'activeWorktreeId'
|
||||
| 'folderWorkspaces'
|
||||
| 'projects'
|
||||
| 'repos'
|
||||
| 'settings'
|
||||
| 'worktreesByRepo'
|
||||
>
|
||||
|
||||
export function resolveAiVaultResumeStartupShell(args: {
|
||||
state: AiVaultResumeShellState
|
||||
worktreeId?: string | null
|
||||
platform: NodeJS.Platform
|
||||
isLocalSession: boolean
|
||||
}): AgentStartupShell {
|
||||
const projectRuntime =
|
||||
args.platform === 'win32' && args.isLocalSession
|
||||
? getLocalProjectExecutionRuntimeContext(args.state, args.worktreeId, CLIENT_PLATFORM)
|
||||
: undefined
|
||||
const workspacePath = getAiVaultResumeWorkspacePath(
|
||||
args.state,
|
||||
args.worktreeId ?? args.state.activeWorktreeId
|
||||
)
|
||||
const shellOverride =
|
||||
args.platform === 'win32' && args.isLocalSession
|
||||
? resolveLocalWindowsTerminalShellOverrideForTab({
|
||||
explicitShellOverride: undefined,
|
||||
defaultWindowsShell: args.state.settings?.terminalWindowsShell,
|
||||
isWslWorktree: Boolean(workspacePath && parseWslUncPath(workspacePath)),
|
||||
projectRuntime
|
||||
})
|
||||
: undefined
|
||||
const shell = shellOverride ? resolveWindowsShellStartupFamily(shellOverride) : undefined
|
||||
return resolveStartupShell(args.platform, shell)
|
||||
}
|
||||
|
||||
export function getAiVaultResumeWorkspacePath(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
|
||||
worktreeId: string | null | undefined
|
||||
): string | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
return (
|
||||
state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId)
|
||||
?.folderPath ?? null
|
||||
)
|
||||
}
|
||||
const targetWorktreeId =
|
||||
workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId
|
||||
return (
|
||||
Object.values(state.worktreesByRepo ?? {})
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === targetWorktreeId)?.path ?? null
|
||||
)
|
||||
}
|
||||
|
|
@ -98,15 +98,20 @@ describe('launchAiVaultSessionInNewTab', () => {
|
|||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'",
|
||||
cwd: 'C:\\Users\\alice\\repo',
|
||||
env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' },
|
||||
envToDelete: ['CODEX_HOME'],
|
||||
launchConfig: {
|
||||
agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'",
|
||||
agentArgs: '--dangerously-skip-permissions --effort max',
|
||||
agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }
|
||||
}
|
||||
},
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
})
|
||||
|
||||
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
startupCwd: 'C:\\Users\\alice\\repo'
|
||||
})
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith('tab-1', {
|
||||
command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'",
|
||||
env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' },
|
||||
|
|
@ -117,6 +122,7 @@ describe('launchAiVaultSessionInNewTab', () => {
|
|||
agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }
|
||||
},
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: { key: 'session_id', id: 'session-1' },
|
||||
telemetry: {
|
||||
agent_kind: 'claude',
|
||||
launch_source: 'sidebar',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function launchAiVaultSessionInNewTab(args: {
|
|||
agent: AiVaultAgent
|
||||
worktreeId: string
|
||||
command: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
|
|
@ -40,6 +41,7 @@ export function launchAiVaultSessionInNewTab(args: {
|
|||
agentSessionKind: 'resume',
|
||||
launchAgent: args.agent,
|
||||
command: args.command,
|
||||
...(args.cwd ? { cwd: args.cwd } : {}),
|
||||
...(args.env ? { env: args.env } : {}),
|
||||
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
|
||||
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
|
||||
|
|
@ -66,12 +68,15 @@ export function launchAiVaultSessionInNewTab(args: {
|
|||
targetGroupId
|
||||
}
|
||||
|
||||
const tab = store.createTab(args.worktreeId, targetGroupId)
|
||||
const tab = args.cwd
|
||||
? store.createTab(args.worktreeId, targetGroupId, undefined, { startupCwd: args.cwd })
|
||||
: store.createTab(args.worktreeId, targetGroupId)
|
||||
store.queueTabStartupCommand(tab.id, {
|
||||
command: args.command,
|
||||
...(args.env ? { env: args.env } : {}),
|
||||
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
|
||||
...(args.launchConfig ? { launchConfig: args.launchConfig, launchAgent: args.agent } : {}),
|
||||
...(args.providerSession ? { resumeProviderSession: args.providerSession } : {}),
|
||||
telemetry: {
|
||||
agent_kind: tuiAgentToAgentKind(args.agent),
|
||||
launch_source: 'sidebar',
|
||||
|
|
|
|||
Loading…
Reference in New Issue