fix: harden recent project recovery and path matching
This commit is contained in:
parent
68378c603c
commit
c3f18df062
8 changed files with 443 additions and 56 deletions
|
|
@ -64,8 +64,11 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
|||
|
||||
const successful = results.flatMap((result) => result.candidates);
|
||||
const hasDegradedSources = results.some((result) => result.degraded);
|
||||
const response: ListDashboardRecentProjectsResponse = {
|
||||
projects: mergeRecentProjectCandidates(successful),
|
||||
};
|
||||
|
||||
if (hasDegradedSources && stale) {
|
||||
if (hasDegradedSources && stale && response.projects.length === 0) {
|
||||
await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs);
|
||||
this.deps.logger.info('recent-projects served stale cache', {
|
||||
cacheKey,
|
||||
|
|
@ -76,9 +79,6 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
|||
return stale;
|
||||
}
|
||||
|
||||
const response: ListDashboardRecentProjectsResponse = {
|
||||
projects: mergeRecentProjectCandidates(successful),
|
||||
};
|
||||
const viewModel = this.deps.output.present(response);
|
||||
const cacheTtlMs = hasDegradedSources
|
||||
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
|||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
||||
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_ARCHIVED_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
||||
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
||||
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS + 1_500;
|
||||
|
||||
function isInteractiveSource(source: unknown): boolean {
|
||||
return source === 'vscode' || source === 'cli';
|
||||
|
|
@ -116,6 +118,37 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
this.deps.logger.warn('codex recent-projects thread list session failed', {
|
||||
error: message,
|
||||
});
|
||||
|
||||
if (message.toLowerCase().includes('timed out')) {
|
||||
return {
|
||||
live: { threads: [], error: message },
|
||||
archived: { threads: [], error: message },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const liveFallback = await this.deps.appServerClient.listRecentLiveThreads(binaryPath, {
|
||||
limit: CODEX_THREAD_LIMIT,
|
||||
requestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||
totalTimeoutMs: CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
this.deps.logger.info('codex recent-projects recovered with live-only fallback', {
|
||||
liveCount: liveFallback.threads.length,
|
||||
});
|
||||
|
||||
return {
|
||||
live: liveFallback,
|
||||
archived: { threads: [], error: message },
|
||||
};
|
||||
} catch (fallbackError) {
|
||||
const fallbackMessage =
|
||||
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||
this.deps.logger.warn('codex recent-projects live-only fallback failed', {
|
||||
error: fallbackMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
live: { threads: [], error: message },
|
||||
archived: { threads: [], error: message },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JsonRpcStdioClient } from './JsonRpcStdioClient';
|
||||
import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||
|
|
@ -49,9 +49,55 @@ export interface CodexRecentThreadsResult {
|
|||
archived: CodexThreadSegmentResult;
|
||||
}
|
||||
|
||||
interface ThreadListSessionOptions {
|
||||
binaryPath: string;
|
||||
requestTimeoutMs: number;
|
||||
totalTimeoutMs: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class CodexAppServerClient {
|
||||
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
|
||||
|
||||
async listRecentLiveThreads(
|
||||
binaryPath: string,
|
||||
options: {
|
||||
limit: number;
|
||||
requestTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
}
|
||||
): Promise<CodexThreadSegmentResult> {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const totalTimeoutMs = Math.max(
|
||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||
requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
);
|
||||
|
||||
return this.#withThreadListSession(
|
||||
{
|
||||
binaryPath,
|
||||
requestTimeoutMs,
|
||||
totalTimeoutMs,
|
||||
label: 'codex app-server thread/list live',
|
||||
},
|
||||
async (session) => {
|
||||
const live = await session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
requestTimeoutMs
|
||||
);
|
||||
|
||||
return {
|
||||
threads: live.data ?? [],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async listRecentThreads(
|
||||
binaryPath: string,
|
||||
options: {
|
||||
|
|
@ -66,36 +112,17 @@ export class CodexAppServerClient {
|
|||
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||
const totalTimeoutMs = Math.max(
|
||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||
sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
liveRequestTimeoutMs + archivedRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
);
|
||||
|
||||
return this.rpcClient.withSession(
|
||||
return this.#withThreadListSession(
|
||||
{
|
||||
binaryPath,
|
||||
args: ['app-server'],
|
||||
requestTimeoutMs: sessionRequestTimeoutMs,
|
||||
totalTimeoutMs,
|
||||
label: 'codex app-server thread/list',
|
||||
},
|
||||
async (session) => {
|
||||
await session.request(
|
||||
'initialize',
|
||||
{
|
||||
clientInfo: {
|
||||
name: 'claude-agent-teams-ui',
|
||||
title: 'Claude Agent Teams UI',
|
||||
version: '0.1.0',
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: false,
|
||||
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
|
||||
},
|
||||
},
|
||||
sessionRequestTimeoutMs
|
||||
);
|
||||
|
||||
await session.notify('initialized');
|
||||
|
||||
const [live, archived] = await Promise.allSettled([
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
|
|
@ -139,4 +166,39 @@ export class CodexAppServerClient {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
async #withThreadListSession<T>(
|
||||
options: ThreadListSessionOptions,
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
): Promise<T> {
|
||||
return this.rpcClient.withSession(
|
||||
{
|
||||
binaryPath: options.binaryPath,
|
||||
args: ['app-server'],
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
totalTimeoutMs: options.totalTimeoutMs,
|
||||
label: options.label,
|
||||
},
|
||||
async (session) => {
|
||||
await session.request(
|
||||
'initialize',
|
||||
{
|
||||
clientInfo: {
|
||||
name: 'claude-agent-teams-ui',
|
||||
title: 'Claude Agent Teams UI',
|
||||
version: '0.1.0',
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: false,
|
||||
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
|
||||
},
|
||||
},
|
||||
options.requestTimeoutMs
|
||||
);
|
||||
|
||||
await session.notify('initialized');
|
||||
return handler(session);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
|
||||
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||
|
||||
const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history';
|
||||
|
|
@ -22,11 +20,20 @@ function canUseLocalStorage(): boolean {
|
|||
}
|
||||
|
||||
function normalizeHistoryPath(projectPath: string): string | null {
|
||||
const trimmed = projectPath.trim();
|
||||
if (!trimmed) {
|
||||
let normalizedPath = projectPath.trim().replace(/\\/g, '/');
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
return normalizePath(trimmed);
|
||||
if (normalizedPath !== '/' && !/^[A-Za-z]:\/$/.test(normalizedPath)) {
|
||||
while (normalizedPath.endsWith('/')) {
|
||||
normalizedPath = normalizedPath.slice(0, -1);
|
||||
}
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
function foldHistoryPath(projectPath: string): string {
|
||||
return projectPath.toLowerCase();
|
||||
}
|
||||
|
||||
function readHistoryState(): RecentProjectOpenHistoryState {
|
||||
|
|
@ -99,16 +106,72 @@ function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]):
|
|||
}
|
||||
}
|
||||
|
||||
function createHistoryLookup(): Map<string, number> {
|
||||
return new Map(readHistoryState().entries.map((entry) => [entry.path, entry.openedAt]));
|
||||
interface HistoryLookup {
|
||||
exact: Map<string, number>;
|
||||
folded: Map<
|
||||
string,
|
||||
{
|
||||
openedAt: number;
|
||||
exactPaths: Set<string>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
function getProjectPaths(
|
||||
function createHistoryLookup(): HistoryLookup {
|
||||
const exact = new Map<string, number>();
|
||||
const folded = new Map<string, { openedAt: number; exactPaths: Set<string> }>();
|
||||
|
||||
for (const entry of readHistoryState().entries) {
|
||||
const normalizedPath = normalizeHistoryPath(entry.path);
|
||||
if (!normalizedPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
exact.set(normalizedPath, Math.max(exact.get(normalizedPath) ?? 0, entry.openedAt));
|
||||
|
||||
const foldedKey = foldHistoryPath(normalizedPath);
|
||||
const existingFolded = folded.get(foldedKey);
|
||||
if (existingFolded) {
|
||||
existingFolded.openedAt = Math.max(existingFolded.openedAt, entry.openedAt);
|
||||
existingFolded.exactPaths.add(normalizedPath);
|
||||
} else {
|
||||
folded.set(foldedKey, {
|
||||
openedAt: entry.openedAt,
|
||||
exactPaths: new Set([normalizedPath]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { exact, folded };
|
||||
}
|
||||
|
||||
function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): number {
|
||||
const normalizedPath = normalizeHistoryPath(projectPath);
|
||||
if (!normalizedPath) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const exactMatch = lookup.exact.get(normalizedPath);
|
||||
if (exactMatch != null) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
|
||||
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return foldedMatch.openedAt;
|
||||
}
|
||||
|
||||
function getProjectLastOpenedAtFromLookup(
|
||||
lookup: HistoryLookup,
|
||||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||
): string[] {
|
||||
return [project.primaryPath, ...project.associatedPaths]
|
||||
.map((projectPath) => normalizeHistoryPath(projectPath))
|
||||
.filter((projectPath): projectPath is string => Boolean(projectPath));
|
||||
): number {
|
||||
return [project.primaryPath, ...project.associatedPaths].reduce(
|
||||
(latest, projectPath) => Math.max(latest, resolveHistoryOpenedAt(lookup, projectPath)),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function recordRecentProjectOpenPaths(
|
||||
|
|
@ -141,10 +204,7 @@ export function getRecentProjectLastOpenedAt(
|
|||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||
): number {
|
||||
const historyLookup = createHistoryLookup();
|
||||
return getProjectPaths(project).reduce(
|
||||
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
|
||||
0
|
||||
);
|
||||
return getProjectLastOpenedAtFromLookup(historyLookup, project);
|
||||
}
|
||||
|
||||
export function sortRecentProjectsByDisplayPriority(
|
||||
|
|
@ -153,20 +213,12 @@ export function sortRecentProjectsByDisplayPriority(
|
|||
): DashboardRecentProject[] {
|
||||
const historyLookup = createHistoryLookup();
|
||||
|
||||
const getLastOpenedAt = (
|
||||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||
): number =>
|
||||
getProjectPaths(project).reduce(
|
||||
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
const isPriorityOpen = (openedAt: number): boolean =>
|
||||
openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS;
|
||||
|
||||
return [...projects].sort((left, right) => {
|
||||
const leftOpenedAt = getLastOpenedAt(left);
|
||||
const rightOpenedAt = getLastOpenedAt(right);
|
||||
const leftOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, left);
|
||||
const rightOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, right);
|
||||
const leftPriority = isPriorityOpen(leftOpenedAt);
|
||||
const rightPriority = isPriorityOpen(rightOpenedAt);
|
||||
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('returns stale cached data when a source degrades after cache expiry', async () => {
|
||||
it('prefers fresh healthy-source results over stale cache when degraded sources still leave usable projects', async () => {
|
||||
const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] };
|
||||
const cache: RecentProjectsCachePort<TestViewModel> = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
|
|
@ -294,6 +294,72 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
|||
logger,
|
||||
});
|
||||
|
||||
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual({
|
||||
ids: ['repo:fresh'],
|
||||
sources: ['claude'],
|
||||
});
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
projects: [
|
||||
expect.objectContaining({
|
||||
identity: 'repo:fresh',
|
||||
source: 'claude',
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(cache.set).toHaveBeenCalledWith(
|
||||
'recent-projects:stale',
|
||||
{ ids: ['repo:fresh'], sources: ['claude'] },
|
||||
1_500
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
|
||||
cacheKey: 'recent-projects:stale',
|
||||
count: 1,
|
||||
degradedSources: 1,
|
||||
cacheTtlMs: 1_500,
|
||||
durationMs: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to stale cache only when degraded sources leave no usable fresh projects', async () => {
|
||||
const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] };
|
||||
const cache: RecentProjectsCachePort<TestViewModel> = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
getStale: vi.fn().mockResolvedValue(stale),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
||||
present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({
|
||||
ids: response.projects.map((project) => project.identity),
|
||||
sources: response.projects.map((project) => project.source),
|
||||
})),
|
||||
};
|
||||
const sources: RecentProjectsSourcePort[] = [
|
||||
{
|
||||
sourceId: 'claude',
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
{
|
||||
sourceId: 'codex',
|
||||
list: vi.fn().mockRejectedValue(new Error('codex unavailable')),
|
||||
},
|
||||
];
|
||||
const logger = createLogger();
|
||||
let now = 15_000;
|
||||
|
||||
const useCase = new ListDashboardRecentProjectsUseCase({
|
||||
sources,
|
||||
cache,
|
||||
output,
|
||||
clock: {
|
||||
now: () => {
|
||||
const current = now;
|
||||
now += 200;
|
||||
return current;
|
||||
},
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CodexRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter';
|
||||
|
||||
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||
import type { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
|
||||
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||
|
||||
function createLogger(): LoggerPort & {
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('CodexRecentProjectsSourceAdapter', () => {
|
||||
it('falls back to live-only threads when the full app-server session fails fast', async () => {
|
||||
const logger = createLogger();
|
||||
const appServerClient = {
|
||||
listRecentThreads: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('JSON-RPC process exited unexpectedly (code=1 signal=null)')),
|
||||
listRecentLiveThreads: vi.fn().mockResolvedValue({
|
||||
threads: [
|
||||
{
|
||||
id: 'thread-live',
|
||||
cwd: '/Users/belief/dev/projects/headless',
|
||||
source: 'cli',
|
||||
updatedAt: 1_700_000_000,
|
||||
gitInfo: { branch: 'main' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as CodexAppServerClient;
|
||||
const identityResolver = {
|
||||
resolve: vi.fn().mockResolvedValue({
|
||||
id: 'repo:headless',
|
||||
name: 'headless',
|
||||
}),
|
||||
} as unknown as RecentProjectIdentityResolver;
|
||||
|
||||
const adapter = new CodexRecentProjectsSourceAdapter({
|
||||
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
|
||||
appServerClient,
|
||||
identityResolver,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
identity: 'repo:headless',
|
||||
displayName: 'headless',
|
||||
primaryPath: '/Users/belief/dev/projects/headless',
|
||||
providerIds: ['codex'],
|
||||
sourceKind: 'codex',
|
||||
openTarget: {
|
||||
type: 'synthetic-path',
|
||||
path: '/Users/belief/dev/projects/headless',
|
||||
},
|
||||
branchName: 'main',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
|
||||
expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith('codex recent-projects recovered with live-only fallback', {
|
||||
liveCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not spend extra time on live-only fallback after a full session timeout', async () => {
|
||||
const logger = createLogger();
|
||||
const appServerClient = {
|
||||
listRecentThreads: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('codex app-server thread/list timed out after 8500ms')),
|
||||
listRecentLiveThreads: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
const identityResolver = {
|
||||
resolve: vi.fn(),
|
||||
} as unknown as RecentProjectIdentityResolver;
|
||||
|
||||
const adapter = new CodexRecentProjectsSourceAdapter({
|
||||
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
|
||||
appServerClient,
|
||||
identityResolver,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual([]);
|
||||
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -53,7 +53,7 @@ describe('CodexAppServerClient', () => {
|
|||
expect.objectContaining({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
requestTimeoutMs: 4500,
|
||||
totalTimeoutMs: 6000,
|
||||
totalTimeoutMs: 8500,
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
|
|
@ -135,9 +135,49 @@ describe('CodexAppServerClient', () => {
|
|||
|
||||
expect(withSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
totalTimeoutMs: 6000,
|
||||
totalTimeoutMs: 8500,
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('can load only live threads in a dedicated fallback 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' }],
|
||||
});
|
||||
}
|
||||
|
||||
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.listRecentLiveThreads('/usr/local/bin/codex', {
|
||||
limit: 40,
|
||||
requestTimeoutMs: 4500,
|
||||
totalTimeoutMs: 6000,
|
||||
});
|
||||
|
||||
expect(withSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
requestTimeoutMs: 4500,
|
||||
totalTimeoutMs: 6000,
|
||||
label: 'codex app-server thread/list live',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(result).toEqual({
|
||||
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,4 +98,36 @@ describe('recentProjectOpenHistory', () => {
|
|||
).map((project) => project.id)
|
||||
).toEqual(['repo:active', 'repo:opened']);
|
||||
});
|
||||
|
||||
it('does not collapse distinct case-variant paths when history contains ambiguous entries', () => {
|
||||
recordRecentProjectOpenPaths(['/Work/Repo'], 5_000);
|
||||
recordRecentProjectOpenPaths(['/work/repo'], 8_000);
|
||||
|
||||
expect(
|
||||
getRecentProjectLastOpenedAt(
|
||||
makeProject({
|
||||
primaryPath: '/Work/Repo',
|
||||
associatedPaths: ['/Work/Repo'],
|
||||
})
|
||||
)
|
||||
).toBe(5_000);
|
||||
|
||||
expect(
|
||||
getRecentProjectLastOpenedAt(
|
||||
makeProject({
|
||||
primaryPath: '/work/repo',
|
||||
associatedPaths: ['/work/repo'],
|
||||
})
|
||||
)
|
||||
).toBe(8_000);
|
||||
|
||||
expect(
|
||||
getRecentProjectLastOpenedAt(
|
||||
makeProject({
|
||||
primaryPath: '/WORK/repo',
|
||||
associatedPaths: ['/WORK/repo'],
|
||||
})
|
||||
)
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue