mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
feat(websearch): add managed mcp runtime and provider cooldowns
- switch ccs-websearch MCP to stdio-first framing and keep legacy compatibility - add cooldown persistence and bounded retries for provider failures - surface cooldown status in readiness output and regression tests
This commit is contained in:
@@ -30,6 +30,13 @@ const DDG_URL = 'https://html.duckduckgo.com/html/';
|
|||||||
const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search';
|
const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search';
|
||||||
const USER_AGENT =
|
const USER_AGENT =
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
|
||||||
|
const SHORT_RETRY_AFTER_MAX_SEC = 3;
|
||||||
|
const TRANSIENT_RETRY_DELAY_MS = 750;
|
||||||
|
const TRANSIENT_RETRY_ATTEMPTS = 1;
|
||||||
|
const DEFAULT_RATE_LIMIT_COOLDOWN_SEC = 120;
|
||||||
|
const DEFAULT_QUOTA_COOLDOWN_SEC = 900;
|
||||||
|
const MAX_PROVIDER_COOLDOWN_SEC = 60 * 60;
|
||||||
|
|
||||||
const SHARED_INSTRUCTIONS = `Instructions:
|
const SHARED_INSTRUCTIONS = `Instructions:
|
||||||
1. Search the web for current, up-to-date information
|
1. Search the web for current, up-to-date information
|
||||||
@@ -101,6 +108,111 @@ function getSafeTracePrefixes() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProviderStatePath() {
|
||||||
|
return path.join(getCcsDirPath(), 'cache', PROVIDER_STATE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProviderState() {
|
||||||
|
try {
|
||||||
|
const statePath = getProviderStatePath();
|
||||||
|
if (!fs.existsSync(statePath)) {
|
||||||
|
return { cooldowns: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||||
|
const cooldowns =
|
||||||
|
parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object'
|
||||||
|
? parsed.cooldowns
|
||||||
|
: {};
|
||||||
|
return { cooldowns };
|
||||||
|
} catch {
|
||||||
|
return { cooldowns: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeProviderState(state) {
|
||||||
|
try {
|
||||||
|
const statePath = getProviderStatePath();
|
||||||
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||||
|
const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
||||||
|
fs.renameSync(tempPath, statePath);
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeProviderState(state) {
|
||||||
|
const now = Date.now();
|
||||||
|
const nextCooldowns = {};
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const [providerId, entry] of Object.entries(state.cooldowns || {})) {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = Number.parseInt(String(entry.until || ''), 10);
|
||||||
|
if (!Number.isFinite(until) || until <= now) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCooldowns[providerId] = {
|
||||||
|
until,
|
||||||
|
reason: typeof entry.reason === 'string' ? entry.reason : 'rate_limited',
|
||||||
|
updatedAt: Number.parseInt(String(entry.updatedAt || ''), 10) || now,
|
||||||
|
sourceError: typeof entry.sourceError === 'string' ? entry.sourceError : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: { cooldowns: nextCooldowns },
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderCooldown(providerId) {
|
||||||
|
const { state, changed } = sanitizeProviderState(readProviderState());
|
||||||
|
if (changed) {
|
||||||
|
writeProviderState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.cooldowns[providerId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProviderCooldown(providerId) {
|
||||||
|
const { state } = sanitizeProviderState(readProviderState());
|
||||||
|
if (!(providerId in state.cooldowns)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete state.cooldowns[providerId];
|
||||||
|
writeProviderState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProviderCooldown(providerId, cooldownSec, reason, sourceError) {
|
||||||
|
const clampedCooldownSec = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(MAX_PROVIDER_COOLDOWN_SEC, Math.floor(cooldownSec))
|
||||||
|
);
|
||||||
|
const { state } = sanitizeProviderState(readProviderState());
|
||||||
|
const until = Date.now() + clampedCooldownSec * 1000;
|
||||||
|
state.cooldowns[providerId] = {
|
||||||
|
until,
|
||||||
|
reason,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
sourceError: sourceError || '',
|
||||||
|
};
|
||||||
|
writeProviderState(state);
|
||||||
|
return until;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
function getAllowedTraceFileOverride() {
|
function getAllowedTraceFileOverride() {
|
||||||
const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim();
|
const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim();
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
@@ -146,6 +258,42 @@ function traceWebSearchEvent(event, payload = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readHeaderValue(headers, headerName) {
|
||||||
|
if (!headers) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof headers.get === 'function') {
|
||||||
|
return headers.get(headerName) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = headers[headerName] ?? headers[String(headerName).toLowerCase()];
|
||||||
|
if (Array.isArray(direct)) {
|
||||||
|
return direct[0] || '';
|
||||||
|
}
|
||||||
|
return typeof direct === 'string' ? direct : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRetryAfterSeconds(rawValue) {
|
||||||
|
const value = String(rawValue || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asSeconds = Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(asSeconds) && asSeconds > 0) {
|
||||||
|
return asSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asDate = Date.parse(value);
|
||||||
|
if (Number.isFinite(asDate)) {
|
||||||
|
const deltaSec = Math.ceil((asDate - Date.now()) / 1000);
|
||||||
|
return deltaSec > 0 ? deltaSec : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getQueryFingerprint(query) {
|
function getQueryFingerprint(query) {
|
||||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||||
return {
|
return {
|
||||||
@@ -371,6 +519,8 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`,
|
error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`,
|
||||||
|
statusCode: response.status,
|
||||||
|
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +572,12 @@ async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
return { success: false, error: `Exa returned ${response.status}: ${body.slice(0, 160)}` };
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Exa returned ${response.status}: ${body.slice(0, 160)}`,
|
||||||
|
statusCode: response.status,
|
||||||
|
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
@@ -474,7 +629,12 @@ async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
return { success: false, error: `Tavily returned ${response.status}: ${body.slice(0, 160)}` };
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Tavily returned ${response.status}: ${body.slice(0, 160)}`,
|
||||||
|
statusCode: response.status,
|
||||||
|
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
@@ -511,7 +671,12 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return { success: false, error: `DuckDuckGo returned ${response.status}` };
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `DuckDuckGo returned ${response.status}`,
|
||||||
|
statusCode: response.status,
|
||||||
|
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
@@ -720,8 +885,174 @@ function getConfiguredProviders() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeQuotaExhaustion(errorMessage) {
|
||||||
|
const lower = String(errorMessage || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
(lower.includes('quota') &&
|
||||||
|
(lower.includes('exceed') ||
|
||||||
|
lower.includes('exhaust') ||
|
||||||
|
lower.includes('deplet') ||
|
||||||
|
lower.includes('limit') ||
|
||||||
|
lower.includes('used up'))) ||
|
||||||
|
lower.includes('insufficient credits') ||
|
||||||
|
lower.includes('credit balance') ||
|
||||||
|
lower.includes('out of credits') ||
|
||||||
|
lower.includes('billing hard limit') ||
|
||||||
|
lower.includes('monthly usage cap')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeTransientFailure(errorMessage) {
|
||||||
|
const lower = String(errorMessage || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
lower.includes('timed out') ||
|
||||||
|
lower.includes('timeout') ||
|
||||||
|
lower.includes('temporarily unavailable') ||
|
||||||
|
lower.includes('service unavailable') ||
|
||||||
|
lower.includes('bad gateway') ||
|
||||||
|
lower.includes('gateway timeout') ||
|
||||||
|
lower.includes('socket hang up') ||
|
||||||
|
lower.includes('econnreset') ||
|
||||||
|
lower.includes('fetch failed') ||
|
||||||
|
lower.includes('network')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyProviderFailure(result) {
|
||||||
|
const errorMessage = String(result.error || '');
|
||||||
|
const statusCode =
|
||||||
|
Number.isFinite(result.statusCode) && result.statusCode > 0 ? result.statusCode : null;
|
||||||
|
const retryAfterSec = Number.isFinite(result.retryAfterSec) ? result.retryAfterSec : null;
|
||||||
|
|
||||||
|
if (looksLikeQuotaExhaustion(errorMessage)) {
|
||||||
|
return {
|
||||||
|
kind: 'cooldown',
|
||||||
|
reason: 'quota_exhausted',
|
||||||
|
cooldownSec: retryAfterSec || DEFAULT_QUOTA_COOLDOWN_SEC,
|
||||||
|
retryAfterSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode === 429 || /too many requests|rate limit/i.test(errorMessage)) {
|
||||||
|
if (retryAfterSec && retryAfterSec <= SHORT_RETRY_AFTER_MAX_SEC) {
|
||||||
|
return {
|
||||||
|
kind: 'retry',
|
||||||
|
delayMs: retryAfterSec * 1000,
|
||||||
|
reason: 'rate_limited_short_backoff',
|
||||||
|
retryAfterSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'cooldown',
|
||||||
|
reason: 'rate_limited',
|
||||||
|
cooldownSec: retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC,
|
||||||
|
retryAfterSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(statusCode && [502, 503, 504].includes(statusCode)) ||
|
||||||
|
looksLikeTransientFailure(errorMessage)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: 'retry',
|
||||||
|
delayMs: TRANSIENT_RETRY_DELAY_MS,
|
||||||
|
reason: 'transient_failure',
|
||||||
|
retryAfterSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'fail',
|
||||||
|
reason: 'non_retryable',
|
||||||
|
retryAfterSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) {
|
||||||
|
for (let attempt = 0; attempt <= TRANSIENT_RETRY_ATTEMPTS; attempt += 1) {
|
||||||
|
traceWebSearchEvent('websearch_provider_attempt', {
|
||||||
|
source: 'provider',
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
attempt: attempt + 1,
|
||||||
|
...fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await provider.fn(query, timeoutSec);
|
||||||
|
if (result.success) {
|
||||||
|
clearProviderCooldown(provider.id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = classifyProviderFailure(result);
|
||||||
|
if (policy.kind === 'retry' && attempt < TRANSIENT_RETRY_ATTEMPTS) {
|
||||||
|
traceWebSearchEvent('websearch_provider_retry_scheduled', {
|
||||||
|
source: 'provider',
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
attempt: attempt + 1,
|
||||||
|
delayMs: policy.delayMs,
|
||||||
|
reason: policy.reason,
|
||||||
|
retryAfterSec: policy.retryAfterSec,
|
||||||
|
...fingerprint,
|
||||||
|
});
|
||||||
|
await sleep(policy.delayMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.kind === 'retry' && policy.reason === 'rate_limited_short_backoff') {
|
||||||
|
const cooldownSec = policy.retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC;
|
||||||
|
const until = applyProviderCooldown(provider.id, cooldownSec, 'rate_limited', result.error);
|
||||||
|
traceWebSearchEvent('websearch_provider_cooldown_applied', {
|
||||||
|
source: 'provider',
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
cooldownUntil: until,
|
||||||
|
cooldownSec,
|
||||||
|
reason: 'rate_limited',
|
||||||
|
retryAfterSec: policy.retryAfterSec,
|
||||||
|
afterRetryExhausted: true,
|
||||||
|
...fingerprint,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
error: `${result.error} (cooldown ${cooldownSec}s)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.kind === 'cooldown') {
|
||||||
|
const until = applyProviderCooldown(
|
||||||
|
provider.id,
|
||||||
|
policy.cooldownSec,
|
||||||
|
policy.reason,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
traceWebSearchEvent('websearch_provider_cooldown_applied', {
|
||||||
|
source: 'provider',
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
cooldownUntil: until,
|
||||||
|
cooldownSec: policy.cooldownSec,
|
||||||
|
reason: policy.reason,
|
||||||
|
retryAfterSec: policy.retryAfterSec,
|
||||||
|
...fingerprint,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
error: `${result.error} (cooldown ${policy.cooldownSec}s)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'Provider retry policy exhausted' };
|
||||||
|
}
|
||||||
|
|
||||||
function getActiveProviders() {
|
function getActiveProviders() {
|
||||||
return getConfiguredProviders().filter((provider) => provider.available());
|
return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available());
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveProviderIds() {
|
function getActiveProviderIds() {
|
||||||
@@ -733,8 +1064,29 @@ function hasAnyActiveProviders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||||
const activeProviders = getActiveProviders();
|
|
||||||
const fingerprint = getQueryFingerprint(query);
|
const fingerprint = getQueryFingerprint(query);
|
||||||
|
const configuredProviders = getConfiguredProviders();
|
||||||
|
const activeProviders = [];
|
||||||
|
|
||||||
|
for (const provider of configuredProviders) {
|
||||||
|
const cooldown = getProviderCooldown(provider.id);
|
||||||
|
if (cooldown) {
|
||||||
|
traceWebSearchEvent('websearch_provider_cooldown_skip', {
|
||||||
|
source: 'provider',
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
cooldownUntil: cooldown.until,
|
||||||
|
cooldownReason: cooldown.reason,
|
||||||
|
remainingMs: Math.max(0, cooldown.until - Date.now()),
|
||||||
|
...fingerprint,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.available()) {
|
||||||
|
activeProviders.push(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debug(
|
debug(
|
||||||
`Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
|
`Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
|
||||||
@@ -757,13 +1109,7 @@ async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
for (const provider of activeProviders) {
|
for (const provider of activeProviders) {
|
||||||
debug(`Trying ${provider.name}`);
|
debug(`Trying ${provider.name}`);
|
||||||
traceWebSearchEvent('websearch_provider_attempt', {
|
const result = await runProviderWithPolicy(provider, query, timeoutSec, fingerprint);
|
||||||
source: 'provider',
|
|
||||||
providerId: provider.id,
|
|
||||||
providerName: provider.name,
|
|
||||||
...fingerprint,
|
|
||||||
});
|
|
||||||
const result = await provider.fn(query, timeoutSec);
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
traceWebSearchEvent('websearch_provider_success', {
|
traceWebSearchEvent('websearch_provider_success', {
|
||||||
source: 'provider',
|
source: 'provider',
|
||||||
@@ -890,8 +1236,10 @@ module.exports = {
|
|||||||
runLocalWebSearch,
|
runLocalWebSearch,
|
||||||
shouldSkipHook,
|
shouldSkipHook,
|
||||||
getActiveProviderIds,
|
getActiveProviderIds,
|
||||||
|
classifyProviderFailure,
|
||||||
getQueryFingerprint,
|
getQueryFingerprint,
|
||||||
getSkipReason,
|
getSkipReason,
|
||||||
|
parseRetryAfterSeconds,
|
||||||
traceWebSearchEvent,
|
traceWebSearchEvent,
|
||||||
tryExaSearch,
|
tryExaSearch,
|
||||||
tryTavilySearch,
|
tryTavilySearch,
|
||||||
|
|||||||
@@ -61,9 +61,7 @@ function getTools() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeMessage(message) {
|
function writeMessage(message) {
|
||||||
const body = Buffer.from(JSON.stringify(message), 'utf8');
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||||
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
||||||
process.stdout.write(body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeResponse(id, result) {
|
function writeResponse(id, result) {
|
||||||
@@ -262,26 +260,46 @@ function writeSessionSummary(exitCodeOrSignal) {
|
|||||||
|
|
||||||
function parseMessages() {
|
function parseMessages() {
|
||||||
while (true) {
|
while (true) {
|
||||||
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
|
let body;
|
||||||
if (headerEnd === -1) {
|
const startsWithLegacyHeaders = inputBuffer
|
||||||
return;
|
.slice(0, Math.min(inputBuffer.length, 32))
|
||||||
}
|
.toString('utf8')
|
||||||
|
.toLowerCase()
|
||||||
|
.startsWith('content-length:');
|
||||||
|
|
||||||
const headerText = inputBuffer.slice(0, headerEnd).toString('utf8');
|
if (startsWithLegacyHeaders) {
|
||||||
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
|
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
|
||||||
if (!contentLengthMatch) {
|
if (headerEnd === -1) {
|
||||||
inputBuffer = Buffer.alloc(0);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
const headerText = inputBuffer.slice(0, headerEnd).toString('utf8');
|
||||||
const messageEnd = headerEnd + 4 + contentLength;
|
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
|
||||||
if (inputBuffer.length < messageEnd) {
|
if (!contentLengthMatch) {
|
||||||
return;
|
inputBuffer = Buffer.alloc(0);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
|
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
||||||
inputBuffer = inputBuffer.slice(messageEnd);
|
const messageEnd = headerEnd + 4 + contentLength;
|
||||||
|
if (inputBuffer.length < messageEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
|
||||||
|
inputBuffer = inputBuffer.slice(messageEnd);
|
||||||
|
} else {
|
||||||
|
const newlineIndex = inputBuffer.indexOf('\n');
|
||||||
|
if (newlineIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
|
||||||
|
inputBuffer = inputBuffer.slice(newlineIndex + 1);
|
||||||
|
if (!body) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let message;
|
let message;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ interface ClaudeUserConfig {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ManagedWebSearchMcpConfig {
|
||||||
|
type: 'stdio';
|
||||||
|
command: 'node';
|
||||||
|
args: [string];
|
||||||
|
env: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
function getCcsMcpDir(): string {
|
function getCcsMcpDir(): string {
|
||||||
return path.join(getCcsDir(), 'mcp');
|
return path.join(getCcsDir(), 'mcp');
|
||||||
}
|
}
|
||||||
@@ -91,9 +98,11 @@ function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null {
|
|||||||
|
|
||||||
function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean {
|
function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean {
|
||||||
const tempPath = getTempPath(configPath);
|
const tempPath = getTempPath(configPath);
|
||||||
|
const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||||
|
fs.chmodSync(tempPath, fileMode);
|
||||||
fs.renameSync(tempPath, configPath);
|
fs.renameSync(tempPath, configPath);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -252,9 +261,11 @@ export function ensureWebSearchMcpConfig(): boolean {
|
|||||||
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
|
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
|
||||||
? (config.mcpServers as Record<string, unknown>)
|
? (config.mcpServers as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
const desiredServerConfig = {
|
const desiredServerConfig: ManagedWebSearchMcpConfig = {
|
||||||
|
type: 'stdio',
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [getWebSearchMcpServerPath()],
|
args: [getWebSearchMcpServerPath()],
|
||||||
|
env: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME];
|
const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME];
|
||||||
|
|||||||
@@ -6,18 +6,105 @@
|
|||||||
* @module utils/websearch/status
|
* @module utils/websearch/status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
import { ok, warn, fail, info } from '../ui';
|
import { ok, warn, fail, info } from '../ui';
|
||||||
import { getWebSearchConfig } from '../../config/unified-config-loader';
|
import { getWebSearchConfig } from '../../config/unified-config-loader';
|
||||||
|
import { getCcsDir } from '../config-manager';
|
||||||
import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli';
|
import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli';
|
||||||
import { getGrokCliStatus } from './grok-cli';
|
import { getGrokCliStatus } from './grok-cli';
|
||||||
import { getOpenCodeCliStatus } from './opencode-cli';
|
import { getOpenCodeCliStatus } from './opencode-cli';
|
||||||
import { getWebSearchApiKeyStates } from './provider-secrets';
|
import { getWebSearchApiKeyStates } from './provider-secrets';
|
||||||
import type { WebSearchCliInfo, WebSearchStatus } from './types';
|
import type { WebSearchCliInfo, WebSearchStatus } from './types';
|
||||||
|
|
||||||
|
const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
|
||||||
|
|
||||||
|
type ProviderCooldown = {
|
||||||
|
reason: string;
|
||||||
|
until: number;
|
||||||
|
};
|
||||||
|
|
||||||
function hasEnvValue(name: string): boolean {
|
function hasEnvValue(name: string): boolean {
|
||||||
return (process.env[name] || '').trim().length > 0;
|
return (process.env[name] || '').trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProviderStatePath(): string {
|
||||||
|
return join(getCcsDir(), 'cache', PROVIDER_STATE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProviderCooldowns(now = Date.now()): Record<string, ProviderCooldown> {
|
||||||
|
try {
|
||||||
|
const statePath = getProviderStatePath();
|
||||||
|
if (!existsSync(statePath)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as {
|
||||||
|
cooldowns?: Record<string, { reason?: unknown; until?: unknown }>;
|
||||||
|
};
|
||||||
|
const nextCooldowns: Record<string, ProviderCooldown> = {};
|
||||||
|
|
||||||
|
for (const [providerId, entry] of Object.entries(parsed.cooldowns || {})) {
|
||||||
|
const until = Number.parseInt(String(entry?.until || ''), 10);
|
||||||
|
if (!Number.isFinite(until) || until <= now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCooldowns[providerId] = {
|
||||||
|
reason: typeof entry?.reason === 'string' ? entry.reason : 'rate_limited',
|
||||||
|
until,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextCooldowns;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCooldownDuration(until: number, now = Date.now()): string {
|
||||||
|
const remainingSec = Math.max(1, Math.ceil((until - now) / 1000));
|
||||||
|
if (remainingSec >= 3600) {
|
||||||
|
return `~${Math.ceil(remainingSec / 3600)}h`;
|
||||||
|
}
|
||||||
|
if (remainingSec >= 60) {
|
||||||
|
return `~${Math.ceil(remainingSec / 60)}m`;
|
||||||
|
}
|
||||||
|
return `~${remainingSec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCooldownReason(reason: string): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'quota_exhausted':
|
||||||
|
return 'quota exhaustion';
|
||||||
|
case 'rate_limited':
|
||||||
|
return 'rate limiting';
|
||||||
|
default:
|
||||||
|
return 'a temporary provider error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCooldownStatus(
|
||||||
|
provider: WebSearchCliInfo,
|
||||||
|
cooldowns: Record<string, ProviderCooldown>,
|
||||||
|
now = Date.now()
|
||||||
|
): WebSearchCliInfo {
|
||||||
|
if (!(provider.enabled && provider.available)) {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldown = cooldowns[provider.id];
|
||||||
|
if (!cooldown) {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...provider,
|
||||||
|
available: false,
|
||||||
|
detail: `Cooling down ${formatCooldownDuration(cooldown.until, now)} after ${formatCooldownReason(cooldown.reason)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getLegacyProviderStatuses(): WebSearchCliInfo[] {
|
function getLegacyProviderStatuses(): WebSearchCliInfo[] {
|
||||||
const wsConfig = getWebSearchConfig();
|
const wsConfig = getWebSearchConfig();
|
||||||
const geminiStatus = getGeminiCliStatus();
|
const geminiStatus = getGeminiCliStatus();
|
||||||
@@ -86,6 +173,7 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] {
|
|||||||
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
||||||
const wsConfig = getWebSearchConfig();
|
const wsConfig = getWebSearchConfig();
|
||||||
const apiKeyStates = getWebSearchApiKeyStates();
|
const apiKeyStates = getWebSearchApiKeyStates();
|
||||||
|
const cooldowns = readProviderCooldowns();
|
||||||
const providers: WebSearchCliInfo[] = [
|
const providers: WebSearchCliInfo[] = [
|
||||||
{
|
{
|
||||||
id: 'exa',
|
id: 'exa',
|
||||||
@@ -152,7 +240,9 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return [...providers, ...getLegacyProviderStatuses()];
|
return [...providers, ...getLegacyProviderStatuses()].map((provider) =>
|
||||||
|
applyCooldownStatus(provider, cooldowns)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { join } from 'node:path';
|
|||||||
const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-websearch-server.cjs');
|
const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-websearch-server.cjs');
|
||||||
|
|
||||||
function encodeMessage(message: unknown): string {
|
function encodeMessage(message: unknown): string {
|
||||||
|
return `${JSON.stringify(message)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeLegacyMessage(message: unknown): string {
|
||||||
const body = JSON.stringify(message);
|
const body = JSON.stringify(message);
|
||||||
return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
|
return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
|
||||||
}
|
}
|
||||||
@@ -22,26 +26,47 @@ function collectResponses(
|
|||||||
|
|
||||||
function tryParse(): void {
|
function tryParse(): void {
|
||||||
while (true) {
|
while (true) {
|
||||||
const headerEnd = buffer.indexOf('\r\n\r\n');
|
const startsWithLegacyHeaders = buffer
|
||||||
if (headerEnd === -1) {
|
.slice(0, Math.min(buffer.length, 32))
|
||||||
return;
|
.toString('utf8')
|
||||||
|
.toLowerCase()
|
||||||
|
.startsWith('content-length:');
|
||||||
|
|
||||||
|
let body: string;
|
||||||
|
if (startsWithLegacyHeaders) {
|
||||||
|
const headerEnd = buffer.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerText = buffer.slice(0, headerEnd).toString('utf8');
|
||||||
|
const match = headerText.match(/content-length:\s*(\d+)/i);
|
||||||
|
if (!match) {
|
||||||
|
reject(new Error('Missing Content-Length header'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number.parseInt(match[1], 10);
|
||||||
|
const messageEnd = headerEnd + 4 + contentLength;
|
||||||
|
if (buffer.length < messageEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = buffer.slice(headerEnd + 4, messageEnd).toString('utf8');
|
||||||
|
buffer = buffer.slice(messageEnd);
|
||||||
|
} else {
|
||||||
|
const newlineIndex = buffer.indexOf('\n');
|
||||||
|
if (newlineIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = buffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
|
||||||
|
buffer = buffer.slice(newlineIndex + 1);
|
||||||
|
if (!body) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerText = buffer.slice(0, headerEnd).toString('utf8');
|
|
||||||
const match = headerText.match(/content-length:\s*(\d+)/i);
|
|
||||||
if (!match) {
|
|
||||||
reject(new Error('Missing Content-Length header'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = Number.parseInt(match[1], 10);
|
|
||||||
const messageEnd = headerEnd + 4 + contentLength;
|
|
||||||
if (buffer.length < messageEnd) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = buffer.slice(headerEnd + 4, messageEnd).toString('utf8');
|
|
||||||
buffer = buffer.slice(messageEnd);
|
|
||||||
responses.push(JSON.parse(body) as Record<string, unknown>);
|
responses.push(JSON.parse(body) as Record<string, unknown>);
|
||||||
|
|
||||||
if (responses.length >= expectedCount) {
|
if (responses.length >= expectedCount) {
|
||||||
@@ -374,4 +399,40 @@ describe('ccs-websearch MCP server', () => {
|
|||||||
|
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts legacy Content-Length framed requests for compatibility', async () => {
|
||||||
|
const child = spawn('node', [serverPath], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CCS_PROFILE_TYPE: 'account',
|
||||||
|
CCS_WEBSEARCH_ENABLED: '1',
|
||||||
|
CCS_WEBSEARCH_SKIP: '1',
|
||||||
|
CCS_WEBSEARCH_DUCKDUCKGO: '1',
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const responsesPromise = collectResponses(child, 2);
|
||||||
|
child.stdin.write(
|
||||||
|
encodeLegacyMessage({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'bun-test', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
child.stdin.write(encodeLegacyMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
|
||||||
|
|
||||||
|
const responses = await responsesPromise;
|
||||||
|
const toolsList = responses.find((message) => message.id === 2);
|
||||||
|
expect(toolsList?.result).toEqual({ tools: [] });
|
||||||
|
} finally {
|
||||||
|
child.kill();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
@@ -29,11 +36,18 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
|
|||||||
url: string;
|
url: string;
|
||||||
description: string;
|
description: string;
|
||||||
}>;
|
}>;
|
||||||
|
classifyProviderFailure: (result: {
|
||||||
|
error?: string;
|
||||||
|
retryAfterSec?: number | null;
|
||||||
|
statusCode?: number | null;
|
||||||
|
success?: boolean;
|
||||||
|
}) => Record<string, unknown>;
|
||||||
formatStructuredSearchResults: (
|
formatStructuredSearchResults: (
|
||||||
query: string,
|
query: string,
|
||||||
providerName: string,
|
providerName: string,
|
||||||
results: Array<{ title: string; url: string; description: string }>
|
results: Array<{ title: string; url: string; description: string }>
|
||||||
) => string;
|
) => string;
|
||||||
|
parseRetryAfterSeconds: (rawValue: string) => number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function runHookWithMockedFetch(mode: 'success' | 'failure') {
|
function runHookWithMockedFetch(mode: 'success' | 'failure') {
|
||||||
@@ -76,6 +90,42 @@ function runHookWithMockedFetch(mode: 'success' | 'failure') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('websearch-transformer hook helpers', () => {
|
describe('websearch-transformer hook helpers', () => {
|
||||||
|
it('parses Retry-After seconds and HTTP dates', () => {
|
||||||
|
expect(hook.parseRetryAfterSeconds('2')).toBe(2);
|
||||||
|
expect(
|
||||||
|
hook.parseRetryAfterSeconds(new Date(Date.now() + 2000).toUTCString())
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(hook.parseRetryAfterSeconds('invalid')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies quota exhaustion and short rate limits into the correct provider policy', () => {
|
||||||
|
expect(
|
||||||
|
hook.classifyProviderFailure({
|
||||||
|
success: false,
|
||||||
|
statusCode: 429,
|
||||||
|
error: 'Exa returned 429: quota exceeded for current plan',
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
kind: 'cooldown',
|
||||||
|
reason: 'quota_exhausted',
|
||||||
|
cooldownSec: 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
hook.classifyProviderFailure({
|
||||||
|
success: false,
|
||||||
|
statusCode: 429,
|
||||||
|
retryAfterSec: 2,
|
||||||
|
error: 'Brave Search returned 429: rate limit exceeded',
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
kind: 'retry',
|
||||||
|
reason: 'rate_limited_short_backoff',
|
||||||
|
delayMs: 2000,
|
||||||
|
retryAfterSec: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => {
|
it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => {
|
||||||
const html = `
|
const html = `
|
||||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
||||||
@@ -320,4 +370,320 @@ describe('websearch-transformer hook helpers', () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies provider cooldown on quota exhaustion and falls back to the next backend', () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-quota-'));
|
||||||
|
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||||
|
const requestLogPath = join(tempDir, 'requests.json');
|
||||||
|
const ccsHome = join(tempDir, 'home');
|
||||||
|
const statePath = join(ccsHome, '.ccs', 'cache', 'websearch-provider-state.json');
|
||||||
|
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
|
||||||
|
const html = `
|
||||||
|
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Fallback title</a>
|
||||||
|
<a class="result__snippet">Fallback snippet</a>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
preloadPath,
|
||||||
|
`
|
||||||
|
const fs = require('fs');
|
||||||
|
const requestLogPath = ${JSON.stringify(requestLogPath)};
|
||||||
|
const html = ${JSON.stringify(html)};
|
||||||
|
function record(url) {
|
||||||
|
const requests = fs.existsSync(requestLogPath)
|
||||||
|
? JSON.parse(fs.readFileSync(requestLogPath, 'utf8'))
|
||||||
|
: [];
|
||||||
|
requests.push(String(url));
|
||||||
|
fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8');
|
||||||
|
}
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
const resolvedUrl = String(url);
|
||||||
|
record(resolvedUrl);
|
||||||
|
if (resolvedUrl.includes('api.exa.ai')) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
headers: { get: () => null },
|
||||||
|
text: async () => 'quota exceeded for current plan',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => null },
|
||||||
|
text: async () => html,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
`.trimStart(),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync('node', ['-r', preloadPath, hookPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: JSON.stringify({
|
||||||
|
tool_name: 'WebSearch',
|
||||||
|
tool_input: { query: 'btc price' },
|
||||||
|
}),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CCS_HOME: ccsHome,
|
||||||
|
CCS_WEBSEARCH_TRACE: '1',
|
||||||
|
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'quota-fallback-test',
|
||||||
|
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
|
||||||
|
CCS_WEBSEARCH_ENABLED: '1',
|
||||||
|
CCS_WEBSEARCH_SKIP: '0',
|
||||||
|
CCS_WEBSEARCH_BRAVE: '0',
|
||||||
|
CCS_WEBSEARCH_DUCKDUCKGO: '1',
|
||||||
|
CCS_WEBSEARCH_EXA: '1',
|
||||||
|
CCS_WEBSEARCH_GEMINI: '0',
|
||||||
|
CCS_WEBSEARCH_GROK: '0',
|
||||||
|
CCS_WEBSEARCH_OPENCODE: '0',
|
||||||
|
CCS_WEBSEARCH_TAVILY: '0',
|
||||||
|
EXA_API_KEY: 'exa-test-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
const output = JSON.parse(result.stdout.trim()) as HookOutput;
|
||||||
|
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo');
|
||||||
|
|
||||||
|
const providerState = JSON.parse(readFileSync(statePath, 'utf8')) as {
|
||||||
|
cooldowns?: Record<string, { reason?: string; until?: number }>;
|
||||||
|
};
|
||||||
|
expect(providerState.cooldowns?.exa?.reason).toBe('quota_exhausted');
|
||||||
|
expect(providerState.cooldowns?.exa?.until).toBeGreaterThan(Date.now());
|
||||||
|
|
||||||
|
const traceEvents = readFileSync(tracePath, 'utf8')
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||||
|
expect(
|
||||||
|
traceEvents.some(
|
||||||
|
(event) =>
|
||||||
|
event.event === 'websearch_provider_cooldown_applied' &&
|
||||||
|
event.providerId === 'exa' &&
|
||||||
|
event.reason === 'quota_exhausted'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
traceEvents.some(
|
||||||
|
(event) =>
|
||||||
|
event.event === 'websearch_provider_success' &&
|
||||||
|
event.providerId === 'duckduckgo'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips providers that are already cooling down on later WebSearch calls', () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-cooldown-skip-'));
|
||||||
|
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||||
|
const requestLogPath = join(tempDir, 'requests.json');
|
||||||
|
const ccsHome = join(tempDir, 'home');
|
||||||
|
const statePath = join(ccsHome, '.ccs', 'cache', 'websearch-provider-state.json');
|
||||||
|
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
|
||||||
|
const html = `
|
||||||
|
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fcooldown">Cooldown title</a>
|
||||||
|
<a class="result__snippet">Cooldown snippet</a>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
mkdirSync(join(ccsHome, '.ccs', 'cache'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
cooldowns: {
|
||||||
|
exa: {
|
||||||
|
until: Date.now() + 10 * 60 * 1000,
|
||||||
|
reason: 'quota_exhausted',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
preloadPath,
|
||||||
|
`
|
||||||
|
const fs = require('fs');
|
||||||
|
const requestLogPath = ${JSON.stringify(requestLogPath)};
|
||||||
|
const html = ${JSON.stringify(html)};
|
||||||
|
function record(url) {
|
||||||
|
const requests = fs.existsSync(requestLogPath)
|
||||||
|
? JSON.parse(fs.readFileSync(requestLogPath, 'utf8'))
|
||||||
|
: [];
|
||||||
|
requests.push(String(url));
|
||||||
|
fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8');
|
||||||
|
}
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
record(url);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => null },
|
||||||
|
text: async () => html,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
`.trimStart(),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync('node', ['-r', preloadPath, hookPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: JSON.stringify({
|
||||||
|
tool_name: 'WebSearch',
|
||||||
|
tool_input: { query: 'btc price' },
|
||||||
|
}),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CCS_HOME: ccsHome,
|
||||||
|
CCS_WEBSEARCH_TRACE: '1',
|
||||||
|
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'cooldown-skip-test',
|
||||||
|
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
|
||||||
|
CCS_WEBSEARCH_ENABLED: '1',
|
||||||
|
CCS_WEBSEARCH_SKIP: '0',
|
||||||
|
CCS_WEBSEARCH_BRAVE: '0',
|
||||||
|
CCS_WEBSEARCH_DUCKDUCKGO: '1',
|
||||||
|
CCS_WEBSEARCH_EXA: '1',
|
||||||
|
CCS_WEBSEARCH_GEMINI: '0',
|
||||||
|
CCS_WEBSEARCH_GROK: '0',
|
||||||
|
CCS_WEBSEARCH_OPENCODE: '0',
|
||||||
|
CCS_WEBSEARCH_TAVILY: '0',
|
||||||
|
EXA_API_KEY: 'exa-test-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
const output = JSON.parse(result.stdout.trim()) as HookOutput;
|
||||||
|
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo');
|
||||||
|
|
||||||
|
const requests = JSON.parse(readFileSync(requestLogPath, 'utf8')) as string[];
|
||||||
|
expect(requests.some((url) => url.includes('api.exa.ai'))).toBe(false);
|
||||||
|
|
||||||
|
const traceEvents = readFileSync(tracePath, 'utf8')
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||||
|
expect(
|
||||||
|
traceEvents.some(
|
||||||
|
(event) =>
|
||||||
|
event.event === 'websearch_provider_cooldown_skip' &&
|
||||||
|
event.providerId === 'exa' &&
|
||||||
|
event.cooldownReason === 'quota_exhausted'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries transient backend failures once before succeeding', () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-retry-'));
|
||||||
|
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||||
|
const requestLogPath = join(tempDir, 'requests.json');
|
||||||
|
const ccsHome = join(tempDir, 'home');
|
||||||
|
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
preloadPath,
|
||||||
|
`
|
||||||
|
const fs = require('fs');
|
||||||
|
const requestLogPath = ${JSON.stringify(requestLogPath)};
|
||||||
|
let exaAttempts = 0;
|
||||||
|
function record(url) {
|
||||||
|
const requests = fs.existsSync(requestLogPath)
|
||||||
|
? JSON.parse(fs.readFileSync(requestLogPath, 'utf8'))
|
||||||
|
: [];
|
||||||
|
requests.push(String(url));
|
||||||
|
fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8');
|
||||||
|
}
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
const resolvedUrl = String(url);
|
||||||
|
record(resolvedUrl);
|
||||||
|
exaAttempts += 1;
|
||||||
|
if (exaAttempts === 1) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
headers: { get: () => null },
|
||||||
|
text: async () => 'service unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: async () => ({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: 'Exa title',
|
||||||
|
url: 'https://example.com/exa',
|
||||||
|
text: 'Exa snippet',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
`.trimStart(),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync('node', ['-r', preloadPath, hookPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: JSON.stringify({
|
||||||
|
tool_name: 'WebSearch',
|
||||||
|
tool_input: { query: 'btc price' },
|
||||||
|
}),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CCS_HOME: ccsHome,
|
||||||
|
CCS_WEBSEARCH_TRACE: '1',
|
||||||
|
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'transient-retry-test',
|
||||||
|
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
|
||||||
|
CCS_WEBSEARCH_ENABLED: '1',
|
||||||
|
CCS_WEBSEARCH_SKIP: '0',
|
||||||
|
CCS_WEBSEARCH_BRAVE: '0',
|
||||||
|
CCS_WEBSEARCH_DUCKDUCKGO: '0',
|
||||||
|
CCS_WEBSEARCH_EXA: '1',
|
||||||
|
CCS_WEBSEARCH_GEMINI: '0',
|
||||||
|
CCS_WEBSEARCH_GROK: '0',
|
||||||
|
CCS_WEBSEARCH_OPENCODE: '0',
|
||||||
|
CCS_WEBSEARCH_TAVILY: '0',
|
||||||
|
EXA_API_KEY: 'exa-test-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
const output = JSON.parse(result.stdout.trim()) as HookOutput;
|
||||||
|
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: Exa');
|
||||||
|
|
||||||
|
const requests = JSON.parse(readFileSync(requestLogPath, 'utf8')) as string[];
|
||||||
|
expect(requests.filter((url) => url.includes('api.exa.ai'))).toHaveLength(2);
|
||||||
|
|
||||||
|
const traceEvents = readFileSync(tracePath, 'utf8')
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||||
|
expect(
|
||||||
|
traceEvents.some(
|
||||||
|
(event) =>
|
||||||
|
event.event === 'websearch_provider_retry_scheduled' &&
|
||||||
|
event.providerId === 'exa' &&
|
||||||
|
event.reason === 'transient_failure'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
traceEvents.some(
|
||||||
|
(event) =>
|
||||||
|
event.event === 'websearch_provider_success' && event.providerId === 'exa'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,7 +150,12 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
'ccs-websearch': { command: 'node', args: ['/global/server.cjs'] },
|
'ccs-websearch': {
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['/global/server.cjs'],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
shared: { command: 'global-shared' },
|
shared: { command: 'global-shared' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -168,7 +173,12 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
'ccs-websearch': { command: 'node', args: ['/old/server.cjs'] },
|
'ccs-websearch': {
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['/old/server.cjs'],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
shared: { command: 'instance-shared' },
|
shared: { command: 'instance-shared' },
|
||||||
instanceOnly: { command: 'instance-only' },
|
instanceOnly: { command: 'instance-only' },
|
||||||
},
|
},
|
||||||
@@ -185,7 +195,12 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
mcpServers: Record<string, unknown>;
|
mcpServers: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
expect(instanceContent.mcpServers).toEqual({
|
expect(instanceContent.mcpServers).toEqual({
|
||||||
'ccs-websearch': { command: 'node', args: ['/global/server.cjs'] },
|
'ccs-websearch': {
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['/global/server.cjs'],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
shared: { command: 'instance-shared' },
|
shared: { command: 'instance-shared' },
|
||||||
instanceOnly: { command: 'instance-only' },
|
instanceOnly: { command: 'instance-only' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -374,11 +374,13 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
const claudeUserConfig = JSON.parse(
|
const claudeUserConfig = JSON.parse(
|
||||||
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
|
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
|
||||||
) as {
|
) as {
|
||||||
mcpServers?: Record<string, { command: string; args: string[] }>;
|
mcpServers?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
expect(claudeUserConfig.mcpServers?.['ccs-websearch']).toEqual({
|
expect(claudeUserConfig.mcpServers?.['ccs-websearch']).toEqual({
|
||||||
|
type: 'stdio',
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(ccsDir, 'mcp', 'ccs-websearch-server.cjs')],
|
args: [path.join(ccsDir, 'mcp', 'ccs-websearch-server.cjs')],
|
||||||
|
env: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ describe('ensureWebSearchMcp', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getManagedConfig() {
|
||||||
|
return {
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: [getWebSearchMcpServerPath()],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore();
|
mock.restore();
|
||||||
|
|
||||||
@@ -79,14 +88,36 @@ describe('ensureWebSearchMcp', () => {
|
|||||||
expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(true);
|
expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(true);
|
||||||
|
|
||||||
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
||||||
mcpServers: Record<string, { command: string; args: string[] }>;
|
mcpServers: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
|
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
|
||||||
expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual({
|
expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual(getManagedConfig());
|
||||||
command: 'node',
|
});
|
||||||
args: [getWebSearchMcpServerPath()],
|
|
||||||
|
it('preserves the existing ~/.claude.json permissions when provisioning WebSearch MCP', () => {
|
||||||
|
setupTempHome();
|
||||||
|
writeEnabledConfig();
|
||||||
|
|
||||||
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
||||||
|
fs.writeFileSync(claudeUserConfigPath, JSON.stringify({ existing: true }, null, 2) + '\n', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
|
fs.chmodSync(claudeUserConfigPath, 0o600);
|
||||||
|
|
||||||
|
expect(ensureWebSearchMcp()).toBe(true);
|
||||||
|
expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes new ~/.claude.json with 0600 permissions', () => {
|
||||||
|
setupTempHome();
|
||||||
|
writeEnabledConfig();
|
||||||
|
|
||||||
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
||||||
|
|
||||||
|
expect(ensureWebSearchMcp()).toBe(true);
|
||||||
|
expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false and preserves malformed ~/.claude.json', () => {
|
it('returns false and preserves malformed ~/.claude.json', () => {
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it, spyOn } from 'bun:test';
|
||||||
import { buildWebSearchReadiness } from '../../../../src/utils/websearch/status';
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import * as geminiCli from '../../../../src/utils/websearch/gemini-cli';
|
||||||
|
import * as grokCli from '../../../../src/utils/websearch/grok-cli';
|
||||||
|
import * as opencodeCli from '../../../../src/utils/websearch/opencode-cli';
|
||||||
|
import * as providerSecrets from '../../../../src/utils/websearch/provider-secrets';
|
||||||
|
import * as unifiedConfigLoader from '../../../../src/config/unified-config-loader';
|
||||||
|
import {
|
||||||
|
buildWebSearchReadiness,
|
||||||
|
getWebSearchCliProviders,
|
||||||
|
} from '../../../../src/utils/websearch/status';
|
||||||
import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types';
|
import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types';
|
||||||
|
|
||||||
function provider(overrides: Partial<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>): WebSearchCliInfo {
|
function provider(overrides: Partial<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>): WebSearchCliInfo {
|
||||||
@@ -81,4 +92,103 @@ describe('websearch readiness', () => {
|
|||||||
expect(readiness.readiness).toBe('ready');
|
expect(readiness.readiness).toBe('ready');
|
||||||
expect(readiness.message).toContain('Exa');
|
expect(readiness.message).toContain('Exa');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats cooled-down providers as temporarily unavailable in readiness status', () => {
|
||||||
|
const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-'));
|
||||||
|
const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json');
|
||||||
|
const originalCcsHome = process.env.CCS_HOME;
|
||||||
|
|
||||||
|
mkdirSync(join(tempHome, '.ccs', 'cache'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
cooldowns: {
|
||||||
|
exa: {
|
||||||
|
until: Date.now() + 10 * 60 * 1000,
|
||||||
|
reason: 'quota_exhausted',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
|
||||||
|
const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({
|
||||||
|
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 },
|
||||||
|
grok: { enabled: false },
|
||||||
|
opencode: { enabled: false },
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({
|
||||||
|
exa: {
|
||||||
|
envVar: 'EXA_API_KEY',
|
||||||
|
configured: true,
|
||||||
|
available: true,
|
||||||
|
source: 'process_env',
|
||||||
|
},
|
||||||
|
tavily: {
|
||||||
|
envVar: 'TAVILY_API_KEY',
|
||||||
|
configured: false,
|
||||||
|
available: false,
|
||||||
|
source: 'none',
|
||||||
|
},
|
||||||
|
brave: {
|
||||||
|
envVar: 'BRAVE_API_KEY',
|
||||||
|
configured: false,
|
||||||
|
available: false,
|
||||||
|
source: 'none',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({
|
||||||
|
installed: false,
|
||||||
|
version: null,
|
||||||
|
} as any);
|
||||||
|
const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false);
|
||||||
|
const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({
|
||||||
|
installed: false,
|
||||||
|
version: null,
|
||||||
|
} as any);
|
||||||
|
const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({
|
||||||
|
installed: false,
|
||||||
|
version: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providers = getWebSearchCliProviders();
|
||||||
|
const exa = providers.find((provider) => provider.id === 'exa');
|
||||||
|
|
||||||
|
expect(exa?.enabled).toBe(true);
|
||||||
|
expect(exa?.available).toBe(false);
|
||||||
|
expect(exa?.detail).toContain('Cooling down');
|
||||||
|
expect(exa?.detail).toContain('quota exhaustion');
|
||||||
|
|
||||||
|
const readiness = buildWebSearchReadiness(true, providers);
|
||||||
|
expect(readiness.readiness).toBe('needs_setup');
|
||||||
|
expect(readiness.message).toContain('Cooling down');
|
||||||
|
} finally {
|
||||||
|
getConfigSpy.mockRestore();
|
||||||
|
apiKeySpy.mockRestore();
|
||||||
|
geminiStatusSpy.mockRestore();
|
||||||
|
geminiAuthSpy.mockRestore();
|
||||||
|
grokStatusSpy.mockRestore();
|
||||||
|
opencodeStatusSpy.mockRestore();
|
||||||
|
|
||||||
|
if (originalCcsHome === undefined) {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
}
|
||||||
|
rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user