fix(startup): repair deferred runtime refresh paths
This commit is contained in:
parent
ef77f36b8f
commit
2be35c74ec
11 changed files with 452 additions and 93 deletions
|
|
@ -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') },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue