From b15de780cbdfdefb95429c3a8775d1d776728fac Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 23:41:54 +0300 Subject: [PATCH] fix(codex-account): keep account snapshots fresh --- .../composition/createCodexAccountFeature.ts | 24 ++-- .../renderer/hooks/useCodexAccountSnapshot.ts | 16 +++ .../mergeCodexProviderStatusWithSnapshot.ts | 19 ++- .../main/createCodexAccountFeature.test.ts | 115 +++++++++++++++++- ...rgeCodexProviderStatusWithSnapshot.test.ts | 60 +++++++++ .../renderer/useCodexAccountSnapshot.test.ts | 73 ++++++++++- .../useRuntimeProviderManagement.test.ts | 6 +- 7 files changed, 298 insertions(+), 15 deletions(-) diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 5a9f39ec..80fd3c5e 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -263,7 +263,8 @@ async function resolveCodexBinaryForAccountSnapshot(): Promise { await resolveInteractiveShellEnvBestEffort({ timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS, fallbackEnv: process.env, - background: false, + background: true, + source: 'codex-account-binary-discovery', }); CodexBinaryResolver.clearCache(); return CodexBinaryResolver.resolve(); @@ -293,6 +294,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { private snapshotCache: CodexAccountSnapshotDto | null = null; private snapshotObservedAt = 0; + private lastPublishedSnapshotUpdatedAtMs = 0; private refreshPromise: Promise | null = null; private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null; private lastKnownAccount: CodexLastKnownAccount | null = null; @@ -446,6 +448,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { this.lastKnownAccount = null; this.lastKnownRateLimits = null; this.lastKnownRuntimeContext = null; + this.lastPublishedSnapshotUpdatedAtMs = 0; this.activeMutationCount = 0; if (this.mutationQueueRelease) { this.mutationQueueRelease(); @@ -519,7 +522,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { runtimeContext: freshRuntimeContext, login, rateLimits: this.snapshotCache?.rateLimits ?? null, - updatedAt: new Date(now).toISOString(), + updatedAt: new Date().toISOString(), }); return snapshot; } @@ -539,7 +542,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { localActiveChatgptAccountPresent, login, rateLimits: null, - updatedAt: new Date(now).toISOString(), + updatedAt: new Date().toISOString(), }); return snapshot; } @@ -699,20 +702,27 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { runtimeContext, login, rateLimits, - updatedAt: new Date(now).toISOString(), + updatedAt: new Date().toISOString(), }); return snapshot; } private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto { + const publishedAtMs = Math.max(Date.now(), this.lastPublishedSnapshotUpdatedAtMs + 1); + this.lastPublishedSnapshotUpdatedAtMs = publishedAtMs; + const publishedSnapshot = { + ...nextSnapshot, + updatedAt: new Date(publishedAtMs).toISOString(), + }; + if (this.disposed) { - return deepClone(nextSnapshot); + return deepClone(publishedSnapshot); } - this.snapshotCache = deepClone(nextSnapshot); + this.snapshotCache = deepClone(publishedSnapshot); this.snapshotObservedAt = Date.now(); - const snapshot = deepClone(nextSnapshot); + const snapshot = deepClone(publishedSnapshot); this.presenter.publish(snapshot); for (const listener of this.listeners) { listener(snapshot); diff --git a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts index 1269ffd6..368d590f 100644 --- a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts +++ b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts @@ -42,6 +42,11 @@ function getRefreshIntervalMs(options: { : CODEX_VISIBLE_STANDARD_REFRESH_MS; } +function getSnapshotUpdatedAtMs(snapshot: CodexAccountSnapshotDto): number | null { + const updatedAtMs = Date.parse(snapshot.updatedAt); + return Number.isFinite(updatedAtMs) ? updatedAtMs : null; +} + export function useCodexAccountSnapshot(options: { enabled: boolean; includeRateLimits?: boolean; @@ -68,6 +73,7 @@ export function useCodexAccountSnapshot(options: { const [error, setError] = useState(null); const [visible, setVisible] = useState(() => isDocumentVisible()); const lastUpdatedAtRef = useRef(null); + const snapshotUpdatedAtRef = useRef(null); const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0; const initialRefreshMaxDelayMs = options.initialRefreshMaxDelayMs; const [initialRefreshAttempted, setInitialRefreshAttempted] = useState( @@ -75,6 +81,16 @@ export function useCodexAccountSnapshot(options: { ); const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => { + const nextUpdatedAtMs = getSnapshotUpdatedAtMs(nextSnapshot); + if ( + nextUpdatedAtMs !== null && + snapshotUpdatedAtRef.current !== null && + nextUpdatedAtMs < snapshotUpdatedAtRef.current + ) { + return; + } + + snapshotUpdatedAtRef.current = nextUpdatedAtMs ?? Date.now(); lastUpdatedAtRef.current = Date.now(); setSnapshot(nextSnapshot); setError(null); diff --git a/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts b/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts index 8980cf69..620cd1f7 100644 --- a/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts +++ b/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts @@ -144,6 +144,21 @@ function mergeCodexNativeBackendOption( }); } +function mergeCodexCapabilitiesWithSnapshot( + provider: CliProviderStatus, + snapshot: CodexAccountSnapshotDto +): CliProviderStatus['capabilities'] { + if (!snapshot.launchAllowed) { + return provider.capabilities; + } + + return { + ...provider.capabilities, + teamLaunch: true, + oneShot: true, + }; +} + export function mergeCodexProviderStatusWithSnapshot( provider: CliProviderStatus, snapshot: CodexAccountSnapshotDto | null @@ -166,8 +181,10 @@ export function mergeCodexProviderStatusWithSnapshot( return { ...provider, - supported: provider.supported || isCodexBootstrapPlaceholder(provider), + supported: + provider.supported || isCodexBootstrapPlaceholder(provider) || snapshot.launchAllowed, authenticated: snapshot.launchAllowed, + capabilities: mergeCodexCapabilitiesWithSnapshot(provider, snapshot), authMethod: snapshot.effectiveAuthMode === 'chatgpt' ? 'chatgpt' diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index 623103c0..1b4e611f 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -370,9 +370,23 @@ describe('createCodexAccountFeature', () => { }); it('retries Codex binary discovery after cold shell env resolves before publishing runtime-missing', async () => { - binaryResolveMock.mockResolvedValueOnce(null).mockResolvedValue('/usr/local/bin/codex'); - resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ - PATH: '/usr/local/bin:/usr/bin:/bin', + getCachedShellEnvMock.mockReturnValue(null); + binaryResolveMock.mockImplementation(async () => + getCachedShellEnvMock()?.PATH?.includes('/custom/bin') ? '/custom/bin/codex' : null + ); + resolveInteractiveShellEnvBestEffortMock.mockImplementation(async (options?: { + background?: boolean; + fallbackEnv?: NodeJS.ProcessEnv; + }) => { + if (options?.background === false) { + return options.fallbackEnv ?? {}; + } + + const shellEnv = { + PATH: '/custom/bin:/usr/bin:/bin', + }; + getCachedShellEnvMock.mockReturnValue(shellEnv); + return shellEnv; }); readAccountMock.mockResolvedValue({ account: createAccountResponse(), @@ -395,6 +409,8 @@ describe('createCodexAccountFeature', () => { expect.objectContaining({ timeoutMs: 12_000, fallbackEnv: process.env, + background: true, + source: 'codex-account-binary-discovery', }) ); expect(binaryClearCacheMock).toHaveBeenCalledTimes(1); @@ -407,6 +423,99 @@ describe('createCodexAccountFeature', () => { } }); + it('timestamps snapshots at publication time after a slow account read', async () => { + vi.useFakeTimers({ + toFake: ['Date'], + }); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const accountReadDeferred = createDeferred(); + readAccountMock.mockImplementation(async () => { + await accountReadDeferred.promise; + return { + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }; + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const snapshotPromise = feature.refreshSnapshot(); + await vi.waitFor(() => { + expect(readAccountMock).toHaveBeenCalledTimes(1); + }); + + vi.setSystemTime(new Date('2026-01-01T00:00:05.000Z')); + accountReadDeferred.resolve(); + + const snapshot = await snapshotPromise; + + expect(snapshot.updatedAt).toBe('2026-01-01T00:00:05.000Z'); + expect(snapshot.appServerState).toBe('healthy'); + } finally { + vi.useRealTimers(); + await feature.dispose(); + } + }); + + it('publishes strictly increasing snapshot timestamps within the same millisecond', async () => { + vi.useFakeTimers({ + toFake: ['Date'], + }); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + const publishedSnapshots: CodexAccountSnapshotDto[] = []; + const unsubscribe = feature.subscribe((snapshot) => { + publishedSnapshots.push(snapshot); + }); + + try { + await feature.refreshSnapshot(); + emitLoginState({ + status: 'pending', + error: null, + startedAt: '2026-01-01T00:00:00.000Z', + authUrl: 'https://chatgpt.com/auth', + }); + emitLoginState({ + status: 'cancelled', + error: null, + startedAt: null, + authUrl: null, + }); + + expect(publishedSnapshots.map((snapshot) => Date.parse(snapshot.updatedAt))).toEqual([ + 1_767_225_600_000, + 1_767_225_600_001, + 1_767_225_600_002, + ]); + expect(publishedSnapshots.at(-1)?.login.status).toBe('cancelled'); + } finally { + unsubscribe(); + vi.useRealTimers(); + await feature.dispose(); + } + }); + it('still reports runtime-missing after the cold binary retry cannot find Codex', async () => { binaryResolveMock.mockResolvedValue(null); resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ diff --git a/test/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.test.ts b/test/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.test.ts index 269fc3dc..ef3df4b3 100644 --- a/test/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.test.ts +++ b/test/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.test.ts @@ -150,6 +150,66 @@ describe('mergeCodexProviderStatusWithSnapshot', () => { }); }); + it('clears a stale runtime-missing provider once the live snapshot is ready', () => { + const baseProvider = createBaseCodexProvider(); + const baseConnection = baseProvider.connection!; + const merged = mergeCodexProviderStatusWithSnapshot( + { + ...baseProvider, + supported: false, + authenticated: false, + verificationState: 'error', + statusMessage: 'Codex CLI not found. Install Codex to use native account management.', + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + availableBackends: [ + { + id: 'codex-native', + label: 'Codex native', + description: 'Use codex exec JSON mode.', + selectable: false, + recommended: true, + available: false, + state: 'runtime-missing', + audience: 'general', + statusMessage: 'Codex CLI not found', + detailMessage: 'Codex CLI not found', + }, + ], + connection: { + ...baseConnection, + codex: { + ...baseConnection.codex!, + appServerState: 'runtime-missing', + appServerStatusMessage: 'Codex CLI not found', + launchAllowed: false, + launchIssueMessage: 'Codex CLI not found', + launchReadinessState: 'runtime_missing', + }, + }, + }, + createReadyChatgptSnapshot() + ); + + expect(merged.supported).toBe(true); + expect(merged.authenticated).toBe(true); + expect(merged.verificationState).toBe('verified'); + expect(merged.statusMessage).toBe('ChatGPT account ready'); + expect(merged.capabilities.teamLaunch).toBe(true); + expect(merged.capabilities.oneShot).toBe(true); + expect(merged.connection?.codex?.appServerState).toBe('healthy'); + expect(merged.connection?.codex?.launchReadinessState).toBe('ready_chatgpt'); + expect(merged.availableBackends?.find((option) => option.id === 'codex-native')).toMatchObject({ + available: true, + selectable: true, + state: 'ready', + statusMessage: 'Ready', + }); + }); + it('hydrates codex connection truth even when the stale provider payload had no connection block', () => { const merged = mergeCodexProviderStatusWithSnapshot( { diff --git a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts index 5c769e68..73d4ebf9 100644 --- a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts +++ b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts @@ -13,7 +13,9 @@ const apiMocks = vi.hoisted(() => ({ startCodexChatgptLogin: vi.fn(), cancelCodexChatgptLogin: vi.fn(), logoutCodexAccount: vi.fn(), - onCodexAccountSnapshotChanged: vi.fn(() => () => undefined), + onCodexAccountSnapshotChanged: vi.fn< + (callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void) => () => void + >(() => () => undefined), })); type IdleCallbackForTest = (deadline: { @@ -71,6 +73,16 @@ function createSnapshot(): CodexAccountSnapshotDto { }; } +function withSnapshotOverrides( + snapshot: CodexAccountSnapshotDto, + overrides: Partial +): CodexAccountSnapshotDto { + return { + ...snapshot, + ...overrides, + }; +} + function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -133,6 +145,65 @@ describe('useCodexAccountSnapshot', () => { }); }); + it('ignores older pushed Codex snapshots after a fresher snapshot was applied', async () => { + let snapshotListener: + | ((event: unknown, snapshot: CodexAccountSnapshotDto) => void) + | null = null; + const staleSnapshot = withSnapshotOverrides(createSnapshot(), { + updatedAt: '2026-01-01T00:00:00.000Z', + managedAccount: { + type: 'chatgpt', + email: 'stale@example.com', + planType: 'pro', + }, + }); + const freshSnapshot = withSnapshotOverrides(createSnapshot(), { + updatedAt: '2026-01-01T00:00:01.000Z', + managedAccount: { + type: 'chatgpt', + email: 'fresh@example.com', + planType: 'pro', + }, + }); + apiMocks.getCodexAccountSnapshot.mockResolvedValue(freshSnapshot); + apiMocks.onCodexAccountSnapshotChanged.mockImplementation( + (callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void) => { + snapshotListener = callback; + return () => undefined; + } + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + function Harness(): React.ReactElement { + const state = useCodexAccountSnapshot({ + enabled: true, + }); + + return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty'); + } + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('fresh@example.com'); + + await act(async () => { + snapshotListener?.({}, staleSnapshot); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('fresh@example.com'); + + act(() => { + root.unmount(); + }); + }); + it('can defer the initial Codex snapshot without starting interval refreshes first', async () => { vi.useFakeTimers(); const snapshot = createSnapshot(); diff --git a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts index 6c85685c..82c5f774 100644 --- a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts +++ b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts @@ -228,11 +228,11 @@ describe('useRuntimeProviderManagement', () => { const root = createRoot(host); await act(async () => { root.render(React.createElement(ConfigurableHarness, { enabled: true })); - await Promise.resolve(); - await Promise.resolve(); }); - expect(state?.error).toContain('wrong runtime binary'); + await vi.waitFor(() => { + expect(state?.error).toContain('wrong runtime binary'); + }); expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); await act(async () => {