diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index df6843ca20..6a7b95fc26 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { opendir } from "node:fs/promises"; @@ -211,6 +211,27 @@ function projectSlug(cwd: string): string { return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 64) || "workspace"; } +export function commandCodeSessionId(parsed: OcxParsedRequest): string { + // Shared prompt-cache cohorts identify a cache population, not one conversation. Using one + // for session affinity would pin unrelated conversations to the same upstream worker. + const threadId = parsed._clientThreadId?.trim(); + const replayId = parsed._reasoningReplayScope?.clientThreadId?.trim(); + const cacheKey = parsed._promptCacheKeyIsSharedCohort === false + ? parsed.options.promptCacheKey?.trim() + : undefined; + const identity = threadId + ? ["thread", threadId] + : replayId + ? ["replay", replayId] + : cacheKey + ? ["cache", cacheKey] + : undefined; + if (!identity) return randomUUID(); + const hex = createHash("sha256").update(`command-code:${identity[0]}\0${identity[1]}`).digest("hex"); + // Replace the digest nibbles at the UUID version and variant positions; the skipped hex characters are intentional. + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + interface GitWorkspaceInfo { isGitRepo: boolean; currentBranch: string; @@ -523,7 +544,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA "x-cli-environment": "production", "x-taste-learning": "false", "x-co-flag": "false", - "x-session-id": randomUUID(), + "x-session-id": commandCodeSessionId(parsed), }; if (cwd) headers["x-project-slug"] = projectSlug(cwd); return { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f78e82b965..034cb43283 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2175,6 +2175,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, preserveCustomDestination: true, defaultModel: "deepseek/deepseek-v4-flash", + promptCacheKey: true, // The default is also the cold-start seed: live discovery failure must not empty the catalog // for a freshly configured provider with no stale cache (issue #308 pattern). models: ["deepseek/deepseek-v4-flash"], diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2475367d46..c194c9d372 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2896,6 +2896,7 @@ async function handleResponsesInner( let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); + parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // Captured before any parser mutates it, so both grammars see the client's id. const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config); if (fastRow) { @@ -3210,6 +3211,7 @@ async function handleResponsesInner( "_providerContinuationOwner", "_cursorConversationId", "_clientThreadId", + "_promptCacheKeyIsSharedCohort", "_cursorClientThreadId", "_reasoningReplayScope", "_cursorIsolateConversation", diff --git a/src/types/request.ts b/src/types/request.ts index ffee4eb8a3..1c6a5294da 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -68,6 +68,8 @@ export interface OcxParsedRequest { _cursorConversationId?: string; /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ _clientThreadId?: string; + /** True when promptCacheKey identifies a shared cache cohort rather than one conversation. */ + _promptCacheKeyIsSharedCohort?: boolean; /** Cursor-only thread owner; may be an opaque process-local Desktop session/thread identity. */ _cursorClientThreadId?: string; /** Conversation/provider/account/model-bound namespace for reasoning replay state. */ diff --git a/tests/claude-integration/claude-code-thought-signature-scope.test.ts b/tests/claude-integration/claude-code-thought-signature-scope.test.ts index 2437a8d157..eb544dce97 100644 --- a/tests/claude-integration/claude-code-thought-signature-scope.test.ts +++ b/tests/claude-integration/claude-code-thought-signature-scope.test.ts @@ -97,16 +97,19 @@ describe("Claude Code Anthropic inbound reasoning-replay scope", () => { const parsed = await drive({ promptCacheKey: "session-key-123", promptCacheKeyIsSharedCohort: false }); expect(parsed._clientThreadId).toBeUndefined(); expect(parsed._reasoningReplayScope?.clientThreadId).toBe("session-key-123"); + expect(parsed._promptCacheKeyIsSharedCohort).toBe(false); }); test("the shared Desktop prompt_cache_key cohort does not create a scope", async () => { const parsed = await drive({ promptCacheKey: "shared-cohort-key", promptCacheKeyIsSharedCohort: true }); expect(parsed._reasoningReplayScope).toBeUndefined(); + expect(parsed._promptCacheKeyIsSharedCohort).toBe(true); }); test("an Anthropic replay without prompt_cache_key does not create a scope", async () => { const parsed = await drive({}); expect(parsed._reasoningReplayScope).toBeUndefined(); + expect(parsed._promptCacheKeyIsSharedCohort).toBeUndefined(); }); test("an overlong prompt_cache_key is hashed, not stored raw", async () => { diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index e4bb3c4d90..06b096586a 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { createCommandCodeAdapter } from "../../src/adapters/command-code"; +import { commandCodeSessionId, createCommandCodeAdapter } from "../../src/adapters/command-code"; import { loginCommandCode, parseCommandCodeCallback, shouldImportLocalCommandCodeAuth } from "../../src/oauth/command-code"; import { buildModelsRequest, OAUTH_PROVIDERS } from "../../src/oauth"; import { @@ -658,4 +658,68 @@ describe("Command Code provider", () => { const built = await builtRequest({ ...parsed(), stream: false }); expect(JSON.parse(built.body).params.stream).toBe(true); }); + + test("derives an opaque stable session id from trusted conversation identity", async () => { + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/i; + const identities = { + thread: "thread-secret-value", + replay: "replay-secret-value", + cache: "cache-secret-value", + }; + const thread = { + ...parsed(), + _clientThreadId: ` ${identities.thread} `, + _reasoningReplayScope: { clientThreadId: identities.replay }, + options: { ...parsed().options, promptCacheKey: identities.cache }, + }; + const sameThread = { + ...thread, + _reasoningReplayScope: { clientThreadId: "different-replay" }, + options: { ...thread.options, promptCacheKey: "different-cache" }, + }; + const replay = { + ...parsed(), + _reasoningReplayScope: { clientThreadId: identities.replay }, + options: { ...parsed().options, promptCacheKey: identities.cache }, + }; + const sameReplay = { ...replay, options: { ...replay.options, promptCacheKey: "different-cache" } }; + const cache = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: ` ${identities.cache} ` }, + _promptCacheKeyIsSharedCohort: false, + }; + const sameCache = { + ...cache, + options: { ...cache.options, promptCacheKey: identities.cache }, + }; + + const threadId = commandCodeSessionId(thread); + expect(threadId).toBe(commandCodeSessionId(sameThread)); + expect(threadId).not.toBe(commandCodeSessionId({ ...thread, _clientThreadId: "different-thread" })); + expect(commandCodeSessionId(replay)).toBe(commandCodeSessionId(sameReplay)); + expect(commandCodeSessionId(cache)).toBe(commandCodeSessionId(sameCache)); + expect(commandCodeSessionId(replay)).not.toBe(commandCodeSessionId(cache)); + expect(threadId).toMatch(uuid); + expect(commandCodeSessionId(replay)).toMatch(uuid); + expect(commandCodeSessionId(cache)).toMatch(uuid); + for (const raw of Object.values(identities)) expect(threadId).not.toContain(raw); + + const built = await builtRequest(thread); + expect(built.headers["x-session-id"]).toBe(threadId); + }); + + test("does not derive affinity from a shared cohort or prompt text", () => { + const shared = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: "shared-cache-key" }, + _promptCacheKeyIsSharedCohort: true, + }; + expect(commandCodeSessionId(shared)).not.toBe(commandCodeSessionId(shared)); + const unclassifiedCache = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: "possibly-shared-cache-key" }, + }; + expect(commandCodeSessionId(unclassifiedCache)).not.toBe(commandCodeSessionId(unclassifiedCache)); + expect(commandCodeSessionId(parsed())).not.toBe(commandCodeSessionId(parsed())); + }); }); diff --git a/tests/providers/commandcode-provider.test.ts b/tests/providers/commandcode-provider.test.ts index e76355dc4f..f70df51093 100644 --- a/tests/providers/commandcode-provider.test.ts +++ b/tests/providers/commandcode-provider.test.ts @@ -67,6 +67,7 @@ describe("Command Code provider", () => { liveModels: true, preserveCustomDestination: true, defaultModel: "deepseek/deepseek-v4-flash", + promptCacheKey: true, apiKeyValidation: "unknown", reasoningEfforts: [], modelReasoningEfforts: { @@ -176,6 +177,20 @@ describe("Command Code provider", () => { expect(body).not.toHaveProperty("parallel_tool_calls"); }); + test("forwards the enabled prompt cache key to chat completions", () => { + const route = routeModel( + commandcodeConfig(), + "commandcode/deepseek/deepseek-v4-flash", + ); + const request = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, + stream: true, + options: { promptCacheKey: "command-code-session-cache" }, + }); + expect(JSON.parse(String(request.body)).prompt_cache_key).toBe("command-code-session-cache"); + }); + test("discovers the live catalog with context windows and preserves slash ids", async () => { globalThis.fetch = (async (input, init) => { expect(String(input)).toBe("https://api.commandcode.ai/provider/v1/models");