From cbcfad0437152c966eb8a9e7cdd228bb406f873a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:11:07 -0700 Subject: [PATCH] fix(agents): keep the global Windows runtime for local detection inside SSH workspaces The no-project fallback for the configured Windows runtime default bailed out whenever any repo or worktree was active, so an SSH workspace (or a stale active repo id) sent local agent detection to the raw Windows host. Refresh in the status bar then overwrote detectedAgentIds with the host's empty list and the usage meters vanished. Only a local project can own the runtime; defer to it, otherwise apply the global default under one shared cache key. Fixes #18837 --- .../useActiveProjectSkillRuntime.test.tsx | 34 +++++++- .../src/lib/local-preflight-context.test.ts | 43 +++++++++- .../src/lib/local-preflight-context.ts | 24 ++++-- ...ocal-detected-agents-ssh-workspace.test.ts | 79 +++++++++++++++++++ 4 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/store/slices/local-detected-agents-ssh-workspace.test.ts diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx index 2d92fc42ebd..0b76082dfb0 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx @@ -68,13 +68,39 @@ describe('useActiveProjectSkillRuntime', () => { expect(result.current.discoveryTarget).toBeUndefined() }) - it('does not adopt the global default once a project is active', () => { + it('lets an active local project override the global default', () => { setGlobalWslDefault('Ubuntu') - useAppStore.setState({ activeRepoId: 'repo-1' }) + useAppStore.setState({ + activeRepoId: 'repo-1', + repos: [{ id: 'repo-1', path: 'C:\\repo', displayName: 'r', badgeColor: 'b', addedAt: 1 }], + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] as never + }) const { result } = renderHook(() => useActiveProjectSkillRuntime()) - expect(result.current.agentRuntime).toBeUndefined() - useAppStore.setState({ activeRepoId: null }) + expect(result.current.agentRuntime).toEqual({ runtime: 'host', label: 'Windows' }) + useAppStore.setState({ activeRepoId: null, repos: [], projects: [] }) + }) + + it('keeps the global WSL default while an SSH project is active', () => { + setGlobalWslDefault('Ubuntu') + useAppStore.setState({ + activeRepoId: 'repo-ssh', + repos: [ + { + id: 'repo-ssh', + path: '/home/alice/repo', + displayName: 'r', + badgeColor: 'b', + addedAt: 1, + connectionId: 'builder', + executionHostId: 'ssh:builder' + } + ] + }) + const { result } = renderHook(() => useActiveProjectSkillRuntime()) + + expect(result.current.agentRuntime).toMatchObject({ runtime: 'wsl' }) + useAppStore.setState({ activeRepoId: null, repos: [] }) }) it('does not inject the local WSL runtime or shell into a remote environment', () => { diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 3bce56d5340..336649e2b37 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -4,6 +4,7 @@ import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import type { AppState } from '@/store/types' import { + getGlobalWindowsExecutionRuntimeContext, getLocalAgentPreflightContext, getLocalPreflightContext, getLocalProjectExecutionRuntimeContext, @@ -375,18 +376,54 @@ describe('local preflight context', () => { expect(localPreflightContextKey(context)).toBe('local-project:wsl:Ubuntu') }) - it('does not use the global runtime default for active SSH projects', () => { + it('keeps the global WSL runtime for local agent checks inside an active SSH project', () => { const state = { ...makeState({ repoPath: '/home/alice/repo', - repo: { connectionId: 'builder', executionHostId: 'ssh:builder' } + worktreePath: '/home/alice/repo', + repo: { connectionId: 'builder', executionHostId: 'ssh:builder' }, + worktree: { hostId: 'ssh:builder' } }), settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } } as unknown as AppState - expect(getLocalAgentPreflightContext(state, 'win32')).toBeUndefined() + // Why: the SSH host owns remote execution, but the local CLIs this probe + // looks for still live in the configured Windows runtime, not the raw host. + expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe( + 'local-project:wsl:Ubuntu' + ) + }) + + it('keeps the global WSL runtime when the active repo id has no repo row', () => { + const state = { + ...makeState({ repoPath: undefined }), + activeRepoId: 'ghost', + activeWorktreeId: null, + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } as unknown as AppState + + expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe( + 'local-project:wsl:Ubuntu' + ) + }) + + it('lets a local project own the runtime instead of the global default', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }], + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } as unknown as AppState + + expect(getGlobalWindowsExecutionRuntimeContext(state, undefined, 'win32')).toBeUndefined() + expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe( + 'repo-1:windows-host' + ) }) it('uses the project override over legacy agent location for local agent checks', () => { diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 7988e4656a7..587bd565a39 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -41,6 +41,9 @@ type LocalProjectRuntimeState = Pick< // or `[]` fallback would miss the cache on every read. const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} const EMPTY_REPOS: AppState['repos'] = [] +// Why: the global Windows default is one runtime regardless of which remote +// workspace is active, so every non-owned caller shares this cache key. +const GLOBAL_LOCAL_PROJECT_ID = 'local-project' type LocalProjectRuntimeWslContext = { wslAvailable?: boolean @@ -91,7 +94,7 @@ export function getLocalProjectExecutionRuntimeContext( }) } -/** Resolves the Windows default only when no project can own the runtime. */ +/** Resolves the Windows default only when no local project can own the runtime. */ export function getGlobalWindowsExecutionRuntimeContext( state: LocalProjectRuntimeState, worktreeId?: string | null, @@ -100,16 +103,22 @@ export function getGlobalWindowsExecutionRuntimeContext( ): ProjectExecutionRuntimeResolution | undefined { if ( appPlatform !== 'win32' || - worktreeId || - state.activeRepoId || - state.activeWorktreeId || + worktreeId === FLOATING_TERMINAL_WORKTREE_ID || !state.settings?.localWindowsRuntimeDefault ) { return undefined } + // Why: an SSH/runtime workspace (or a stale active id) cannot own the local + // Windows runtime, and the local CLIs still live where the default says. + // Only a local project owner may displace the global default. + const worktree = getLocalWorktree(state, worktreeId) + const repo = getLocalRuntimeRepoForWorktree(state, worktree) + if (isLocalRuntimeRepo(repo) && isLocalRuntimeWorktree(worktree)) { + return undefined + } return resolveProjectExecutionRuntime({ appPlatform: 'win32', - projectId: getLocalPreflightProjectId(state, worktreeId), + projectId: GLOBAL_LOCAL_PROJECT_ID, projectRuntimePreference: { kind: 'inherit-global' }, globalWindowsRuntimeDefault: state.settings.localWindowsRuntimeDefault, ...wslContext @@ -323,6 +332,9 @@ function getLocalPreflightProjectId( ): string { const activeWorktree = getLocalWorktree(state, worktreeId) return ( - activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project' + activeWorktree?.projectId ?? + activeWorktree?.repoId ?? + state.activeRepoId ?? + GLOBAL_LOCAL_PROJECT_ID ) } diff --git a/src/renderer/src/store/slices/local-detected-agents-ssh-workspace.test.ts b/src/renderer/src/store/slices/local-detected-agents-ssh-workspace.test.ts new file mode 100644 index 00000000000..0a73cbb0994 --- /dev/null +++ b/src/renderer/src/store/slices/local-detected-agents-ssh-workspace.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { createDetectedAgentsSlice } from './detected-agents' + +const detectAgents = vi.fn() +const refreshAgents = vi.fn() + +globalThis.window = { + api: { + preflight: { detectAgents, refreshAgents }, + platform: { get: () => ({ platform: 'win32' }) } + } as unknown as Window['api'] +} as Window & typeof globalThis + +const sshRepo = { + id: 'repo-ssh', + path: '/home/alice/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'builder', + executionHostId: 'ssh:builder' +} satisfies Repo + +const sshWorktree = { + id: 'repo-ssh::main', + repoId: 'repo-ssh', + path: '/home/alice/repo', + hostId: 'ssh:builder' +} as Worktree + +function createTestStore() { + const store = create()( + (...args) => createDetectedAgentsSlice(...args) as unknown as AppState + ) + store.setState({ + settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }, + repos: [sshRepo], + projects: [], + worktreesByRepo: { 'repo-ssh': [sshWorktree] }, + activeRepoId: 'repo-ssh', + activeWorktreeId: 'repo-ssh::main' + } as Partial) + return store +} + +describe('local detected agents inside an SSH workspace', () => { + beforeEach(() => { + // Why: mirrors the reported machine -- CLIs live in WSL, the Windows host has none. + const agentsFor = (context: { projectRuntime?: { runtime?: { kind?: string } } }) => + context?.projectRuntime?.runtime?.kind === 'wsl' ? ['claude', 'codex'] : [] + detectAgents.mockReset().mockImplementation(async (context) => agentsFor(context)) + refreshAgents.mockReset().mockImplementation(async (context) => ({ + agents: agentsFor(context), + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'shell_hydrate', + pathFailureReason: 'none' + })) + }) + + it('refreshes in the global WSL runtime instead of the raw Windows host', async () => { + const store = createTestStore() + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude', 'codex']) + await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['claude', 'codex']) + + const detectContext = detectAgents.mock.calls[0]?.[0] + expect(detectContext).toMatchObject({ + projectRuntime: { runtime: { kind: 'wsl', distro: 'Ubuntu', reason: 'global-default' } } + }) + // Why: Refresh must re-detect in the runtime the visible list came from (#18837). + expect(refreshAgents).toHaveBeenCalledWith(detectContext) + expect(store.getState().detectedAgentIds).toEqual(['claude', 'codex']) + }) +})