From 2be35c74ecd9d9776a13e25bd7c31741a24d1cbf Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 23 May 2026 17:08:13 +0300 Subject: [PATCH] fix(startup): repair deferred runtime refresh paths --- .../components/sections/ComparisonSection.vue | 4 +- .../renderer/hooks/useCodexAccountSnapshot.ts | 15 +- .../services/team/ClaudeBinaryResolver.ts | 29 ++- .../services/team/TeamMcpConfigBuilder.ts | 68 +++++- .../extensions/ExtensionStoreView.tsx | 2 - .../components/sidebar/GlobalTaskList.tsx | 35 +++ .../renderer/useCodexAccountSnapshot.test.ts | 58 ++++- .../team/ClaudeBinaryResolver.test.ts | 8 +- .../team/TeamMcpConfigBuilder.test.ts | 209 ++++++++++++------ .../extensions/ExtensionStoreView.test.ts | 39 +++- .../components/sidebar/GlobalTaskList.test.ts | 78 +++++++ 11 files changed, 452 insertions(+), 93 deletions(-) diff --git a/landing/components/sections/ComparisonSection.vue b/landing/components/sections/ComparisonSection.vue index 115ee8d6..7176fd7a 100644 --- a/landing/components/sections/ComparisonSection.vue +++ b/landing/components/sections/ComparisonSection.vue @@ -55,7 +55,7 @@ const ruNotes: Record = { '5 columns, real-time': '5 колонок, в реальном времени', 'Dashboard, not Kanban': 'Панель, не канбан', '7 columns, drag-and-drop': '7 колонок, перетаскивание', - 'Инструменты, ход рассуждений и таймлайн': 'Инструменты, ход рассуждений и таймлайн', + 'Tools, reasoning trace, and timeline': 'Инструменты, ход рассуждений и таймлайн', 'Feed, metrics, dashboard': 'Лента, метрики, панель', 'Agent chat + terminal': 'Чат агента и терминал', 'View, stop, open URLs': 'Просмотр, остановка, открытие URL', @@ -271,7 +271,7 @@ const rows = computed(() => [ }, { feature: t('comparison.features.execLog'), - us: { status: 'yes', note: note('Инструменты, ход рассуждений и таймлайн') }, + us: { status: 'yes', note: note('Tools, reasoning trace, and timeline') }, gastown: { status: 'partial', note: note('Feed, metrics, dashboard') }, paperclip: { status: 'yes', note: note('Run transcripts + audit log') }, cursor: { status: 'partial', note: note('Agent chat + terminal') }, diff --git a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts index bb191b7f..8bdad308 100644 --- a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts +++ b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts @@ -65,6 +65,9 @@ export function useCodexAccountSnapshot(options: { const [visible, setVisible] = useState(() => isDocumentVisible()); const lastUpdatedAtRef = useRef(null); const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0; + const [initialRefreshAttempted, setInitialRefreshAttempted] = useState( + () => initialRefreshDelayMs <= 0 + ); const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => { lastUpdatedAtRef.current = Date.now(); @@ -157,6 +160,7 @@ export function useCodexAccountSnapshot(options: { if (!active) { return; } + setInitialRefreshAttempted(true); setLoading(false); if (options.includeRateLimits) { setRateLimitsLoading(false); @@ -206,7 +210,12 @@ export function useCodexAccountSnapshot(options: { ? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS : CODEX_VISIBLE_STANDARD_REFRESH_MS; - if (initialRefreshDelayMs > 0 && lastUpdatedAtRef.current === null && snapshot === null) { + if ( + initialRefreshDelayMs > 0 && + lastUpdatedAtRef.current === null && + snapshot === null && + !initialRefreshAttempted + ) { return; } @@ -227,6 +236,7 @@ export function useCodexAccountSnapshot(options: { }; }, [ electronMode, + initialRefreshAttempted, initialRefreshDelayMs, options.enabled, options.includeRateLimits, @@ -238,7 +248,7 @@ export function useCodexAccountSnapshot(options: { if (!electronMode || !options.enabled) { return; } - if (initialRefreshDelayMs > 0 && snapshot === null) { + if (initialRefreshDelayMs > 0 && snapshot === null && !initialRefreshAttempted) { return; } @@ -259,6 +269,7 @@ export function useCodexAccountSnapshot(options: { }; }, [ electronMode, + initialRefreshAttempted, initialRefreshDelayMs, options.enabled, options.includeRateLimits, diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 69ee29b5..e98ea21d 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -302,6 +302,19 @@ export class ClaudeBinaryResolver { } } + const shouldTryBundledOrchestratorBeforeShell = + flavor === 'agent_teams_orchestrator' && (!overrideRaw || overrideIsExplicitPath); + if (shouldTryBundledOrchestratorBeforeShell) { + emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...'); + const bundledBinary = await resolveBundledOrchestratorBinary(); + if (bundledBinary) { + cachedPath = bundledBinary; + cacheVerifiedAt = Date.now(); + emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...'); + return cachedPath; + } + } + await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env, @@ -323,13 +336,15 @@ export class ClaudeBinaryResolver { } if (flavor === 'agent_teams_orchestrator') { - emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...'); - const bundledBinary = await resolveBundledOrchestratorBinary(); - if (bundledBinary) { - cachedPath = bundledBinary; - cacheVerifiedAt = Date.now(); - emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...'); - return cachedPath; + if (!shouldTryBundledOrchestratorBeforeShell) { + emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...'); + const bundledBinary = await resolveBundledOrchestratorBinary(); + if (bundledBinary) { + cachedPath = bundledBinary; + cacheVerifiedAt = Date.now(); + emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...'); + return cachedPath; + } } // Keep agent_teams_orchestrator resolution generic. Dev flows should diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 69d09c80..995e0cff 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -47,6 +47,9 @@ const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; +const MIN_MCP_NODE_MAJOR_VERSION = 20; +const NODE_RUNTIME_PROBE_SCRIPT = + 'process.stdout.write(JSON.stringify({execPath:process.execPath,version:process.versions.node}))'; /** * Stale configs older than this are removed on startup (best-effort). * 7 days is intentionally long: respawnAfterAuthFailure() reuses saved @@ -57,6 +60,11 @@ const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; type McpServerConfig = Record; +interface NodeRuntimeProbeMetadata { + path: string; + version: string; +} + const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local']; function isPackagedApp(): boolean { @@ -284,6 +292,56 @@ function mergePathValues(...values: (string | undefined)[]): string | undefined return merged.length > 0 ? merged.join(path.delimiter) : undefined; } +function parseNodeMajorVersion(version: string): number | null { + const match = /^v?(\d+)(?:\.|$)/.exec(version.trim()); + if (!match) { + return null; + } + + const major = Number.parseInt(match[1] ?? '', 10); + return Number.isFinite(major) ? major : null; +} + +function parseNodeRuntimeProbeMetadata(stdout: string, command: string): NodeRuntimeProbeMetadata { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error(`${command} did not report Node.js runtime metadata`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error(`${command} reported invalid Node.js runtime metadata`); + } + + if (parsed === null || typeof parsed !== 'object') { + throw new Error(`${command} reported invalid Node.js runtime metadata`); + } + + const metadata = parsed as { execPath?: unknown; version?: unknown }; + const resolvedPath = typeof metadata.execPath === 'string' ? metadata.execPath.trim() : ''; + if (!resolvedPath) { + throw new Error(`${command} did not report process.execPath`); + } + + const version = typeof metadata.version === 'string' ? metadata.version.trim() : ''; + if (!version) { + throw new Error(`${command} did not report process.versions.node`); + } + + return { path: resolvedPath, version }; +} + +function assertSupportedMcpNodeRuntime(command: string, metadata: NodeRuntimeProbeMetadata): void { + const major = parseNodeMajorVersion(metadata.version); + if (major === null || major < MIN_MCP_NODE_MAJOR_VERSION) { + throw new Error( + `${command} resolved ${metadata.path} with Node.js ${metadata.version}; Agent Teams MCP requires Node.js ${MIN_MCP_NODE_MAJOR_VERSION}+` + ); + } +} + function isWriteMcpConfigOptions(value: unknown): value is WriteMcpConfigOptions { return ( value !== null && @@ -310,16 +368,14 @@ async function probeNodeRuntimePath( let lastError: unknown = null; for (const command of getNodeRuntimeCommandCandidates()) { try { - const { stdout } = await execCli(command, ['-e', 'process.stdout.write(process.execPath)'], { + const { stdout } = await execCli(command, ['-e', NODE_RUNTIME_PROBE_SCRIPT], { encoding: 'utf-8', timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS, env, }); - const resolved = stdout.trim(); - if (!resolved) { - throw new Error(`${command} did not report process.execPath`); - } - return { ok: true, path: resolved }; + const metadata = parseNodeRuntimeProbeMetadata(stdout, command); + assertSupportedMcpNodeRuntime(command, metadata); + return { ok: true, path: metadata.path }; } catch (error) { lastError = error; } diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index c1e03f0e..0e368433 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { - CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS, mergeCodexProviderStatusWithSnapshot, useCodexAccountSnapshot, } from '@features/codex-account/renderer'; @@ -177,7 +176,6 @@ export const ExtensionStoreView = (): React.JSX.Element => { ) ), includeRateLimits: true, - initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS, }); const codexSnapshotPending = codexAccount.loading && diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 3a2ce4b2..db7d61bb 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -201,10 +201,16 @@ export const GlobalTaskList = memo(function GlobalTaskList({ globalTasksLoading, globalTasksInitialized, fetchAllTasks, + fetchProjects, + fetchRepositoryGroups, softDeleteTask, projects, + projectsLoading, + projectsError, viewMode, repositoryGroups, + repositoryGroupsLoading, + repositoryGroupsError, teams, provisioningRuns, currentProvisioningRunIdByTeam, @@ -215,10 +221,16 @@ export const GlobalTaskList = memo(function GlobalTaskList({ globalTasksLoading: s.globalTasksLoading, globalTasksInitialized: s.globalTasksInitialized, fetchAllTasks: s.fetchAllTasks, + fetchProjects: s.fetchProjects, + fetchRepositoryGroups: s.fetchRepositoryGroups, softDeleteTask: s.softDeleteTask, projects: s.projects, + projectsLoading: s.projectsLoading, + projectsError: s.projectsError, viewMode: s.viewMode, repositoryGroups: s.repositoryGroups, + repositoryGroupsLoading: s.repositoryGroupsLoading, + repositoryGroupsError: s.repositoryGroupsError, teams: s.teams, provisioningRuns: s.provisioningRuns, currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam, @@ -442,6 +454,29 @@ export const GlobalTaskList = memo(function GlobalTaskList({ } }, [fetchAllTasks, globalTasksLoading]); + useEffect(() => { + if ( + viewMode === 'grouped' && + repositoryGroups.length === 0 && + !repositoryGroupsLoading && + !repositoryGroupsError + ) { + void fetchRepositoryGroups(); + } else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) { + void fetchProjects(); + } + }, [ + fetchProjects, + fetchRepositoryGroups, + projects.length, + projectsError, + projectsLoading, + repositoryGroups.length, + repositoryGroupsError, + repositoryGroupsLoading, + viewMode, + ]); + // Build project combobox options from available projects/repos const projectFilterOptions = useMemo((): ComboboxOption[] => { const items = diff --git a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts index 7f6a2392..3a01586d 100644 --- a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts +++ b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts @@ -79,8 +79,9 @@ function createDeferred() { describe('useCodexAccountSnapshot', () => { beforeEach(() => { vi.clearAllMocks(); - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) - .IS_REACT_ACT_ENVIRONMENT = true; + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; vi.useRealTimers(); }); @@ -214,6 +215,59 @@ describe('useCodexAccountSnapshot', () => { expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled(); }); + it('keeps retrying after a deferred initial Codex snapshot fails transiently', async () => { + vi.useFakeTimers(); + const snapshot = createSnapshot(); + apiMocks.refreshCodexAccountSnapshot + .mockRejectedValueOnce(new Error('temporary Codex outage')) + .mockResolvedValueOnce(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', + null, + state.error ?? state.snapshot?.managedAccount?.email ?? 'empty' + ); + } + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1); + expect(host.textContent).toContain('temporary Codex outage'); + + await act(async () => { + vi.advanceTimersByTime(10_000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(2); + expect(host.textContent).toContain('belief@example.com'); + + act(() => { + root.unmount(); + }); + }); + it('does not run the deferred initial snapshot after a manual refresh already loaded one', async () => { vi.useFakeTimers(); Object.defineProperty(document, 'visibilityState', { diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts index b342a6e0..f8567a72 100644 --- a/test/main/services/team/ClaudeBinaryResolver.test.ts +++ b/test/main/services/team/ClaudeBinaryResolver.test.ts @@ -7,9 +7,8 @@ import type { PathLike } from 'fs'; const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>(); const mockGetShellPreferredHome = vi.fn<() => string>(); const mockGetClaudeBasePath = vi.fn<() => string>(); -const mockResolveInteractiveShellEnvBestEffort = vi.fn< - (options?: unknown) => Promise ->(); +const mockResolveInteractiveShellEnvBestEffort = + vi.fn<(options?: unknown) => Promise>(); const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>(); const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise>(); @@ -120,6 +119,7 @@ describe('ClaudeBinaryResolver', () => { await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); + expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled(); }); it('prefers the dedicated CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH override', async () => { @@ -138,6 +138,7 @@ describe('ClaudeBinaryResolver', () => { await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); + expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled(); }); it('does not wait for shell env before using an explicit absolute runtime override', async () => { @@ -249,6 +250,7 @@ describe('ClaudeBinaryResolver', () => { await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); + expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled(); }); it('finds npm-local Claude install in the vendor bin directory', async () => { diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 4b99a6e8..e6d1120d 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -21,10 +21,13 @@ const hoisted = vi.hoisted(() => ({ isPackaged: false, version: '9.9.9-test', }, - execCliMock: vi.fn(async () => ({ stdout: '/mock/node', stderr: '' })), + execCliMock: vi.fn(async () => ({ + stdout: JSON.stringify({ execPath: '/mock/node', version: '20.11.0' }), + stderr: '', + })), cachedShellEnv: null as NodeJS.ProcessEnv | null, resolveInteractiveShellEnvMock: vi.fn( - async () => ({} as NodeJS.ProcessEnv) + async () => ({}) as NodeJS.ProcessEnv ), })); @@ -65,6 +68,10 @@ import { } from '@main/services/team/TeamMcpConfigBuilder'; import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; +function nodeRuntimeProbeStdout(execPath: string, version = '20.11.0'): string { + return JSON.stringify({ execPath, version }); +} + describe('TeamMcpConfigBuilder', () => { const createdPaths: string[] = []; const createdDirs: string[] = []; @@ -95,7 +102,9 @@ describe('TeamMcpConfigBuilder', () => { function readGeneratedServer( configPath: string - ): { command?: string; args?: string[]; enabled?: boolean; env?: Record } | undefined { + ): + | { command?: string; args?: string[]; enabled?: boolean; env?: Record } + | undefined { const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw) as { mcpServers?: Record< @@ -206,7 +215,10 @@ describe('TeamMcpConfigBuilder', () => { setPackagedMode(false); setResourcesPath(undefined); hoisted.execCliMock.mockClear(); - hoisted.execCliMock.mockResolvedValue({ stdout: '/mock/node', stderr: '' }); + hoisted.execCliMock.mockResolvedValue({ + stdout: nodeRuntimeProbeStdout('/mock/node'), + stderr: '', + }); hoisted.cachedShellEnv = null; hoisted.resolveInteractiveShellEnvMock.mockClear(); hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({}); @@ -318,7 +330,7 @@ describe('TeamMcpConfigBuilder', () => { expect(readGeneratedServer(configPath)?.command).toBe('/mock/node'); expect(hoisted.execCliMock).toHaveBeenCalledWith( 'node', - ['-e', 'process.stdout.write(process.execPath)'], + ['-e', expect.stringContaining('process.versions.node')], expect.objectContaining({ encoding: 'utf-8', timeout: 5000, @@ -346,7 +358,7 @@ describe('TeamMcpConfigBuilder', () => { expect(command).toBe('node'); const env = options?.env as NodeJS.ProcessEnv | undefined; expect(env?.PATH?.split(path.delimiter)[0]).toBe('/mock-shell-node-bin'); - return { stdout: '/mock-shell-node-bin/node', stderr: '' }; + return { stdout: nodeRuntimeProbeStdout('/mock-shell-node-bin/node'), stderr: '' }; }); const builder = new TeamMcpConfigBuilder(); @@ -361,64 +373,64 @@ describe('TeamMcpConfigBuilder', () => { it.each(['linux', 'darwin', 'win32'] as const)( 'uses the packaged Electron Node runtime for %s packaged MCP launches', async (platform) => { - const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath'); - const electronBinary = - platform === 'win32' - ? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe' - : '/opt/Agent Teams AI/agent-teams-ai'; - setPackagedMode(true, '3.0.0'); - const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); - createdDirs.push(resourcesDir); - createPackagedServerBundle(resourcesDir, '// packaged server'); - setResourcesPath(resourcesDir); - hoisted.execCliMock.mockResolvedValue({ - stdout: 'agent-teams-electron-node-ok', - stderr: '', - }); - - Object.defineProperty(process, 'platform', { - value: platform, - configurable: true, - }); - Object.defineProperty(process, 'execPath', { - value: electronBinary, - configurable: true, - writable: true, - }); - - try { - const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); - const builder = new TeamMcpConfigBuilder(); - const configPath = await builder.writeConfigFile(); - createdPaths.push(configPath); - const server = readGeneratedServer(configPath); - const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js'); - - expect(launchSpec).toEqual({ - command: electronBinary, - args: [expectedEntry], - env: { ELECTRON_RUN_AS_NODE: '1' }, + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath'); + const electronBinary = + platform === 'win32' + ? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe' + : '/opt/Agent Teams AI/agent-teams-ai'; + setPackagedMode(true, '3.0.0'); + const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); + createdDirs.push(resourcesDir); + createPackagedServerBundle(resourcesDir, '// packaged server'); + setResourcesPath(resourcesDir); + hoisted.execCliMock.mockResolvedValue({ + stdout: 'agent-teams-electron-node-ok', + stderr: '', }); - expect(server?.command).toBe(electronBinary); - expect(server?.args).toEqual([expectedEntry]); - expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1'); - expect(hoisted.execCliMock).toHaveBeenCalledTimes(1); - expect(hoisted.execCliMock).toHaveBeenCalledWith( - electronBinary, - ['-e', 'process.stdout.write("agent-teams-electron-node-ok")'], - expect.objectContaining({ - env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }), - }) - ); - } finally { - if (platformDescriptor) { - Object.defineProperty(process, 'platform', platformDescriptor); + + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); + Object.defineProperty(process, 'execPath', { + value: electronBinary, + configurable: true, + writable: true, + }); + + try { + const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + const server = readGeneratedServer(configPath); + const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js'); + + expect(launchSpec).toEqual({ + command: electronBinary, + args: [expectedEntry], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + expect(server?.command).toBe(electronBinary); + expect(server?.args).toEqual([expectedEntry]); + expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1'); + expect(hoisted.execCliMock).toHaveBeenCalledTimes(1); + expect(hoisted.execCliMock).toHaveBeenCalledWith( + electronBinary, + ['-e', 'process.stdout.write("agent-teams-electron-node-ok")'], + expect.objectContaining({ + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }), + }) + ); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + if (execPathDescriptor) { + Object.defineProperty(process, 'execPath', execPathDescriptor); + } } - if (execPathDescriptor) { - Object.defineProperty(process, 'execPath', execPathDescriptor); - } - } } ); @@ -436,7 +448,7 @@ describe('TeamMcpConfigBuilder', () => { const env = options?.env as NodeJS.ProcessEnv | undefined; if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { expect(command).toBe('node'); - return { stdout: '/strict-shell-node-bin/node', stderr: '' }; + return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' }; } throw new Error(`spawn ${command} ENOENT`); }); @@ -468,7 +480,10 @@ describe('TeamMcpConfigBuilder', () => { 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: '' }); + hoisted.execCliMock.mockResolvedValue({ + stdout: nodeRuntimeProbeStdout('/fast/node'), + stderr: '', + }); try { const builder = new TeamMcpConfigBuilder(); @@ -486,6 +501,58 @@ describe('TeamMcpConfigBuilder', () => { } }); + it('falls back to strict shell env lookup when the fast Node runtime is too old', async () => { + mockBuiltWorkspaceEntryAvailable(); + const previousNodeBinary = process.env.NODE_BINARY; + const previousNpmNodeExecPath = process.env.npm_node_execpath; + const previousPath = process.env.PATH; + delete process.env.NODE_BINARY; + delete process.env.npm_node_execpath; + process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter); + hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({ + PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter), + HOME: '/Users/tester', + }); + hoisted.execCliMock.mockImplementation(async (command, _args, options) => { + const env = options?.env as NodeJS.ProcessEnv | undefined; + if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { + expect(command).toBe('node'); + return { + stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node', '20.11.0'), + stderr: '', + }; + } + return { stdout: nodeRuntimeProbeStdout('/usr/bin/node', '18.19.0'), stderr: '' }; + }); + + try { + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node'); + expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ source: 'mcp-node-runtime' }) + ); + } finally { + if (previousNodeBinary === undefined) { + delete process.env.NODE_BINARY; + } else { + process.env.NODE_BINARY = previousNodeBinary; + } + if (previousNpmNodeExecPath === undefined) { + delete process.env.npm_node_execpath; + } else { + process.env.npm_node_execpath = previousNpmNodeExecPath; + } + 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({ @@ -497,7 +564,7 @@ describe('TeamMcpConfigBuilder', () => { const env = options?.env as NodeJS.ProcessEnv | undefined; if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { expect(command).toBe('node'); - return { stdout: '/strict-shell-node-bin/node', stderr: '' }; + return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' }; } if (!returnedEmptyPath) { returnedEmptyPath = true; @@ -522,7 +589,7 @@ describe('TeamMcpConfigBuilder', () => { process.env.NODE_BINARY = '/explicit/node'; hoisted.execCliMock.mockImplementationOnce(async (command) => { expect(command).toBe('/explicit/node'); - return { stdout: '/explicit/node', stderr: '' }; + return { stdout: nodeRuntimeProbeStdout('/explicit/node'), stderr: '' }; }); try { @@ -665,7 +732,10 @@ describe('TeamMcpConfigBuilder', () => { createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { - mcpServers: Record }>; + mcpServers: Record< + string, + { command?: string; args?: string[]; enabled?: boolean; env?: Record } + >; }; expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); @@ -766,7 +836,10 @@ describe('TeamMcpConfigBuilder', () => { createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { - mcpServers: Record; + mcpServers: Record< + string, + { command?: string; args?: string[]; type?: string; url?: string } + >; }; expect(Object.keys(parsed.mcpServers).sort()).toEqual(['agent-teams', 'github', 'linear']); diff --git a/test/renderer/components/extensions/ExtensionStoreView.test.ts b/test/renderer/components/extensions/ExtensionStoreView.test.ts index 2f4e28dc..cabb7da6 100644 --- a/test/renderer/components/extensions/ExtensionStoreView.test.ts +++ b/test/renderer/components/extensions/ExtensionStoreView.test.ts @@ -45,6 +45,10 @@ const codexAccountHookState = { const pluginsPanelSpy = vi.fn(); const mcpServersPanelSpy = vi.fn(); const customMcpDialogSpy = vi.fn(); +const useCodexAccountSnapshotSpy = vi.fn( + (_options: { enabled: boolean; includeRateLimits?: boolean; initialRefreshDelayMs?: number }) => + codexAccountHookState +); vi.mock('@renderer/store', () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), @@ -67,7 +71,11 @@ vi.mock('@features/codex-account/renderer', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useCodexAccountSnapshot: () => codexAccountHookState, + useCodexAccountSnapshot: (options: { + enabled: boolean; + includeRateLimits?: boolean; + initialRefreshDelayMs?: number; + }) => useCodexAccountSnapshotSpy(options), }; }); @@ -296,6 +304,7 @@ describe('ExtensionStoreView provider loading placeholders', () => { pluginsPanelSpy.mockReset(); mcpServersPanelSpy.mockReset(); customMcpDialogSpy.mockReset(); + useCodexAccountSnapshotSpy.mockClear(); codexAccountHookState.snapshot = null; codexAccountHookState.loading = false; codexAccountHookState.error = null; @@ -367,6 +376,34 @@ describe('ExtensionStoreView provider loading placeholders', () => { }); }); + it('does not defer Codex account refresh again after the lazy Extensions tab mounts', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExtensionStoreView)); + await Promise.resolve(); + await Promise.resolve(); + }); + + const lastOptions = useCodexAccountSnapshotSpy.mock.calls.at(-1)?.[0] as + | { enabled?: boolean; includeRateLimits?: boolean; initialRefreshDelayMs?: number } + | undefined; + expect(lastOptions).toEqual( + expect.objectContaining({ + enabled: true, + includeRateLimits: true, + }) + ); + expect(lastOptions?.initialRefreshDelayMs).toBeUndefined(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('falls back to legacy refresh when multimodel is disabled', async () => { storeState.appConfig = { general: { diff --git a/test/renderer/components/sidebar/GlobalTaskList.test.ts b/test/renderer/components/sidebar/GlobalTaskList.test.ts index 659ebc8c..e0651516 100644 --- a/test/renderer/components/sidebar/GlobalTaskList.test.ts +++ b/test/renderer/components/sidebar/GlobalTaskList.test.ts @@ -10,8 +10,12 @@ interface StoreState { globalTasksLoading: boolean; globalTasksInitialized: boolean; fetchAllTasks: ReturnType; + fetchProjects: ReturnType; + fetchRepositoryGroups: ReturnType; softDeleteTask: ReturnType; projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[]; + projectsLoading: boolean; + projectsError: string | null; viewMode: 'flat' | 'grouped'; repositoryGroups: { id: string; @@ -19,6 +23,8 @@ interface StoreState { totalSessions: number; worktrees: { path: string }[]; }[]; + repositoryGroupsLoading: boolean; + repositoryGroupsError: string | null; teams: (Pick & Partial)[]; provisioningRuns: Record; currentProvisioningRunIdByTeam: Record; @@ -205,10 +211,16 @@ describe('GlobalTaskList project grouping', () => { storeState.globalTasksLoading = false; storeState.globalTasksInitialized = true; storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined)); + storeState.fetchProjects = vi.fn(() => Promise.resolve(undefined)); + storeState.fetchRepositoryGroups = vi.fn(() => Promise.resolve(undefined)); storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined)); storeState.projects = []; + storeState.projectsLoading = false; + storeState.projectsError = null; storeState.viewMode = 'flat'; storeState.repositoryGroups = []; + storeState.repositoryGroupsLoading = false; + storeState.repositoryGroupsError = null; storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }]; storeState.provisioningRuns = {}; storeState.currentProvisioningRunIdByTeam = {}; @@ -232,6 +244,72 @@ describe('GlobalTaskList project grouping', () => { storeListeners.clear(); }); + it('fetches repository groups when grouped project filter data is missing', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.viewMode = 'grouped'; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(storeState.fetchRepositoryGroups).toHaveBeenCalledTimes(1); + expect(storeState.fetchProjects).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('fetches flat projects when flat project filter data is missing', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(storeState.fetchProjects).toHaveBeenCalledTimes(1); + expect(storeState.fetchRepositoryGroups).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('does not duplicate project filter data fetches while a repository fetch is already pending', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.viewMode = 'grouped'; + storeState.repositoryGroupsLoading = true; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(storeState.fetchRepositoryGroups).not.toHaveBeenCalled(); + expect(storeState.fetchProjects).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + it('shows five tasks first, then expands and collapses with Show more and Show less', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1));