diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index b98ebc7b..4070bd91 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -6,6 +6,7 @@ import type { RecentProjectsSourcePort } from '@features/recent-projects/core/ap import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; import type { CodexAppServerClient, + CodexRecentThreadsResult, CodexThreadSummary, } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; @@ -14,9 +15,7 @@ import type { ServiceContext } from '@main/services'; const CODEX_THREAD_LIMIT = 40; const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500; const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500; -const CODEX_REQUEST_TIMEOUT_MS = 4_500; const CODEX_SOURCE_TIMEOUT_MS = 5_200; -const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150; function isInteractiveSource(source: unknown): boolean { return source === 'vscode' || source === 'cli'; @@ -58,18 +57,11 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor return []; } - const liveThreads = await this.#listThreadsSegmentSafe(binaryPath, 'live', { - archived: false, - totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS, - }); - const archivedPromise = this.#listThreadsSegmentSafe(binaryPath, 'archived', { - archived: true, - totalTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS, - }); - const archivedThreads = - liveThreads.length > 0 - ? await this.#awaitWithTimeout(archivedPromise, FAST_ARCHIVED_MERGE_TIMEOUT_MS) - : await archivedPromise; + const threadSegments = await this.#listRecentThreadsSafe(binaryPath); + this.#logSegmentFailure(threadSegments, 'live'); + this.#logSegmentFailure(threadSegments, 'archived'); + const liveThreads = threadSegments.live.threads; + const archivedThreads = threadSegments.archived.threads; const interactiveThreads = [...liveThreads, ...archivedThreads].filter( (thread) => Boolean(thread.cwd) && isInteractiveSource(thread.source) @@ -86,67 +78,45 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor return candidates; } - async #listThreadsSegment( - binaryPath: string, - segment: 'live' | 'archived', - options: { - archived: boolean; - totalTimeoutMs: number; - } - ): Promise { - const result = await this.deps.appServerClient.listThreads(binaryPath, { - archived: options.archived, + async #listRecentThreads(binaryPath: string): Promise { + const result = await this.deps.appServerClient.listRecentThreads(binaryPath, { limit: CODEX_THREAD_LIMIT, - requestTimeoutMs: CODEX_REQUEST_TIMEOUT_MS, - totalTimeoutMs: options.totalTimeoutMs, + liveRequestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS, + archivedRequestTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS, + totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS, }); - this.deps.logger.info('codex recent-projects thread list loaded', { - segment, - count: result.length, + this.deps.logger.info('codex recent-projects thread lists loaded', { + liveCount: result.live.threads.length, + archivedCount: result.archived.threads.length, }); return result; } - async #awaitWithTimeout( - promise: Promise, - timeoutMs: number - ): Promise { - let timer: ReturnType | null = null; - try { - return await Promise.race([ - promise, - new Promise((resolve) => { - timer = setTimeout(() => resolve([]), timeoutMs); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } + #logSegmentFailure(result: CodexRecentThreadsResult, segment: 'live' | 'archived'): void { + const error = result[segment].error; + if (!error) { + return; } - } - #unwrapThreadListError(error: unknown, segment: 'live' | 'archived'): CodexThreadSummary[] { this.deps.logger.warn('codex recent-projects thread list failed', { segment, - error: error instanceof Error ? error.message : String(error), + error, }); - return []; } - async #listThreadsSegmentSafe( - binaryPath: string, - segment: 'live' | 'archived', - options: { - archived: boolean; - totalTimeoutMs: number; - } - ): Promise { + async #listRecentThreadsSafe(binaryPath: string): Promise { try { - return await this.#listThreadsSegment(binaryPath, segment, options); + return await this.#listRecentThreads(binaryPath); } catch (error) { - return this.#unwrapThreadListError(error, segment); + const message = error instanceof Error ? error.message : String(error); + this.deps.logger.warn('codex recent-projects thread list session failed', { + error: message, + }); + return { + live: { threads: [], error: message }, + archived: { threads: [], error: message }, + }; } } diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index a7cab3e2..2a43a960 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -38,26 +38,38 @@ export interface CodexThreadSummary { path?: string | null; } +export interface CodexThreadSegmentResult { + threads: CodexThreadSummary[]; + error?: string; +} + +export interface CodexRecentThreadsResult { + live: CodexThreadSegmentResult; + archived: CodexThreadSegmentResult; +} + export class CodexAppServerClient { constructor(private readonly rpcClient: JsonRpcStdioClient) {} - async listThreads( + async listRecentThreads( binaryPath: string, options: { - archived: boolean; limit: number; - requestTimeoutMs?: number; + liveRequestTimeoutMs?: number; + archivedRequestTimeoutMs?: number; totalTimeoutMs?: number; } - ): Promise { - const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + ): Promise { + const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs); const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS; return this.rpcClient.withSession( { binaryPath, args: ['app-server'], - requestTimeoutMs, + requestTimeoutMs: sessionRequestTimeoutMs, totalTimeoutMs, label: 'codex app-server thread/list', }, @@ -75,22 +87,51 @@ export class CodexAppServerClient { optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS, }, }, - requestTimeoutMs + sessionRequestTimeoutMs ); await session.notify('initialized'); - const response = await session.request( - 'thread/list', - { - archived: options.archived, - limit: options.limit, - sortKey: 'updated_at', - }, - requestTimeoutMs - ); + const [live, archived] = await Promise.allSettled([ + session.request( + 'thread/list', + { + archived: false, + limit: options.limit, + sortKey: 'updated_at', + }, + liveRequestTimeoutMs + ), + session.request( + 'thread/list', + { + archived: true, + limit: options.limit, + sortKey: 'updated_at', + }, + archivedRequestTimeoutMs + ), + ]); - return response.data ?? []; + return { + live: + live.status === 'fulfilled' + ? { threads: live.value.data ?? [] } + : { + threads: [], + error: live.reason instanceof Error ? live.reason.message : String(live.reason), + }, + archived: + archived.status === 'fulfilled' + ? { threads: archived.value.data ?? [] } + : { + threads: [], + error: + archived.reason instanceof Error + ? archived.reason.message + : String(archived.reason), + }, + }; } ); } diff --git a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts new file mode 100644 index 00000000..bb92552d --- /dev/null +++ b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; + +import type { JsonRpcSession, JsonRpcStdioClient } from '@features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient'; + +function createSession( + request: JsonRpcSession['request'], + notify: JsonRpcSession['notify'] = vi.fn().mockResolvedValue(undefined) +): JsonRpcSession { + return { + request, + notify, + }; +} + +describe('CodexAppServerClient', () => { + it('loads live and archived threads in a single app-server session', async () => { + const session = createSession( + vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => { + if (method === 'initialize') { + return Promise.resolve({}); + } + + if (method === 'thread/list' && params?.archived === false) { + return Promise.resolve({ + data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], + }); + } + + if (method === 'thread/list' && params?.archived === true) { + return Promise.resolve({ + data: [{ id: 'archived-1', cwd: '/Users/test/archive-project', source: 'vscode' }], + }); + } + + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }) + ); + + const withSession = vi.fn().mockImplementation((_options, handler) => handler(session)); + const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient); + + const result = await client.listRecentThreads('/usr/local/bin/codex', { + limit: 40, + liveRequestTimeoutMs: 4500, + archivedRequestTimeoutMs: 2500, + totalTimeoutMs: 4500, + }); + + expect(withSession).toHaveBeenCalledTimes(1); + expect(withSession).toHaveBeenCalledWith( + expect.objectContaining({ + binaryPath: '/usr/local/bin/codex', + requestTimeoutMs: 4500, + totalTimeoutMs: 4500, + }), + expect.any(Function) + ); + expect(session.notify).toHaveBeenCalledWith('initialized'); + expect(result).toEqual({ + live: { + threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], + }, + archived: { + threads: [{ id: 'archived-1', cwd: '/Users/test/archive-project', source: 'vscode' }], + }, + }); + }); + + it('keeps live results when archived thread loading fails', async () => { + const session = createSession( + vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => { + if (method === 'initialize') { + return Promise.resolve({}); + } + + if (method === 'thread/list' && params?.archived === false) { + return Promise.resolve({ + data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], + }); + } + + if (method === 'thread/list' && params?.archived === true) { + return Promise.reject(new Error('JSON-RPC request timed out: thread/list')); + } + + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }) + ); + + const withSession = vi.fn().mockImplementation((_options, handler) => handler(session)); + const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient); + + const result = await client.listRecentThreads('/usr/local/bin/codex', { + limit: 40, + liveRequestTimeoutMs: 4500, + archivedRequestTimeoutMs: 2500, + totalTimeoutMs: 4500, + }); + + expect(result.live.threads).toEqual([ + { id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }, + ]); + expect(result.archived).toEqual({ + threads: [], + error: 'JSON-RPC request timed out: thread/list', + }); + }); +});