mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-30 17:11:12 +08:00
Add context archive config and gateway support
This commit is contained in:
@@ -15,6 +15,7 @@ import type {
|
||||
BotGatewaySavedConfig,
|
||||
ClaudeCodeProfileConfig,
|
||||
CodexProfileConfig,
|
||||
ContextArchiveConfig,
|
||||
GatewayAgentConfig,
|
||||
GatewayMcpServerConfig,
|
||||
GatewayMcpServerTransport,
|
||||
@@ -67,11 +68,12 @@ type LoadedBotGatewayConfig = Partial<Omit<BotGatewayRuntimeConfig, "handoff">>
|
||||
handoff?: Partial<BotGatewayRuntimeConfig["handoff"]>;
|
||||
};
|
||||
|
||||
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "gateway" | "observability" | "profile" | "proxy" | "toolHub">> & {
|
||||
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "contextArchive" | "gateway" | "observability" | "profile" | "proxy" | "toolHub">> & {
|
||||
Router?: Partial<RouterConfig>;
|
||||
agent?: Partial<GatewayAgentConfig>;
|
||||
botConfigs?: BotGatewaySavedConfig[];
|
||||
botGateway?: LoadedBotGatewayConfig;
|
||||
contextArchive?: Partial<ContextArchiveConfig>;
|
||||
gateway?: Partial<AppConfig["gateway"]>;
|
||||
observability?: Partial<ObservabilityConfig>;
|
||||
profile?: LoadedProfileConfig;
|
||||
@@ -242,6 +244,14 @@ export async function loadAppConfig(): Promise<AppConfig> {
|
||||
},
|
||||
botConfigs: picked.botConfigs ?? DEFAULT_CONFIG.botConfigs,
|
||||
botGateway: completeBotGatewayConfig(picked.botGateway),
|
||||
contextArchive: {
|
||||
...DEFAULT_CONFIG.contextArchive,
|
||||
...(picked.contextArchive ?? {}),
|
||||
llm: {
|
||||
...DEFAULT_CONFIG.contextArchive.llm,
|
||||
...(picked.contextArchive?.llm ?? {})
|
||||
}
|
||||
},
|
||||
gateway: {
|
||||
...DEFAULT_CONFIG.gateway,
|
||||
...gatewayConfig,
|
||||
@@ -588,6 +598,10 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
|
||||
if (botConfigs) {
|
||||
config.botConfigs = botConfigs;
|
||||
}
|
||||
const contextArchive = parseContextArchive((value as Record<string, unknown>).contextArchive ?? (value as Record<string, unknown>).context_archive);
|
||||
if (contextArchive) {
|
||||
config.contextArchive = contextArchive;
|
||||
}
|
||||
if (typeof value.autoStart === "boolean") {
|
||||
config.autoStart = value.autoStart;
|
||||
}
|
||||
@@ -676,6 +690,69 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseContextArchive(value: unknown): Partial<ContextArchiveConfig> | undefined {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contextArchive: Partial<ContextArchiveConfig> = {};
|
||||
if (typeof value.enabled === "boolean") {
|
||||
contextArchive.enabled = value.enabled;
|
||||
}
|
||||
const mcpEnabled = value.mcpEnabled ?? value.mcp_enabled;
|
||||
if (typeof mcpEnabled === "boolean") {
|
||||
contextArchive.mcpEnabled = mcpEnabled;
|
||||
}
|
||||
const triggerTokenLimit = readNumber(value.triggerTokenLimit ?? value.trigger_token_limit);
|
||||
if (triggerTokenLimit !== undefined) {
|
||||
contextArchive.triggerTokenLimit = clampNumber(triggerTokenLimit, 1000, 2_000_000);
|
||||
}
|
||||
const retainRecentItems = readNumber(value.retainRecentItems ?? value.retain_recent_items);
|
||||
if (retainRecentItems !== undefined) {
|
||||
contextArchive.retainRecentItems = clampNumber(retainRecentItems, 2, 200);
|
||||
}
|
||||
const maxEntries = readNumber(value.maxEntries ?? value.max_entries);
|
||||
if (maxEntries !== undefined) {
|
||||
contextArchive.maxEntries = clampNumber(maxEntries, 50, 100000);
|
||||
}
|
||||
const maxSearchResults = readNumber(value.maxSearchResults ?? value.max_search_results);
|
||||
if (maxSearchResults !== undefined) {
|
||||
contextArchive.maxSearchResults = clampNumber(maxSearchResults, 1, 50);
|
||||
}
|
||||
const handoffMaxCharacters = readNumber(value.handoffMaxCharacters ?? value.handoff_max_characters);
|
||||
if (handoffMaxCharacters !== undefined) {
|
||||
contextArchive.handoffMaxCharacters = clampNumber(handoffMaxCharacters, 1000, 200000);
|
||||
}
|
||||
const toolName = readString(value.toolName ?? value.tool_name);
|
||||
if (toolName !== undefined) {
|
||||
contextArchive.toolName = toolName;
|
||||
}
|
||||
|
||||
const rawLlm = isObject(value.llm) ? value.llm : value;
|
||||
const llm: Partial<ContextArchiveConfig["llm"]> = {};
|
||||
const apiKey = readString(rawLlm.apiKey) || readString(rawLlm.api_key);
|
||||
if (apiKey !== undefined) {
|
||||
llm.apiKey = apiKey;
|
||||
}
|
||||
const baseUrl = readString(rawLlm.baseUrl) || readString(rawLlm.base_url);
|
||||
if (baseUrl !== undefined) {
|
||||
llm.baseUrl = baseUrl;
|
||||
}
|
||||
const model = readString(rawLlm.model);
|
||||
if (model !== undefined) {
|
||||
llm.model = model;
|
||||
}
|
||||
const timeoutMs = readNumber(rawLlm.timeoutMs ?? rawLlm.timeout_ms);
|
||||
if (timeoutMs !== undefined) {
|
||||
llm.timeoutMs = clampNumber(timeoutMs, 8000, 600000);
|
||||
}
|
||||
if (Object.keys(llm).length > 0) {
|
||||
contextArchive.llm = llm as ContextArchiveConfig["llm"];
|
||||
}
|
||||
|
||||
return Object.keys(contextArchive).length ? contextArchive : undefined;
|
||||
}
|
||||
|
||||
function parseObservability(value: unknown): Partial<ObservabilityConfig> | undefined {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
|
||||
@@ -82,6 +82,22 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
|
||||
stateDir: "",
|
||||
tenantId: "ccr"
|
||||
},
|
||||
contextArchive: {
|
||||
enabled: false,
|
||||
handoffMaxCharacters: 24000,
|
||||
llm: {
|
||||
apiKey: "",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
model: "",
|
||||
timeoutMs: 60000
|
||||
},
|
||||
maxEntries: 2000,
|
||||
maxSearchResults: 8,
|
||||
mcpEnabled: true,
|
||||
retainRecentItems: 12,
|
||||
toolName: "ccr_history_search",
|
||||
triggerTokenLimit: 100000
|
||||
},
|
||||
gateway: {
|
||||
coreHost,
|
||||
corePort: 3457,
|
||||
|
||||
@@ -656,6 +656,25 @@ export type ToolHubConfig = {
|
||||
requestTimeoutMs: number;
|
||||
};
|
||||
|
||||
export type ContextArchiveLlmConfig = {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type ContextArchiveConfig = {
|
||||
enabled: boolean;
|
||||
handoffMaxCharacters: number;
|
||||
llm: ContextArchiveLlmConfig;
|
||||
maxEntries: number;
|
||||
maxSearchResults: number;
|
||||
mcpEnabled: boolean;
|
||||
retainRecentItems: number;
|
||||
toolName: string;
|
||||
triggerTokenLimit: number;
|
||||
};
|
||||
|
||||
export const CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY";
|
||||
export const CLAUDE_CODE_DEFAULT_ENV: Record<string, string> = {
|
||||
[CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV]: "1"
|
||||
@@ -1400,6 +1419,7 @@ export type AppConfig = {
|
||||
autoStart: boolean;
|
||||
botConfigs: BotGatewaySavedConfig[];
|
||||
botGateway: BotGatewayRuntimeConfig;
|
||||
contextArchive: ContextArchiveConfig;
|
||||
gateway: GatewayRuntimeConfig;
|
||||
launchAtLogin: boolean;
|
||||
observability: ObservabilityConfig;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,13 @@ import { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-provi
|
||||
import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp";
|
||||
import { BROWSER_AUTOMATION_MCP_PATH, TOOL_HUB_MCP_SERVER_NAME, browserAutomationMcpEnabled, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config";
|
||||
import {
|
||||
contextArchiveMcpServer,
|
||||
handleContextArchiveMcpRequest,
|
||||
isContextArchiveMcpPath,
|
||||
prepareContextArchiveRequest,
|
||||
recordContextArchiveResponse
|
||||
} from "@ccr/core/gateway/context-archive";
|
||||
import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store";
|
||||
@@ -212,6 +219,8 @@ type UpstreamAttempt = {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type ContextArchiveForwardRecord = Parameters<typeof recordContextArchiveResponse>[0];
|
||||
|
||||
type UpstreamFailedAttempt = {
|
||||
credentialChain?: string[];
|
||||
credentialIds?: string[];
|
||||
@@ -603,6 +612,15 @@ class GatewayService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isContextArchiveMcpPath(path)) {
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
return;
|
||||
}
|
||||
await handleContextArchiveMcpRequest(request, response, this.config);
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginRoute = pluginService.matchGatewayRoute(request.method, path);
|
||||
if (pluginRoute) {
|
||||
if (pluginRoute.auth !== "none") {
|
||||
@@ -917,6 +935,23 @@ class GatewayService {
|
||||
}
|
||||
}
|
||||
|
||||
let contextArchiveRecord: ContextArchiveForwardRecord;
|
||||
const contextArchivePreparation = await prepareContextArchiveRequest({
|
||||
body: bodyToForward,
|
||||
config: this.config,
|
||||
headers: request.headers,
|
||||
method,
|
||||
path,
|
||||
protocol: requestProtocolForPath(path),
|
||||
requestId
|
||||
});
|
||||
if (contextArchivePreparation) {
|
||||
bodyToForward = contextArchivePreparation.body;
|
||||
contextArchiveRecord = contextArchivePreparation.record;
|
||||
headers["content-type"] = "application/json";
|
||||
headers["x-ccr-context-archive"] = sanitizeHeaderValue(contextArchivePreparation.diagnostic);
|
||||
}
|
||||
|
||||
delete headers["content-length"];
|
||||
const upstreamUrl = new URL(request.url || "/", this.status.coreEndpoint).toString();
|
||||
let upstreamResult: UpstreamFetchResult;
|
||||
@@ -1057,6 +1092,7 @@ class GatewayService {
|
||||
responseBody.once("end", () => {
|
||||
upstreamStreamEnded = true;
|
||||
streamDetectedError ??= sseErrorDetector.finish();
|
||||
recordContextArchiveResponse(contextArchiveRecord, sampler.read(), this.config);
|
||||
if (responseCompleted || response.writableEnded) {
|
||||
writeStreamLog();
|
||||
}
|
||||
@@ -1185,6 +1221,14 @@ async function writeCoreGatewayConfig(
|
||||
...builtinToolArtifacts.mcpServers,
|
||||
...(toolHubServer ? [toolHubServer] : externalMcpServers)
|
||||
];
|
||||
const contextArchiveServer = contextArchiveMcpServer(
|
||||
config,
|
||||
clientGatewayEndpoint(config.gateway.host, config.gateway.port),
|
||||
firstConfiguredApiKey(config)
|
||||
);
|
||||
if (contextArchiveServer) {
|
||||
mcpServers.push(contextArchiveServer);
|
||||
}
|
||||
const fallbackMcpServer = fusionToolFallbackMcpServer(virtualModelProfiles, [
|
||||
...builtinToolArtifacts.mcpServers,
|
||||
...externalMcpServers
|
||||
@@ -1248,6 +1292,11 @@ function providerPluginEnabled(plugin: unknown): boolean {
|
||||
return !isRecord(plugin) || plugin.enabled !== false;
|
||||
}
|
||||
|
||||
function firstConfiguredApiKey(config: AppConfig): string | undefined {
|
||||
return (Array.isArray(config.APIKEYS) ? config.APIKEYS : [])
|
||||
.find((apiKey) => apiKey.key.trim())?.key.trim() || stringValue(config.APIKEY);
|
||||
}
|
||||
|
||||
export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] {
|
||||
return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config));
|
||||
}
|
||||
@@ -6867,6 +6916,20 @@ function endpoint(host: string, port: number): string {
|
||||
return `http://${endpointHost}:${port}`;
|
||||
}
|
||||
|
||||
function clientGatewayEndpoint(host: string, port: number): string {
|
||||
let endpointHost = host;
|
||||
if (endpointHost === "0.0.0.0") {
|
||||
endpointHost = "127.0.0.1";
|
||||
} else if (endpointHost === "::" || endpointHost === "[::]") {
|
||||
endpointHost = "::1";
|
||||
}
|
||||
return `http://${formatUrlHost(endpointHost)}:${port}`;
|
||||
}
|
||||
|
||||
function formatUrlHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function gatewayNetworkEndpoints(host: string, port: number): GatewayNetworkEndpoint[] {
|
||||
const normalizedHost = normalizeBindHost(host);
|
||||
const lanAddresses = physicalLanAddresses();
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
|
||||
import {
|
||||
CONTEXT_ARCHIVE_MCP_PATH,
|
||||
contextArchiveMcpServer,
|
||||
contextArchiveService,
|
||||
prepareContextArchiveRequest
|
||||
} from "../../packages/core/src/gateway/context-archive.ts";
|
||||
|
||||
function testConfig(contextArchiveOverrides = {}) {
|
||||
const config = createDefaultAppConfig({
|
||||
generatedConfigFile: "/tmp/ccr-context-archive-test-gateway.json"
|
||||
});
|
||||
return {
|
||||
...config,
|
||||
APIKEY: "local-test-key",
|
||||
APIKEYS: [{ id: "local", key: "local-test-key", name: "Local" }],
|
||||
contextArchive: {
|
||||
...config.contextArchive,
|
||||
enabled: true,
|
||||
handoffMaxCharacters: 12000,
|
||||
maxEntries: 200,
|
||||
maxSearchResults: 4,
|
||||
retainRecentItems: 2,
|
||||
triggerTokenLimit: 1,
|
||||
...contextArchiveOverrides
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test("context archive compacts OpenAI chat requests and preserves searchable pruned history", async () => {
|
||||
contextArchiveService.clear();
|
||||
const config = testConfig();
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: "You are a coding agent." },
|
||||
{ role: "user", content: "Historical decision: use SQLite for the archive index, not a JSON file." },
|
||||
{ role: "assistant", content: "Acknowledged. I will use SQLite." },
|
||||
{ role: "user", content: "Recent request: continue implementation." },
|
||||
{ role: "assistant", content: "Working on it." }
|
||||
],
|
||||
model: "test-model"
|
||||
};
|
||||
|
||||
const result = await prepareContextArchiveRequest({
|
||||
body: Buffer.from(JSON.stringify(body)),
|
||||
config,
|
||||
headers: { "x-session-id": "session-a" },
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
protocol: "openai_chat_completions",
|
||||
requestId: "request-a"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result.diagnostic, /^compacted:session-a:/);
|
||||
const compacted = JSON.parse(result.body.toString("utf8"));
|
||||
assert.equal(compacted.messages[0].role, "system");
|
||||
assert.match(compacted.messages[1].content, /CCR CONTEXT HANDOFF/);
|
||||
assert.match(compacted.messages[1].content, /ccr_history_search/);
|
||||
assert.equal(compacted.messages.at(-1).content, "Working on it.");
|
||||
|
||||
const search = await contextArchiveService.search({
|
||||
prompt: "Which storage was chosen for the archive index?",
|
||||
sessionId: "session-a"
|
||||
}, config.contextArchive);
|
||||
assert.equal(search.evidence.length > 0, true);
|
||||
assert.match(search.answer, /SQLite/);
|
||||
});
|
||||
|
||||
test("context archive adapts Codex compact requests without pruning the client payload", async () => {
|
||||
contextArchiveService.clear();
|
||||
const config = testConfig({ triggerTokenLimit: 999999 });
|
||||
const body = {
|
||||
instructions: "You are Codex.",
|
||||
input: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
text: "Historical decision: the archive search should use a radix index for quick prefix lookup.",
|
||||
type: "input_text"
|
||||
}
|
||||
],
|
||||
role: "user",
|
||||
type: "message"
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{
|
||||
text: "Please summarize the conversation so far for context compaction. Include decisions and next steps.",
|
||||
type: "input_text"
|
||||
}
|
||||
],
|
||||
role: "user",
|
||||
type: "message"
|
||||
}
|
||||
],
|
||||
model: "gpt-5-codex"
|
||||
};
|
||||
|
||||
const result = await prepareContextArchiveRequest({
|
||||
body: Buffer.from(JSON.stringify(body)),
|
||||
config,
|
||||
headers: { "user-agent": "codex-cli/1.0", "x-codex-session-id": "codex-s1" },
|
||||
method: "POST",
|
||||
path: "/v1/responses",
|
||||
protocol: "openai_responses",
|
||||
requestId: "request-codex-compact"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result.diagnostic, /^client-compact:codex:codex-s1:/);
|
||||
const prepared = JSON.parse(result.body.toString("utf8"));
|
||||
assert.equal(prepared.input.length, body.input.length);
|
||||
assert.match(prepared.instructions, /Archived history access/);
|
||||
assert.match(prepared.instructions, /ccr_history_search/);
|
||||
assert.match(prepared.instructions, /codex-s1/);
|
||||
|
||||
const search = await contextArchiveService.search({
|
||||
prompt: "Which index was chosen for archive search?",
|
||||
sessionId: "codex-s1"
|
||||
}, config.contextArchive);
|
||||
assert.match(search.answer, /radix index/);
|
||||
});
|
||||
|
||||
test("context archive adapts Claude Code compact requests without pruning messages", async () => {
|
||||
contextArchiveService.clear();
|
||||
const config = testConfig({ triggerTokenLimit: 999999 });
|
||||
const body = {
|
||||
messages: [
|
||||
{
|
||||
content: "Important result: npm run test:main passes after the context archive changes.",
|
||||
role: "assistant"
|
||||
},
|
||||
{
|
||||
content: "Summarize the conversation so far for handoff into a new context window.",
|
||||
role: "user"
|
||||
}
|
||||
],
|
||||
model: "claude-sonnet-4-5",
|
||||
system: "You are Claude Code."
|
||||
};
|
||||
|
||||
const result = await prepareContextArchiveRequest({
|
||||
body: Buffer.from(JSON.stringify(body)),
|
||||
config,
|
||||
headers: { "user-agent": "claude-code/2.0", "x-claude-code-session-id": "claude-s1" },
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
protocol: "anthropic_messages",
|
||||
requestId: "request-claude-compact"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result.diagnostic, /^client-compact:claude-code:claude-s1:/);
|
||||
const prepared = JSON.parse(result.body.toString("utf8"));
|
||||
assert.equal(prepared.messages.length, body.messages.length);
|
||||
assert.match(prepared.system, /Archived history access/);
|
||||
assert.match(prepared.system, /ccr_history_search/);
|
||||
assert.match(prepared.system, /claude-s1/);
|
||||
|
||||
const search = await contextArchiveService.search({
|
||||
prompt: "What test command passed?",
|
||||
sessionId: "claude-s1"
|
||||
}, config.contextArchive);
|
||||
assert.match(search.answer, /npm run test:main/);
|
||||
});
|
||||
|
||||
test("context archive does not treat generic summary prompts as client compact requests", async () => {
|
||||
contextArchiveService.clear();
|
||||
const config = testConfig({ triggerTokenLimit: 999999 });
|
||||
const body = {
|
||||
messages: [
|
||||
{ content: "Please summarize the conversation so far for context compaction.", role: "user" }
|
||||
],
|
||||
model: "test-model"
|
||||
};
|
||||
|
||||
const result = await prepareContextArchiveRequest({
|
||||
body: Buffer.from(JSON.stringify(body)),
|
||||
config,
|
||||
headers: { "user-agent": "generic-openai-client/1.0", "x-session-id": "generic-s1" },
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
protocol: "openai_chat_completions",
|
||||
requestId: "request-generic-summary"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result.diagnostic, /^archived:generic-s1:/);
|
||||
assert.deepEqual(JSON.parse(result.body.toString("utf8")), body);
|
||||
});
|
||||
|
||||
test("context archive does not treat unrelated Claude Code compact wording as context compaction", async () => {
|
||||
contextArchiveService.clear();
|
||||
const config = testConfig({ triggerTokenLimit: 999999 });
|
||||
const body = {
|
||||
messages: [
|
||||
{ content: "Please set the UI density option to compact.", role: "user" }
|
||||
],
|
||||
model: "claude-sonnet-4-5"
|
||||
};
|
||||
|
||||
const result = await prepareContextArchiveRequest({
|
||||
body: Buffer.from(JSON.stringify(body)),
|
||||
config,
|
||||
headers: { "user-agent": "claude-code/2.0", "x-claude-code-session-id": "claude-s2" },
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
protocol: "anthropic_messages",
|
||||
requestId: "request-claude-unrelated-compact"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.match(result.diagnostic, /^archived:claude-s2:/);
|
||||
assert.deepEqual(JSON.parse(result.body.toString("utf8")), body);
|
||||
});
|
||||
|
||||
test("context archive deep search expands neighboring evidence", async () => {
|
||||
contextArchiveService.clear();
|
||||
const config = testConfig();
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: "First note: alpha marker belongs to the retry policy discussion." },
|
||||
{ role: "assistant", content: "Neighbor note: the retry policy uses exponential backoff." },
|
||||
{ role: "user", content: "Recent request." }
|
||||
],
|
||||
model: "test-model"
|
||||
};
|
||||
|
||||
await prepareContextArchiveRequest({
|
||||
body: Buffer.from(JSON.stringify(body)),
|
||||
config,
|
||||
headers: { "x-session-id": "session-b" },
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
protocol: "openai_chat_completions",
|
||||
requestId: "request-b"
|
||||
});
|
||||
|
||||
const shallow = await contextArchiveService.search({ prompt: "alpha marker", sessionId: "session-b" }, config.contextArchive);
|
||||
const deep = await contextArchiveService.search({ deep: true, prompt: "alpha marker", sessionId: "session-b" }, config.contextArchive);
|
||||
|
||||
assert.equal(shallow.evidence.length > 0, true);
|
||||
assert.equal(deep.evidence.length >= shallow.evidence.length, true);
|
||||
assert.match(deep.answer, /exponential backoff|alpha marker/);
|
||||
});
|
||||
|
||||
test("context archive MCP server points at the built-in gateway endpoint", () => {
|
||||
const config = testConfig();
|
||||
const server = contextArchiveMcpServer(config, "http://127.0.0.1:3456", "local-test-key");
|
||||
|
||||
assert.ok(server);
|
||||
assert.equal(server.name, "ccr-context-archive");
|
||||
assert.equal(server.transport, "streamable-http");
|
||||
assert.equal(server.apiKey, "local-test-key");
|
||||
assert.equal(server.url, `http://127.0.0.1:3456${CONTEXT_ARCHIVE_MCP_PATH}`);
|
||||
});
|
||||
Reference in New Issue
Block a user