fix(settings): stabilize websearch and quota checks

This commit is contained in:
Tam Nhu Tran
2026-03-25 16:31:55 -04:00
parent a97fc42b10
commit 3ea9d2fc34
5 changed files with 149 additions and 163 deletions
+14 -8
View File
@@ -180,14 +180,11 @@ export function getCliInstallHints(): string[] {
];
}
/**
* Get WebSearch readiness status for display.
*/
export function getWebSearchReadiness(): WebSearchStatus {
const wsConfig = getWebSearchConfig();
const providers = getWebSearchCliProviders();
if (!wsConfig.enabled) {
export function buildWebSearchReadiness(
enabled: boolean,
providers: WebSearchCliInfo[]
): WebSearchStatus {
if (!enabled) {
return {
readiness: 'unavailable',
message: 'Disabled in config',
@@ -223,6 +220,15 @@ export function getWebSearchReadiness(): WebSearchStatus {
};
}
/**
* Get WebSearch readiness status for display.
*/
export function getWebSearchReadiness(): WebSearchStatus {
const wsConfig = getWebSearchConfig();
const providers = getWebSearchCliProviders();
return buildWebSearchReadiness(wsConfig.enabled, providers);
}
/**
* Display WebSearch status (single line, equilibrium UX).
*/
@@ -8,16 +8,15 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
buildCodexQuotaWindows,
buildCodexCoreUsageSummary,
fetchCodexQuota,
getUnknownCodexWindowLabels,
} from '../../../src/cliproxy/quota-fetcher-codex';
let tmpDir: string;
let originalCcsHome: string | undefined;
let originalFetch: typeof fetch;
let moduleVersion = 0;
let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexQuotaWindows;
let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary;
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
function createCodexAccount(
accountId: string,
@@ -29,7 +28,26 @@ function createCodexAccount(
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload));
}
beforeEach(() => {
beforeEach(async () => {
moduleVersion += 1;
mock.restore();
const configGenerator = await import(
`../../../src/cliproxy/config-generator?codex-config-generator=${moduleVersion}`
);
const accountManager = await import(
`../../../src/cliproxy/account-manager?codex-account-manager=${moduleVersion}`
);
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
({
buildCodexQuotaWindows,
buildCodexCoreUsageSummary,
fetchCodexQuota,
getUnknownCodexWindowLabels,
} = await import(`../../../src/cliproxy/quota-fetcher-codex?codex-fetcher=${moduleVersion}`));
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-quota-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
@@ -37,6 +55,7 @@ beforeEach(() => {
});
afterEach(() => {
mock.restore();
global.fetch = originalFetch;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
@@ -7,21 +7,18 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import {
buildGeminiCliBuckets,
resolveGeminiCliProjectId,
} from '../../../src/cliproxy/quota-fetcher-gemini-cli';
import { refreshGeminiToken } from '../../../src/cliproxy/auth/gemini-token-refresh';
import { getProviderAuthDir } from '../../../src/cliproxy/config-generator';
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
describe('Gemini CLI Quota Fetcher', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalGeminiClientId: string | undefined;
let originalGeminiClientSecret: string | undefined;
let moduleVersion = 0;
let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets;
let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId;
let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken;
let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir;
function writeGeminiToken(token: Record<string, unknown>): string {
const authDir = getProviderAuthDir('gemini');
@@ -31,35 +28,51 @@ describe('Gemini CLI Quota Fetcher', () => {
return tokenPath;
}
beforeEach(() => {
beforeEach(async () => {
moduleVersion += 1;
mock.restore();
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
process.env.HOME = tempHome;
process.env.CCS_HOME = tempHome;
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
const actualConfigGenerator = await import(
`../../../src/cliproxy/config-generator?gemini-config-generator-actual=${moduleVersion}`
);
const actualAccountManager = await import(
`../../../src/cliproxy/account-manager?gemini-account-manager-actual=${moduleVersion}`
);
mock.module('../../../src/cliproxy/config-generator', () => ({
...actualConfigGenerator,
getProviderAuthDir: () => authDir,
}));
mock.module('../../../src/cliproxy/account-manager', () => ({
...actualAccountManager,
getDefaultAccount: () => null,
getProviderAccounts: () => [],
}));
const configGenerator = await import(
`../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}`
);
({ buildGeminiCliBuckets, resolveGeminiCliProjectId } = await import(
`../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}`
));
({ refreshGeminiToken } = await import(
`../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}`
));
({ getProviderAuthDir } = configGenerator);
});
afterEach(() => {
mock.restore();
restoreFetch();
fs.rmSync(tempHome, { recursive: true, force: true });
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (originalGeminiClientId === undefined) {
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
} else {
+59 -102
View File
@@ -1,126 +1,83 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getWebSearchReadiness } from '../../../../src/utils/websearch/status';
import { describe, expect, it } from 'bun:test';
import { buildWebSearchReadiness } from '../../../../src/utils/websearch/status';
import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types';
function writeWebSearchConfig(tempRoot: string, lines: string[]): void {
fs.writeFileSync(path.join(tempRoot, '.ccs', 'config.yaml'), lines.join('\n'), 'utf8');
function provider(overrides: Partial<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>): WebSearchCliInfo {
return {
id: overrides.id,
kind: overrides.kind ?? 'backend',
name: overrides.name,
enabled: overrides.enabled ?? false,
available: overrides.available ?? false,
version: overrides.version ?? null,
requiresApiKey: overrides.requiresApiKey ?? false,
description: overrides.description ?? '',
detail: overrides.detail ?? '',
...overrides,
};
}
describe('websearch readiness', () => {
const originalCcsHome = process.env.CCS_HOME;
const originalBraveKey = process.env.BRAVE_API_KEY;
const originalExaKey = process.env.EXA_API_KEY;
const originalTavilyKey = process.env.TAVILY_API_KEY;
let tempRoot = '';
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-status-'));
process.env.CCS_HOME = tempRoot;
delete process.env.BRAVE_API_KEY;
delete process.env.EXA_API_KEY;
delete process.env.TAVILY_API_KEY;
fs.mkdirSync(path.join(tempRoot, '.ccs'), { recursive: true });
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalBraveKey !== undefined) process.env.BRAVE_API_KEY = originalBraveKey;
else delete process.env.BRAVE_API_KEY;
if (originalExaKey !== undefined) process.env.EXA_API_KEY = originalExaKey;
else delete process.env.EXA_API_KEY;
if (originalTavilyKey !== undefined) process.env.TAVILY_API_KEY = originalTavilyKey;
else delete process.env.TAVILY_API_KEY;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('is ready by default because DuckDuckGo is enabled', () => {
const readiness = getWebSearchReadiness();
const readiness = buildWebSearchReadiness(true, [
provider({
id: 'duckduckgo',
name: 'DuckDuckGo',
enabled: true,
available: true,
detail: 'Built-in (5 results)',
}),
]);
expect(readiness.readiness).toBe('ready');
expect(readiness.message).toContain('DuckDuckGo');
});
it('reports setup required when only Tavily is enabled without an API key', () => {
writeWebSearchConfig(tempRoot, [
'version: 10',
'websearch:',
' enabled: true',
' providers:',
' exa:',
' enabled: false',
' max_results: 5',
' tavily:',
' enabled: true',
' max_results: 5',
' duckduckgo:',
' enabled: false',
' max_results: 5',
' brave:',
' enabled: false',
' max_results: 5',
' gemini:',
' enabled: false',
' model: "gemini-2.5-flash"',
' timeout: 55',
' opencode:',
' enabled: false',
' model: "opencode/grok-code"',
' timeout: 90',
' grok:',
' enabled: false',
' timeout: 55',
'',
const readiness = buildWebSearchReadiness(true, [
provider({
id: 'tavily',
name: 'Tavily',
enabled: true,
available: false,
requiresApiKey: true,
apiKeyEnvVar: 'TAVILY_API_KEY',
detail: 'Set TAVILY_API_KEY',
}),
provider({
id: 'duckduckgo',
name: 'DuckDuckGo',
enabled: false,
available: false,
detail: 'Built-in (5 results)',
}),
]);
const readiness = getWebSearchReadiness();
expect(readiness.readiness).toBe('needs_setup');
expect(readiness.message).toContain('Tavily');
expect(readiness.message).toContain('TAVILY_API_KEY');
});
it('prefers API-backed readiness when Exa is enabled and configured', () => {
process.env.EXA_API_KEY = 'exa-test-key';
writeWebSearchConfig(tempRoot, [
'version: 10',
'websearch:',
' enabled: true',
' providers:',
' exa:',
' enabled: true',
' max_results: 5',
' tavily:',
' enabled: false',
' max_results: 5',
' duckduckgo:',
' enabled: false',
' max_results: 5',
' brave:',
' enabled: false',
' max_results: 5',
' gemini:',
' enabled: false',
' model: "gemini-2.5-flash"',
' timeout: 55',
' opencode:',
' enabled: false',
' model: "opencode/grok-code"',
' timeout: 90',
' grok:',
' enabled: false',
' timeout: 55',
'',
const readiness = buildWebSearchReadiness(true, [
provider({
id: 'exa',
name: 'Exa',
enabled: true,
available: true,
requiresApiKey: true,
apiKeyEnvVar: 'EXA_API_KEY',
detail: 'API key detected (5 results)',
}),
provider({
id: 'duckduckgo',
name: 'DuckDuckGo',
enabled: false,
available: false,
detail: 'Built-in (5 results)',
}),
]);
const readiness = getWebSearchReadiness();
expect(readiness.readiness).toBe('ready');
expect(readiness.message).toContain('Exa');
});
@@ -350,24 +350,6 @@ export default function WebSearchSection() {
fetchRawConfig();
}, [fetchConfig, fetchStatus, fetchRawConfig]);
useEffect(() => {
if (!config?.providers) {
return;
}
const nextDrafts: Record<string, string> = {};
for (const provider of [...BACKEND_PROVIDERS, ...LEGACY_PROVIDERS]) {
for (const field of provider.fields ?? []) {
nextDrafts[`${provider.id}.${field.key}`] = getConfiguredValue(
config.providers,
provider.id,
field
);
}
}
setFieldDrafts(nextDrafts);
}, [config]);
const providerStatus = useMemo(
() => new Map((status?.providers ?? []).map((provider) => [provider.id, provider])),
[status?.providers]
@@ -410,7 +392,10 @@ export default function WebSearchSection() {
const currentProviders = (config.providers ?? {}) as WebSearchProvidersConfig;
const currentProviderConfig = currentProviders[providerId] ?? {};
const currentValue = currentProviderConfig[field.key] ?? field.defaultValue;
const normalized = normalizeFieldValue(field, fieldDrafts[fieldId] ?? String(currentValue));
const normalized = normalizeFieldValue(
field,
fieldDrafts[fieldId] ?? getConfiguredValue(currentProviders, providerId, field)
);
setFieldDrafts((current) => ({ ...current, [fieldId]: String(normalized) }));
@@ -443,7 +428,13 @@ export default function WebSearchSection() {
return {
id: fieldId,
label: field.label,
value: fieldDrafts[fieldId] ?? String(field.defaultValue),
value:
fieldDrafts[fieldId] ??
getConfiguredValue(
(config?.providers ?? {}) as WebSearchProvidersConfig,
provider.id,
field
),
placeholder: field.placeholder,
type: field.type,
helpText: field.helpText,