mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 06:17:27 +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:
1 parent
cae5b710b8
commit
7f83e041b7
10 files changed
+1115
-63
No files matched your search
@@ -374,11 +374,13 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
const claudeUserConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, { command: string; args: string[] }>;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(claudeUserConfig.mcpServers?.['ccs-websearch']).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
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(() => {
|
||||
mock.restore();
|
||||
|
||||
@@ -79,14 +88,36 @@ describe('ensureWebSearchMcp', () => {
|
||||
expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(true);
|
||||
|
||||
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[getWebSearchMcpServerName()]).toEqual({
|
||||
command: 'node',
|
||||
args: [getWebSearchMcpServerPath()],
|
||||
expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual(getManagedConfig());
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildWebSearchReadiness } from '../../../../src/utils/websearch/status';
|
||||
import { describe, expect, it, spyOn } from 'bun:test';
|
||||
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';
|
||||
|
||||
function provider(overrides: Partial<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>): WebSearchCliInfo {
|
||||
@@ -81,4 +92,103 @@ describe('websearch readiness', () => {
|
||||
expect(readiness.readiness).toBe('ready');
|
||||
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