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 колонок, в реальном времени',
|
'5 columns, real-time': '5 колонок, в реальном времени',
|
||||||
'Dashboard, not Kanban': 'Панель, не канбан',
|
'Dashboard, not Kanban': 'Панель, не канбан',
|
||||||
'7 columns, drag-and-drop': '7 колонок, перетаскивание',
|
'7 columns, drag-and-drop': '7 колонок, перетаскивание',
|
||||||
'Инструменты, ход рассуждений и таймлайн': 'Инструменты, ход рассуждений и таймлайн',
|
'Tools, reasoning trace, and timeline': 'Инструменты, ход рассуждений и таймлайн',
|
||||||
'Feed, metrics, dashboard': 'Лента, метрики, панель',
|
'Feed, metrics, dashboard': 'Лента, метрики, панель',
|
||||||
'Agent chat + terminal': 'Чат агента и терминал',
|
'Agent chat + terminal': 'Чат агента и терминал',
|
||||||
'View, stop, open URLs': 'Просмотр, остановка, открытие URL',
|
'View, stop, open URLs': 'Просмотр, остановка, открытие URL',
|
||||||
|
|
@ -271,7 +271,7 @@ const rows = computed<ComparisonRow[]>(() => [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
feature: t('comparison.features.execLog'),
|
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') },
|
gastown: { status: 'partial', note: note('Feed, metrics, dashboard') },
|
||||||
paperclip: { status: 'yes', note: note('Run transcripts + audit log') },
|
paperclip: { status: 'yes', note: note('Run transcripts + audit log') },
|
||||||
cursor: { status: 'partial', note: note('Agent chat + terminal') },
|
cursor: { status: 'partial', note: note('Agent chat + terminal') },
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,9 @@ export function useCodexAccountSnapshot(options: {
|
||||||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||||
const lastUpdatedAtRef = useRef<number | null>(null);
|
const lastUpdatedAtRef = useRef<number | null>(null);
|
||||||
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
||||||
|
const [initialRefreshAttempted, setInitialRefreshAttempted] = useState(
|
||||||
|
() => initialRefreshDelayMs <= 0
|
||||||
|
);
|
||||||
|
|
||||||
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
||||||
lastUpdatedAtRef.current = Date.now();
|
lastUpdatedAtRef.current = Date.now();
|
||||||
|
|
@ -157,6 +160,7 @@ export function useCodexAccountSnapshot(options: {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setInitialRefreshAttempted(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (options.includeRateLimits) {
|
if (options.includeRateLimits) {
|
||||||
setRateLimitsLoading(false);
|
setRateLimitsLoading(false);
|
||||||
|
|
@ -206,7 +210,12 @@ export function useCodexAccountSnapshot(options: {
|
||||||
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
|
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
|
||||||
: CODEX_VISIBLE_STANDARD_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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +236,7 @@ export function useCodexAccountSnapshot(options: {
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
electronMode,
|
electronMode,
|
||||||
|
initialRefreshAttempted,
|
||||||
initialRefreshDelayMs,
|
initialRefreshDelayMs,
|
||||||
options.enabled,
|
options.enabled,
|
||||||
options.includeRateLimits,
|
options.includeRateLimits,
|
||||||
|
|
@ -238,7 +248,7 @@ export function useCodexAccountSnapshot(options: {
|
||||||
if (!electronMode || !options.enabled) {
|
if (!electronMode || !options.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (initialRefreshDelayMs > 0 && snapshot === null) {
|
if (initialRefreshDelayMs > 0 && snapshot === null && !initialRefreshAttempted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,6 +269,7 @@ export function useCodexAccountSnapshot(options: {
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
electronMode,
|
electronMode,
|
||||||
|
initialRefreshAttempted,
|
||||||
initialRefreshDelayMs,
|
initialRefreshDelayMs,
|
||||||
options.enabled,
|
options.enabled,
|
||||||
options.includeRateLimits,
|
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({
|
await resolveInteractiveShellEnvBestEffort({
|
||||||
timeoutMs: 1_500,
|
timeoutMs: 1_500,
|
||||||
fallbackEnv: process.env,
|
fallbackEnv: process.env,
|
||||||
|
|
@ -323,13 +336,15 @@ export class ClaudeBinaryResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flavor === 'agent_teams_orchestrator') {
|
if (flavor === 'agent_teams_orchestrator') {
|
||||||
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
|
if (!shouldTryBundledOrchestratorBeforeShell) {
|
||||||
const bundledBinary = await resolveBundledOrchestratorBinary();
|
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
|
||||||
if (bundledBinary) {
|
const bundledBinary = await resolveBundledOrchestratorBinary();
|
||||||
cachedPath = bundledBinary;
|
if (bundledBinary) {
|
||||||
cacheVerifiedAt = Date.now();
|
cachedPath = bundledBinary;
|
||||||
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
|
cacheVerifiedAt = Date.now();
|
||||||
return cachedPath;
|
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
|
||||||
|
return cachedPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep agent_teams_orchestrator resolution generic. Dev flows should
|
// 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 MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
|
||||||
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
||||||
const ELECTRON_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).
|
* Stale configs older than this are removed on startup (best-effort).
|
||||||
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
|
* 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>;
|
type McpServerConfig = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface NodeRuntimeProbeMetadata {
|
||||||
|
path: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local'];
|
const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local'];
|
||||||
|
|
||||||
function isPackagedApp(): boolean {
|
function isPackagedApp(): boolean {
|
||||||
|
|
@ -284,6 +292,56 @@ function mergePathValues(...values: (string | undefined)[]): string | undefined
|
||||||
return merged.length > 0 ? merged.join(path.delimiter) : 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 {
|
function isWriteMcpConfigOptions(value: unknown): value is WriteMcpConfigOptions {
|
||||||
return (
|
return (
|
||||||
value !== null &&
|
value !== null &&
|
||||||
|
|
@ -310,16 +368,14 @@ async function probeNodeRuntimePath(
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
for (const command of getNodeRuntimeCommandCandidates()) {
|
for (const command of getNodeRuntimeCommandCandidates()) {
|
||||||
try {
|
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',
|
encoding: 'utf-8',
|
||||||
timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS,
|
timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS,
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
const resolved = stdout.trim();
|
const metadata = parseNodeRuntimeProbeMetadata(stdout, command);
|
||||||
if (!resolved) {
|
assertSupportedMcpNodeRuntime(command, metadata);
|
||||||
throw new Error(`${command} did not report process.execPath`);
|
return { ok: true, path: metadata.path };
|
||||||
}
|
|
||||||
return { ok: true, path: resolved };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
|
||||||
mergeCodexProviderStatusWithSnapshot,
|
mergeCodexProviderStatusWithSnapshot,
|
||||||
useCodexAccountSnapshot,
|
useCodexAccountSnapshot,
|
||||||
} from '@features/codex-account/renderer';
|
} from '@features/codex-account/renderer';
|
||||||
|
|
@ -177,7 +176,6 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
includeRateLimits: true,
|
includeRateLimits: true,
|
||||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
|
||||||
});
|
});
|
||||||
const codexSnapshotPending =
|
const codexSnapshotPending =
|
||||||
codexAccount.loading &&
|
codexAccount.loading &&
|
||||||
|
|
|
||||||
|
|
@ -201,10 +201,16 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
globalTasksLoading,
|
globalTasksLoading,
|
||||||
globalTasksInitialized,
|
globalTasksInitialized,
|
||||||
fetchAllTasks,
|
fetchAllTasks,
|
||||||
|
fetchProjects,
|
||||||
|
fetchRepositoryGroups,
|
||||||
softDeleteTask,
|
softDeleteTask,
|
||||||
projects,
|
projects,
|
||||||
|
projectsLoading,
|
||||||
|
projectsError,
|
||||||
viewMode,
|
viewMode,
|
||||||
repositoryGroups,
|
repositoryGroups,
|
||||||
|
repositoryGroupsLoading,
|
||||||
|
repositoryGroupsError,
|
||||||
teams,
|
teams,
|
||||||
provisioningRuns,
|
provisioningRuns,
|
||||||
currentProvisioningRunIdByTeam,
|
currentProvisioningRunIdByTeam,
|
||||||
|
|
@ -215,10 +221,16 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
globalTasksLoading: s.globalTasksLoading,
|
globalTasksLoading: s.globalTasksLoading,
|
||||||
globalTasksInitialized: s.globalTasksInitialized,
|
globalTasksInitialized: s.globalTasksInitialized,
|
||||||
fetchAllTasks: s.fetchAllTasks,
|
fetchAllTasks: s.fetchAllTasks,
|
||||||
|
fetchProjects: s.fetchProjects,
|
||||||
|
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||||
softDeleteTask: s.softDeleteTask,
|
softDeleteTask: s.softDeleteTask,
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
|
projectsLoading: s.projectsLoading,
|
||||||
|
projectsError: s.projectsError,
|
||||||
viewMode: s.viewMode,
|
viewMode: s.viewMode,
|
||||||
repositoryGroups: s.repositoryGroups,
|
repositoryGroups: s.repositoryGroups,
|
||||||
|
repositoryGroupsLoading: s.repositoryGroupsLoading,
|
||||||
|
repositoryGroupsError: s.repositoryGroupsError,
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
provisioningRuns: s.provisioningRuns,
|
provisioningRuns: s.provisioningRuns,
|
||||||
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
|
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
|
||||||
|
|
@ -442,6 +454,29 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
}
|
}
|
||||||
}, [fetchAllTasks, globalTasksLoading]);
|
}, [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
|
// Build project combobox options from available projects/repos
|
||||||
const projectFilterOptions = useMemo((): ComboboxOption[] => {
|
const projectFilterOptions = useMemo((): ComboboxOption[] => {
|
||||||
const items =
|
const items =
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,9 @@ function createDeferred<T>() {
|
||||||
describe('useCodexAccountSnapshot', () => {
|
describe('useCodexAccountSnapshot', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -214,6 +215,59 @@ describe('useCodexAccountSnapshot', () => {
|
||||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
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 () => {
|
it('does not run the deferred initial snapshot after a manual refresh already loaded one', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@ import type { PathLike } from 'fs';
|
||||||
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
|
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
|
||||||
const mockGetShellPreferredHome = vi.fn<() => string>();
|
const mockGetShellPreferredHome = vi.fn<() => string>();
|
||||||
const mockGetClaudeBasePath = vi.fn<() => string>();
|
const mockGetClaudeBasePath = vi.fn<() => string>();
|
||||||
const mockResolveInteractiveShellEnvBestEffort = vi.fn<
|
const mockResolveInteractiveShellEnvBestEffort =
|
||||||
(options?: unknown) => Promise<NodeJS.ProcessEnv>
|
vi.fn<(options?: unknown) => Promise<NodeJS.ProcessEnv>>();
|
||||||
>();
|
|
||||||
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>();
|
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>();
|
||||||
const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>();
|
const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>();
|
||||||
|
|
||||||
|
|
@ -120,6 +119,7 @@ describe('ClaudeBinaryResolver', () => {
|
||||||
|
|
||||||
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
|
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
|
||||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
||||||
|
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers the dedicated CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH override', async () => {
|
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);
|
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
|
||||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
||||||
|
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not wait for shell env before using an explicit absolute runtime override', async () => {
|
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);
|
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
|
||||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
||||||
|
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finds npm-local Claude install in the vendor bin directory', async () => {
|
it('finds npm-local Claude install in the vendor bin directory', async () => {
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,13 @@ const hoisted = vi.hoisted(() => ({
|
||||||
isPackaged: false,
|
isPackaged: false,
|
||||||
version: '9.9.9-test',
|
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,
|
cachedShellEnv: null as NodeJS.ProcessEnv | null,
|
||||||
resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>(
|
resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>(
|
||||||
async () => ({} as NodeJS.ProcessEnv)
|
async () => ({}) as NodeJS.ProcessEnv
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -65,6 +68,10 @@ import {
|
||||||
} from '@main/services/team/TeamMcpConfigBuilder';
|
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||||
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||||
|
|
||||||
|
function nodeRuntimeProbeStdout(execPath: string, version = '20.11.0'): string {
|
||||||
|
return JSON.stringify({ execPath, version });
|
||||||
|
}
|
||||||
|
|
||||||
describe('TeamMcpConfigBuilder', () => {
|
describe('TeamMcpConfigBuilder', () => {
|
||||||
const createdPaths: string[] = [];
|
const createdPaths: string[] = [];
|
||||||
const createdDirs: string[] = [];
|
const createdDirs: string[] = [];
|
||||||
|
|
@ -95,7 +102,9 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
|
|
||||||
function readGeneratedServer(
|
function readGeneratedServer(
|
||||||
configPath: string
|
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 raw = fs.readFileSync(configPath, 'utf8');
|
||||||
const parsed = JSON.parse(raw) as {
|
const parsed = JSON.parse(raw) as {
|
||||||
mcpServers?: Record<
|
mcpServers?: Record<
|
||||||
|
|
@ -206,7 +215,10 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
setPackagedMode(false);
|
setPackagedMode(false);
|
||||||
setResourcesPath(undefined);
|
setResourcesPath(undefined);
|
||||||
hoisted.execCliMock.mockClear();
|
hoisted.execCliMock.mockClear();
|
||||||
hoisted.execCliMock.mockResolvedValue({ stdout: '/mock/node', stderr: '' });
|
hoisted.execCliMock.mockResolvedValue({
|
||||||
|
stdout: nodeRuntimeProbeStdout('/mock/node'),
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
hoisted.cachedShellEnv = null;
|
hoisted.cachedShellEnv = null;
|
||||||
hoisted.resolveInteractiveShellEnvMock.mockClear();
|
hoisted.resolveInteractiveShellEnvMock.mockClear();
|
||||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({});
|
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({});
|
||||||
|
|
@ -318,7 +330,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
expect(readGeneratedServer(configPath)?.command).toBe('/mock/node');
|
expect(readGeneratedServer(configPath)?.command).toBe('/mock/node');
|
||||||
expect(hoisted.execCliMock).toHaveBeenCalledWith(
|
expect(hoisted.execCliMock).toHaveBeenCalledWith(
|
||||||
'node',
|
'node',
|
||||||
['-e', 'process.stdout.write(process.execPath)'],
|
['-e', expect.stringContaining('process.versions.node')],
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
|
@ -346,7 +358,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
expect(command).toBe('node');
|
expect(command).toBe('node');
|
||||||
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||||
expect(env?.PATH?.split(path.delimiter)[0]).toBe('/mock-shell-node-bin');
|
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();
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
|
@ -361,64 +373,64 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
it.each(['linux', 'darwin', 'win32'] as const)(
|
it.each(['linux', 'darwin', 'win32'] as const)(
|
||||||
'uses the packaged Electron Node runtime for %s packaged MCP launches',
|
'uses the packaged Electron Node runtime for %s packaged MCP launches',
|
||||||
async (platform) => {
|
async (platform) => {
|
||||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath');
|
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath');
|
||||||
const electronBinary =
|
const electronBinary =
|
||||||
platform === 'win32'
|
platform === 'win32'
|
||||||
? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe'
|
? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe'
|
||||||
: '/opt/Agent Teams AI/agent-teams-ai';
|
: '/opt/Agent Teams AI/agent-teams-ai';
|
||||||
setPackagedMode(true, '3.0.0');
|
setPackagedMode(true, '3.0.0');
|
||||||
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
createdDirs.push(resourcesDir);
|
createdDirs.push(resourcesDir);
|
||||||
createPackagedServerBundle(resourcesDir, '// packaged server');
|
createPackagedServerBundle(resourcesDir, '// packaged server');
|
||||||
setResourcesPath(resourcesDir);
|
setResourcesPath(resourcesDir);
|
||||||
hoisted.execCliMock.mockResolvedValue({
|
hoisted.execCliMock.mockResolvedValue({
|
||||||
stdout: 'agent-teams-electron-node-ok',
|
stdout: 'agent-teams-electron-node-ok',
|
||||||
stderr: '',
|
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' },
|
|
||||||
});
|
});
|
||||||
expect(server?.command).toBe(electronBinary);
|
|
||||||
expect(server?.args).toEqual([expectedEntry]);
|
Object.defineProperty(process, 'platform', {
|
||||||
expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1');
|
value: platform,
|
||||||
expect(hoisted.execCliMock).toHaveBeenCalledTimes(1);
|
configurable: true,
|
||||||
expect(hoisted.execCliMock).toHaveBeenCalledWith(
|
});
|
||||||
electronBinary,
|
Object.defineProperty(process, 'execPath', {
|
||||||
['-e', 'process.stdout.write("agent-teams-electron-node-ok")'],
|
value: electronBinary,
|
||||||
expect.objectContaining({
|
configurable: true,
|
||||||
env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }),
|
writable: true,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
} finally {
|
try {
|
||||||
if (platformDescriptor) {
|
const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||||
Object.defineProperty(process, 'platform', platformDescriptor);
|
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;
|
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||||
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
||||||
expect(command).toBe('node');
|
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`);
|
throw new Error(`spawn ${command} ENOENT`);
|
||||||
});
|
});
|
||||||
|
|
@ -468,7 +480,10 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
mockBuiltWorkspaceEntryAvailable();
|
mockBuiltWorkspaceEntryAvailable();
|
||||||
const previousPath = process.env.PATH;
|
const previousPath = process.env.PATH;
|
||||||
process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
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 {
|
try {
|
||||||
const builder = new TeamMcpConfigBuilder();
|
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 () => {
|
it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => {
|
||||||
mockBuiltWorkspaceEntryAvailable();
|
mockBuiltWorkspaceEntryAvailable();
|
||||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
||||||
|
|
@ -497,7 +564,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||||
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
||||||
expect(command).toBe('node');
|
expect(command).toBe('node');
|
||||||
return { stdout: '/strict-shell-node-bin/node', stderr: '' };
|
return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' };
|
||||||
}
|
}
|
||||||
if (!returnedEmptyPath) {
|
if (!returnedEmptyPath) {
|
||||||
returnedEmptyPath = true;
|
returnedEmptyPath = true;
|
||||||
|
|
@ -522,7 +589,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
process.env.NODE_BINARY = '/explicit/node';
|
process.env.NODE_BINARY = '/explicit/node';
|
||||||
hoisted.execCliMock.mockImplementationOnce(async (command) => {
|
hoisted.execCliMock.mockImplementationOnce(async (command) => {
|
||||||
expect(command).toBe('/explicit/node');
|
expect(command).toBe('/explicit/node');
|
||||||
return { stdout: '/explicit/node', stderr: '' };
|
return { stdout: nodeRuntimeProbeStdout('/explicit/node'), stderr: '' };
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -665,7 +732,10 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
createdPaths.push(configPath);
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
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']);
|
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
|
||||||
|
|
@ -766,7 +836,10 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
createdPaths.push(configPath);
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
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']);
|
expect(Object.keys(parsed.mcpServers).sort()).toEqual(['agent-teams', 'github', 'linear']);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ const codexAccountHookState = {
|
||||||
const pluginsPanelSpy = vi.fn();
|
const pluginsPanelSpy = vi.fn();
|
||||||
const mcpServersPanelSpy = vi.fn();
|
const mcpServersPanelSpy = vi.fn();
|
||||||
const customMcpDialogSpy = vi.fn();
|
const customMcpDialogSpy = vi.fn();
|
||||||
|
const useCodexAccountSnapshotSpy = vi.fn(
|
||||||
|
(_options: { enabled: boolean; includeRateLimits?: boolean; initialRefreshDelayMs?: number }) =>
|
||||||
|
codexAccountHookState
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock('@renderer/store', () => ({
|
vi.mock('@renderer/store', () => ({
|
||||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
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')>();
|
const actual = await importOriginal<typeof import('@features/codex-account/renderer')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useCodexAccountSnapshot: () => codexAccountHookState,
|
useCodexAccountSnapshot: (options: {
|
||||||
|
enabled: boolean;
|
||||||
|
includeRateLimits?: boolean;
|
||||||
|
initialRefreshDelayMs?: number;
|
||||||
|
}) => useCodexAccountSnapshotSpy(options),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -296,6 +304,7 @@ describe('ExtensionStoreView provider loading placeholders', () => {
|
||||||
pluginsPanelSpy.mockReset();
|
pluginsPanelSpy.mockReset();
|
||||||
mcpServersPanelSpy.mockReset();
|
mcpServersPanelSpy.mockReset();
|
||||||
customMcpDialogSpy.mockReset();
|
customMcpDialogSpy.mockReset();
|
||||||
|
useCodexAccountSnapshotSpy.mockClear();
|
||||||
codexAccountHookState.snapshot = null;
|
codexAccountHookState.snapshot = null;
|
||||||
codexAccountHookState.loading = false;
|
codexAccountHookState.loading = false;
|
||||||
codexAccountHookState.error = null;
|
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 () => {
|
it('falls back to legacy refresh when multimodel is disabled', async () => {
|
||||||
storeState.appConfig = {
|
storeState.appConfig = {
|
||||||
general: {
|
general: {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,12 @@ interface StoreState {
|
||||||
globalTasksLoading: boolean;
|
globalTasksLoading: boolean;
|
||||||
globalTasksInitialized: boolean;
|
globalTasksInitialized: boolean;
|
||||||
fetchAllTasks: ReturnType<typeof vi.fn>;
|
fetchAllTasks: ReturnType<typeof vi.fn>;
|
||||||
|
fetchProjects: ReturnType<typeof vi.fn>;
|
||||||
|
fetchRepositoryGroups: ReturnType<typeof vi.fn>;
|
||||||
softDeleteTask: ReturnType<typeof vi.fn>;
|
softDeleteTask: ReturnType<typeof vi.fn>;
|
||||||
projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[];
|
projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[];
|
||||||
|
projectsLoading: boolean;
|
||||||
|
projectsError: string | null;
|
||||||
viewMode: 'flat' | 'grouped';
|
viewMode: 'flat' | 'grouped';
|
||||||
repositoryGroups: {
|
repositoryGroups: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -19,6 +23,8 @@ interface StoreState {
|
||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
worktrees: { path: string }[];
|
worktrees: { path: string }[];
|
||||||
}[];
|
}[];
|
||||||
|
repositoryGroupsLoading: boolean;
|
||||||
|
repositoryGroupsError: string | null;
|
||||||
teams: (Pick<TeamSummary, 'teamName' | 'displayName'> & Partial<TeamSummary>)[];
|
teams: (Pick<TeamSummary, 'teamName' | 'displayName'> & Partial<TeamSummary>)[];
|
||||||
provisioningRuns: Record<string, { state: string; runId: string; updatedAt: string }>;
|
provisioningRuns: Record<string, { state: string; runId: string; updatedAt: string }>;
|
||||||
currentProvisioningRunIdByTeam: Record<string, string | null>;
|
currentProvisioningRunIdByTeam: Record<string, string | null>;
|
||||||
|
|
@ -205,10 +211,16 @@ describe('GlobalTaskList project grouping', () => {
|
||||||
storeState.globalTasksLoading = false;
|
storeState.globalTasksLoading = false;
|
||||||
storeState.globalTasksInitialized = true;
|
storeState.globalTasksInitialized = true;
|
||||||
storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined));
|
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.softDeleteTask = vi.fn(() => Promise.resolve(undefined));
|
||||||
storeState.projects = [];
|
storeState.projects = [];
|
||||||
|
storeState.projectsLoading = false;
|
||||||
|
storeState.projectsError = null;
|
||||||
storeState.viewMode = 'flat';
|
storeState.viewMode = 'flat';
|
||||||
storeState.repositoryGroups = [];
|
storeState.repositoryGroups = [];
|
||||||
|
storeState.repositoryGroupsLoading = false;
|
||||||
|
storeState.repositoryGroupsError = null;
|
||||||
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
|
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
|
||||||
storeState.provisioningRuns = {};
|
storeState.provisioningRuns = {};
|
||||||
storeState.currentProvisioningRunIdByTeam = {};
|
storeState.currentProvisioningRunIdByTeam = {};
|
||||||
|
|
@ -232,6 +244,72 @@ describe('GlobalTaskList project grouping', () => {
|
||||||
storeListeners.clear();
|
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 () => {
|
it('shows five tasks first, then expands and collapses with Show more and Show less', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1));
|
storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue