fix(startup): repair deferred runtime refresh paths

This commit is contained in:
777genius 2026-05-23 17:08:13 +03:00
parent ef77f36b8f
commit 2be35c74ec
11 changed files with 452 additions and 93 deletions

View file

@ -55,7 +55,7 @@ const ruNotes: Record<string, string> = {
'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<ComparisonRow[]>(() => [
},
{
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') },

View file

@ -65,6 +65,9 @@ export function useCodexAccountSnapshot(options: {
const [visible, setVisible] = useState(() => isDocumentVisible());
const lastUpdatedAtRef = useRef<number | null>(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,

View file

@ -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

View file

@ -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<string, unknown>;
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;
}

View file

@ -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 &&

View file

@ -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 =

View file

@ -79,8 +79,9 @@ function createDeferred<T>() {
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', {

View file

@ -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<NodeJS.ProcessEnv>
>();
const mockResolveInteractiveShellEnvBestEffort =
vi.fn<(options?: unknown) => Promise<NodeJS.ProcessEnv>>();
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>();
const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>();
@ -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 () => {

View file

@ -21,10 +21,13 @@ const hoisted = vi.hoisted(() => ({
isPackaged: false,
version: '9.9.9-test',
},
execCliMock: vi.fn<ExecCliMock>(async () => ({ stdout: '/mock/node', stderr: '' })),
execCliMock: vi.fn<ExecCliMock>(async () => ({
stdout: JSON.stringify({ execPath: '/mock/node', version: '20.11.0' }),
stderr: '',
})),
cachedShellEnv: null as NodeJS.ProcessEnv | null,
resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>(
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<string, string> } | undefined {
):
| { command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }
| 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<string, { command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }>;
mcpServers: Record<
string,
{ command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }
>;
};
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<string, { command?: string; args?: string[]; type?: string; url?: string }>;
mcpServers: Record<
string,
{ command?: string; args?: string[]; type?: string; url?: string }
>;
};
expect(Object.keys(parsed.mcpServers).sort()).toEqual(['agent-teams', 'github', 'linear']);

View file

@ -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<typeof import('@features/codex-account/renderer')>();
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: {

View file

@ -10,8 +10,12 @@ interface StoreState {
globalTasksLoading: boolean;
globalTasksInitialized: boolean;
fetchAllTasks: ReturnType<typeof vi.fn>;
fetchProjects: ReturnType<typeof vi.fn>;
fetchRepositoryGroups: ReturnType<typeof vi.fn>;
softDeleteTask: ReturnType<typeof vi.fn>;
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<TeamSummary, 'teamName' | 'displayName'> & Partial<TeamSummary>)[];
provisioningRuns: Record<string, { state: string; runId: string; updatedAt: string }>;
currentProvisioningRunIdByTeam: Record<string, string | null>;
@ -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));