diff --git a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts index b9b18c11..bb191b7f 100644 --- a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts +++ b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts @@ -11,6 +11,7 @@ const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000; const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000; const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000; const CODEX_HIDDEN_REFRESH_MS = 60_000; +export const CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS = 30_000; function isDocumentVisible(): boolean { if (typeof document === 'undefined') { @@ -41,6 +42,7 @@ function getRefreshIntervalMs(options: { export function useCodexAccountSnapshot(options: { enabled: boolean; includeRateLimits?: boolean; + initialRefreshDelayMs?: number; }): { snapshot: CodexAccountSnapshotDto | null; loading: boolean; @@ -62,6 +64,7 @@ export function useCodexAccountSnapshot(options: { const [error, setError] = useState(null); const [visible, setVisible] = useState(() => isDocumentVisible()); const lastUpdatedAtRef = useRef(null); + const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0; const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => { lastUpdatedAtRef.current = Date.now(); @@ -117,38 +120,74 @@ export function useCodexAccountSnapshot(options: { return; } - setLoading(true); - if (options.includeRateLimits) { - setRateLimitsLoading(true); - } - setError(null); + let active = true; + let initialRefreshTimer: number | null = null; - const initialSnapshotRequest = options.includeRateLimits - ? api.refreshCodexAccountSnapshot({ - includeRateLimits: true, + const startInitialSnapshotRequest = (): void => { + if (!active || lastUpdatedAtRef.current !== null) { + return; + } + + setLoading(true); + if (options.includeRateLimits) { + setRateLimitsLoading(true); + } + setError(null); + + const initialSnapshotRequest = options.includeRateLimits + ? api.refreshCodexAccountSnapshot({ + includeRateLimits: true, + }) + : api.getCodexAccountSnapshot(); + + void initialSnapshotRequest + .then((nextSnapshot) => { + if (active) { + applySnapshot(nextSnapshot); + } }) - : api.getCodexAccountSnapshot(); + .catch((nextError) => { + if (active) { + setError( + nextError instanceof Error ? nextError.message : 'Failed to load Codex account' + ); + } + }) + .finally(() => { + if (!active) { + return; + } + setLoading(false); + if (options.includeRateLimits) { + setRateLimitsLoading(false); + } + }); + }; - void initialSnapshotRequest - .then((nextSnapshot) => { - applySnapshot(nextSnapshot); - }) - .catch((nextError) => { - setError(nextError instanceof Error ? nextError.message : 'Failed to load Codex account'); - }) - .finally(() => { - setLoading(false); - if (options.includeRateLimits) { - setRateLimitsLoading(false); - } - }); + if (initialRefreshDelayMs > 0) { + initialRefreshTimer = window.setTimeout(startInitialSnapshotRequest, initialRefreshDelayMs); + } else { + startInitialSnapshotRequest(); + } const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => { applySnapshot(nextSnapshot); }); - return unsubscribe; - }, [applySnapshot, electronMode, options.enabled, options.includeRateLimits]); + return () => { + active = false; + if (initialRefreshTimer) { + window.clearTimeout(initialRefreshTimer); + } + unsubscribe(); + }; + }, [ + applySnapshot, + electronMode, + initialRefreshDelayMs, + options.enabled, + options.includeRateLimits, + ]); useEffect(() => { if (!electronMode || !options.enabled || typeof document === 'undefined') { @@ -167,6 +206,10 @@ export function useCodexAccountSnapshot(options: { ? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS : CODEX_VISIBLE_STANDARD_REFRESH_MS; + if (initialRefreshDelayMs > 0 && lastUpdatedAtRef.current === null && snapshot === null) { + return; + } + if ( lastUpdatedAtRef.current === null || Date.now() - lastUpdatedAtRef.current >= staleAfterMs @@ -182,12 +225,22 @@ export function useCodexAccountSnapshot(options: { return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [electronMode, options.enabled, options.includeRateLimits, refresh]); + }, [ + electronMode, + initialRefreshDelayMs, + options.enabled, + options.includeRateLimits, + refresh, + snapshot, + ]); useEffect(() => { if (!electronMode || !options.enabled) { return; } + if (initialRefreshDelayMs > 0 && snapshot === null) { + return; + } const refreshIntervalMs = getRefreshIntervalMs({ loginStatus: snapshot?.login.status, @@ -206,9 +259,11 @@ export function useCodexAccountSnapshot(options: { }; }, [ electronMode, + initialRefreshDelayMs, options.enabled, options.includeRateLimits, refresh, + snapshot, snapshot?.login.status, visible, ]); diff --git a/src/features/codex-account/renderer/index.ts b/src/features/codex-account/renderer/index.ts index 70d2e307..3cfd5851 100644 --- a/src/features/codex-account/renderer/index.ts +++ b/src/features/codex-account/renderer/index.ts @@ -1,4 +1,7 @@ -export { useCodexAccountSnapshot } from './hooks/useCodexAccountSnapshot'; +export { + CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS, + useCodexAccountSnapshot, +} from './hooks/useCodexAccountSnapshot'; export { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot'; export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot'; export { diff --git a/src/main/index.ts b/src/main/index.ts index 5ac5bf6d..5c735e63 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -354,10 +354,18 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str return 'The current review request is still waiting for explicit review pickup.'; } -async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise { - const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath(); +async function resolveOpenCodeRuntimeBinaryForBridgeEnv(options?: { + includeShellEnv?: boolean; +}): Promise { + const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath({ + includeShellEnv: options?.includeShellEnv, + }); if (resolvedBinaryPath) return resolvedBinaryPath; + if (options?.includeShellEnv === false) { + return null; + } + try { const status = await openCodeRuntimeInstallerService?.getStatus(); return status?.installed === true && status.binaryPath ? status.binaryPath : null; @@ -448,15 +456,18 @@ async function createOpenCodeRuntimeAdapterRegistry( copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv); } }; - const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise => { + const ensureOpenCodeRuntimeBinaryEnv = async ( + targetEnv: NodeJS.ProcessEnv, + options: { includeShellEnv?: boolean } = {} + ): Promise => { await ensureOpenCodeBridgeRuntimeBinaryEnv({ targetEnv, bridgeEnv, - resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv, + resolveVerifiedOpenCodeRuntimeBinaryPath: () => + resolveOpenCodeRuntimeBinaryForBridgeEnv({ includeShellEnv: options.includeShellEnv }), onWarning: (message) => logger.warn(message), }); }; - await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv); try { reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...'); const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ @@ -500,7 +511,7 @@ async function createOpenCodeRuntimeAdapterRegistry( reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); const resolveBridgeCommandEnv = async (): Promise => { const nextEnv = { ...bridgeEnv }; - await ensureOpenCodeRuntimeBinaryEnv(nextEnv); + await ensureOpenCodeRuntimeBinaryEnv(nextEnv, { includeShellEnv: true }); if (!useHttpMcpBridge) { return nextEnv; } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 73de41f6..a8bf195d 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -1030,13 +1030,17 @@ export class CliInstallerService { providerStatusMode: CliInstallerProviderStatusMode ): Promise { resetGatherDiag(diag); - const shellEnvStartedAt = Date.now(); - await resolveInteractiveShellEnvBestEffort({ - timeoutMs: 1_500, - fallbackEnv: process.env, - background: false, - }); - diag.shellEnvMs = Date.now() - shellEnvStartedAt; + if (providerStatusMode === 'defer') { + diag.shellEnvMs = 0; + } else { + const shellEnvStartedAt = Date.now(); + await resolveInteractiveShellEnvBestEffort({ + timeoutMs: 1_500, + fallbackEnv: process.env, + background: false, + }); + diag.shellEnvMs = Date.now() - shellEnvStartedAt; + } const r = ref.current; const binaryResolveStartedAt = Date.now(); diff --git a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts index 8ed11f66..fef3a0cb 100644 --- a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts +++ b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts @@ -212,8 +212,13 @@ async function probeFirstWorkingOpenCodeBinaryCandidate( return { ok: false, firstFailure: nextFirstFailure }; } +interface OpenCodeRuntimeBinaryResolveOptions { + shellEnvTimeoutMs?: number; + includeShellEnv?: boolean; +} + async function probeFirstWorkingPathOpenCodeBinary( - options: { shellEnvTimeoutMs?: number } = {} + options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { const seenCandidates = new Set(); let firstFailure: { binaryPath: string; error: string } | null = null; @@ -230,6 +235,16 @@ async function probeFirstWorkingPathOpenCodeBinary( } firstFailure = cachedProbe.firstFailure; + if (options.includeShellEnv === false) { + return probeFirstWorkingOpenCodeBinaryCandidate( + collectPathOpenCodeBinaryCandidates([], { + includeFallbackPathEntries: true, + }), + seenCandidates, + firstFailure + ); + } + const shellEnv = await resolveInteractiveShellEnvBestEffort({ timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS, fallbackEnv: process.env, @@ -254,14 +269,14 @@ async function probeFirstWorkingPathOpenCodeBinary( } async function resolveVerifiedPathOpenCodeBinaryPath( - options: { shellEnvTimeoutMs?: number } = {} + options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { const result = await probeFirstWorkingPathOpenCodeBinary(options); return result.ok ? result.binaryPath : null; } export async function resolveVerifiedOpenCodeRuntimeBinaryPath( - options: { shellEnvTimeoutMs?: number } = {} + options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { return ( (await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ?? diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index a4a3e95e..69d09c80 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -386,15 +386,6 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise { loadingCliStatus?.flavor === 'agent_teams_orchestrator' && Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), includeRateLimits: false, + initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS, }); const effectiveCliStatus = useMemo( diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index d1670d7c..4b35b89f 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { + CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS, mergeCodexProviderStatusWithSnapshot, useCodexAccountSnapshot, } from '@features/codex-account/renderer'; @@ -1358,6 +1359,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { loadingCliStatus?.flavor === 'agent_teams_orchestrator' && Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), includeRateLimits: true, + initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS, }); const visibleCliProviders = useMemo( () => diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 0e368433..c1e03f0e 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { + CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS, mergeCodexProviderStatusWithSnapshot, useCodexAccountSnapshot, } from '@features/codex-account/renderer'; @@ -176,6 +177,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { ) ), includeRateLimits: true, + initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS, }); const codexSnapshotPending = codexAccount.loading && diff --git a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts index 53f27dcc..7f6a2392 100644 --- a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts +++ b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts @@ -1,5 +1,6 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot'; @@ -124,9 +125,146 @@ describe('useCodexAccountSnapshot', () => { }); }); + it('can defer the initial Codex snapshot without starting interval refreshes first', async () => { + vi.useFakeTimers(); + const snapshot = createSnapshot(); + apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + function Harness(): React.ReactElement { + const state = useCodexAccountSnapshot({ + enabled: true, + includeRateLimits: true, + initialRefreshDelayMs: 30_000, + }); + + return React.createElement( + 'div', + { 'data-loading': state.loading ? 'true' : 'false' }, + state.snapshot?.managedAccount?.email ?? 'empty' + ); + } + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled(); + expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false'); + + await act(async () => { + vi.advanceTimersByTime(20_000); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(10_000); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1); + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({ + includeRateLimits: true, + }); + expect(host.textContent).toContain('belief@example.com'); + + act(() => { + root.unmount(); + }); + }); + + it('clears a deferred initial Codex snapshot timer on unmount', async () => { + vi.useFakeTimers(); + apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(createSnapshot()); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + function Harness(): React.ReactElement { + useCodexAccountSnapshot({ + enabled: true, + includeRateLimits: true, + initialRefreshDelayMs: 30_000, + }); + + return React.createElement('div', null, 'mounted'); + } + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + act(() => { + root.unmount(); + }); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled(); + }); + + it('does not run the deferred initial snapshot after a manual refresh already loaded one', async () => { + vi.useFakeTimers(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden' satisfies DocumentVisibilityState, + }); + const snapshot = createSnapshot(); + apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot); + let refreshNow!: () => Promise; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + function Harness(): React.ReactElement { + const state = useCodexAccountSnapshot({ + enabled: true, + includeRateLimits: true, + initialRefreshDelayMs: 30_000, + }); + refreshNow = () => state.refresh({ includeRateLimits: true }); + + return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty'); + } + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + await refreshNow(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => { vi.useFakeTimers(); - let visibilityState: DocumentVisibilityState = 'visible'; + const visibilityState: DocumentVisibilityState = 'visible'; Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibilityState, diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 22ccd333..4ba73b75 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { realpathMock } = vi.hoisted(() => ({ +const { realpathMock, resolveInteractiveShellEnvBestEffortMock } = vi.hoisted(() => ({ realpathMock: vi.fn(async (value: string) => value), + resolveInteractiveShellEnvBestEffortMock: vi.fn( + async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env + ), })); // Mock dependencies before importing service @@ -83,6 +86,14 @@ vi.mock('@main/utils/cliAuthDiagLog', () => ({ appendCliAuthDiag: vi.fn(() => Promise.resolve(null)), })); +vi.mock('@main/utils/shellEnv', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock, + }; +}); + import { CliInstallerService, isVersionOlder, @@ -148,6 +159,9 @@ describe('CliInstallerService', () => { vi.clearAllMocks(); realpathMock.mockReset(); realpathMock.mockImplementation(async (value: string) => value); + resolveInteractiveShellEnvBestEffortMock.mockImplementation( + async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env + ); vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude'); vi.mocked(getCliFlavorUiOptions).mockReturnValue({ displayName: 'Claude CLI', @@ -169,6 +183,13 @@ describe('CliInstallerService', () => { expect(status.installedVersion).toBeNull(); expect(status.binaryPath).toBeNull(); expect(status.updateAvailable).toBe(false); + expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 1_500, + fallbackEnv: process.env, + background: false, + }) + ); }); it('does not block getStatus on diagnostic file writes', async () => { @@ -356,6 +377,7 @@ describe('CliInstallerService', () => { .filter((event) => event.type === 'status'); expect(status.installed).toBe(true); + expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled(); expect(status.authStatusChecking).toBe(false); expect(status.authLoggedIn).toBe(false); expect(status.providers).toHaveLength(3); diff --git a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts index e822b75c..563d6e49 100644 --- a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts +++ b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts @@ -243,6 +243,36 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { }); }); + it('resolves from fast fallback PATH without spawning shell env when shell env is disabled', async () => { + const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode'); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath)); + + await expect( + resolveVerifiedOpenCodeRuntimeBinaryPath({ includeShellEnv: false }) + ).resolves.toBe(binaryPath); + expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled(); + expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], { + timeout: 10_000, + windowsHide: true, + }); + }); + + it('does not spawn shell env for shell-only PATH installs when shell env is disabled', async () => { + const binaryPath = path.join(tempRoot!, 'custom-npm-prefix', 'bin', 'opencode'); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ + PATH: path.dirname(binaryPath), + }); + + await expect( + resolveVerifiedOpenCodeRuntimeBinaryPath({ includeShellEnv: false }) + ).resolves.toBeNull(); + expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled(); + }); + it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => { const olderBinaryPath = path.join( tempRoot!, diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 1681e5b8..4b99a6e8 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -464,6 +464,28 @@ describe('TeamMcpConfigBuilder', () => { } }); + it('skips strict shell env lookup when fast Node lookup succeeds from a minimal GUI PATH', async () => { + mockBuiltWorkspaceEntryAvailable(); + const previousPath = process.env.PATH; + process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter); + hoisted.execCliMock.mockResolvedValue({ stdout: '/fast/node', stderr: '' }); + + try { + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + expect(readGeneratedServer(configPath)?.command).toBe('/fast/node'); + expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); + } finally { + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } + } + }); + it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => { mockBuiltWorkspaceEntryAvailable(); hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({