diff --git a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md index 0152933d69..343090b978 100644 --- a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md +++ b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md @@ -12,7 +12,7 @@ adversarial xai/grok-4.6 subagent verdicts, and a final green dev CI gate. | #2289 | fix(service): restart existing installs w/o re-register | 2df92a270 + locale sync 174f03b60 (train-stacked) | 2 | yes | green incl. Service lifecycle | MERGED to train; grok P2 fixed; re-verdict PASS; Closes #2287 | | #2295 | fix(codex): recover zero-byte coordinator remnants | 6d5f0cf2c (ingw/fix-zero-byte-coordinator-2291) | 0 | yes | green | MERGED to train 728ca1e8b; suite green; landing on dev | | #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 + pin ec32a8d52 (train-stacked) | merged into train | yes | MERGED to train; grok P2 fixed; re-verdict PASS | Linux shards green; lidge full suite green | -| #2281 | fix: call_id thought-signature replay for Claude Code | b31f3dbed (fix/claude-code-thought-signature-replay) | 50 | no (review-ready + hygiene-blocked label) | BLOCKED state | CodeRabbit minor: normalize promptCacheKey via anthropicSessionKeyFromParts before storing as clientThreadId (core.ts ~1888-1896); lidge-jun review priority 63/80 confirms repro | +| #2281 | fix: call_id thought-signature replay for Claude Code | b31f3dbed + normalization bc6d6b516 (train-stacked) | merged into train | yes | MERGED to train; two reviewers PASS; CodeRabbit normalization done | hygiene resolved by shipped regression rows | | #2296 | fix(codex): bind Desktop reconnects to one pool account | e672b0fd0 + scope fix 698228e40 (train-stacked) | 2 | yes | green | MERGED to train; grok major fixed; re-verdict PASS; landing on dev | ## Baseline dev CI status (pre-train blocker) diff --git a/devlog/_plan/260821_bug_merge_train/065_merge_2281.md b/devlog/_plan/260821_bug_merge_train/065_merge_2281.md index 67374542f1..928d768383 100644 --- a/devlog/_plan/260821_bug_merge_train/065_merge_2281.md +++ b/devlog/_plan/260821_bug_merge_train/065_merge_2281.md @@ -2,3 +2,38 @@ Takes the core.ts rebase conflict deliberately. Pre-merge blockers (ALL merge-blocking): (a) stacked commit: normalize promptCacheKey via anthropicSessionKeyFromParts before assigning clientThreadId (src/server/responses/core.ts ~1888-1896; helper at src/oauth/anthropic-routing.ts:573-594) + trimmed/overlong-key test rows; (b) missing_regression_test hygiene label re-checked after stacked commit — drop or record maintainer override; (c) rebase onto final dev, resolve core.ts against #2296's affinity changes with a semantic re-check (replay scope + affinity key compose; both test files green on merged tree); (d) FULL SUITE green on that head. Fork head (Hsia97/opencodex, maintainerCanModify=true): stacked commits push to the fork remote. Also: reviewDecision is CHANGES_REQUESTED (lidge-jun priority-63 review) — the stacked fixes must answer that review, then refresh/dismiss it. Verify: bun test tests/claude-code-thought-signature-scope.test.ts tests/google-signature-history-roundtrip.test.ts, bun run typecheck, FULL SUITE. Owner (CODEOWNERS core.ts) review recorded at merge. grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live PR head b31f3dbed — 2 commits over base e3b2136b, far behind dev) + +Merge the PR ref into the train and resolve the core.ts conflict there against +the landed affinity work. Steps: +1. Merge pr/2281 into train; resolve core.ts semantically (promptCacheKey + normalization + affinity compose). +2. Stacked commit (a): normalize promptCacheKey via + anthropicSessionKeyFromParts before clientThreadId assignment, with + trimmed/overlong-key test rows. +3. Adversarial review (inherited model) on the merged head: replay scope + correctness, signature integrity, cache-key normalization, privacy. +4. Focused signature tests + typecheck + privacy locally; lidge full suite; + land via train PR to dev; hygiene label (b) resolved by the stacked test + coverage; record owner approval at merge. + +## Review (Locke + second inherited reviewer) — both PASS + +Blocker (a) fixed by the stacked normalization commit bc6d6b516: promptCacheKey +routed through anthropicSessionKeyFromParts before scope assignment — trim + +sha256-over-128 parity with the affinity path; overlong-hash and whitespace +rows added. Reviewers verified: shared-cohort leak structurally blocked twice +(cacheKeySource gate + in-helper re-check); replay cache keys carry the full +provider/adapter/model/credential identity tuple plus serving-identity guard, +so no cross-session or cross-account signature leak; privacy clean (stored +scope is always the translator's opaque hash, never raw user_id). + +Accepted residuals (P3): provenance comment for future client-supplied +cache-key ingress; exact-digest pin and padded-trim row; header-priority row. +Hygiene label (b) resolved: regression coverage shipped in this train +(claude-code-thought-signature-scope.test.ts rows). + +Gates at train head bc6d6b516: focused 27/27, typecheck pass, privacy:scan +pass, lidge r10 full suite 14240 pass / 0 fail exit 0. Owner approval for +core.ts recorded by merging maintainer per repo policy. diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 07dd38e476..746f9490a4 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -413,6 +413,7 @@ interface GoogleResponsePart { thought?: boolean; thoughtSignature?: string; thought_signature?: string; + extra_content?: { google?: { thought_signature?: unknown } }; functionCall?: unknown; } @@ -421,6 +422,18 @@ interface GoogleFunctionCall { args?: unknown; } +/** + * Read a Gemini/Antigravity thought signature from a response part. Antigravity can place it + * either directly on the part (`thoughtSignature` / `thought_signature`) or inside the same + * nested `extra_content.google.thought_signature` shape used on the Responses wire. + */ +function googlePartThoughtSignature(part: GoogleResponsePart): string | undefined { + const direct = part.thoughtSignature ?? part.thought_signature; + if (typeof direct === "string" && direct.length > 0) return direct; + const nested = part.extra_content?.google?.thought_signature; + return typeof nested === "string" && nested.length > 0 ? nested : undefined; +} + /** * Carry a Gemini thought signature with the exact function-call part that produced it. Google * validates the signature against that specific part, so it must ride the individual tool call @@ -430,7 +443,7 @@ function googleToolCallMetadataFromPart( part: GoogleResponsePart, fallbackSignature?: string, ): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined { - const signature = part.thoughtSignature ?? part.thought_signature ?? fallbackSignature; + const signature = googlePartThoughtSignature(part) ?? fallbackSignature; if (!isLikelyRealThoughtSignature(signature)) return undefined; return { providerMetadata: { google: { thoughtSignature: signature } } }; } @@ -960,7 +973,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } if (parts) { for (const part of parts) { - const sig = part.thoughtSignature ?? part.thought_signature; + const sig = googlePartThoughtSignature(part); if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingStreamThoughtSig = sig; } @@ -1224,7 +1237,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } let pendingThoughtSig: string | undefined; for (const part of parts) { - const sig = part.thoughtSignature ?? part.thought_signature; + const sig = googlePartThoughtSignature(part); if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingThoughtSig = sig; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b6bbf7f8c7..b1bb6fadeb 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2186,6 +2186,27 @@ async function handleResponsesInner( if (inboundClientThreadId) { parsed._clientThreadId = inboundClientThreadId; parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId }; + } else if ( + options.inboundWire === "anthropic" + && options.promptCacheKeyIsSharedCohort !== true + && typeof parsed.options.promptCacheKey === "string" + && parsed.options.promptCacheKey.trim().length > 0 + ) { + // Claude Code has no Codex parent-thread header, but its metadata.user_id is + // translated into a stable per-session prompt_cache_key. Use it as the replay + // thread identity so Gemini thought signatures are remembered by call_id for + // Anthropic Messages clients too (#1735/#1926). Keep `_clientThreadId` unset so + // existing provider session-id derivation (first-user-text fallback) is unchanged. + // Normalize through anthropicSessionKeyFromParts so overlong keys are hashed and + // trimming matches the affinity/session-key path exactly (no raw >128-char ids). + const normalizedCacheKey = anthropicSessionKeyFromParts({ + promptCacheKey: parsed.options.promptCacheKey, + // The enclosing branch already proves this is not the shared cohort. + promptCacheKeyIsSharedCohort: false, + }); + if (normalizedCacheKey) { + parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey }; + } } } catch (err) { if (isTranslatorBudgetExceededError(err)) { diff --git a/tests/claude-code-thought-signature-scope.test.ts b/tests/claude-code-thought-signature-scope.test.ts new file mode 100644 index 0000000000..d6a3de65f6 --- /dev/null +++ b/tests/claude-code-thought-signature-scope.test.ts @@ -0,0 +1,125 @@ +/** + * Regression coverage for the Claude Code thought-signature replay scope: + * + * Claude Code speaks Anthropic Messages and does not send Codex's + * `x-codex-parent-thread-id`. The server must still create a reasoning-replay + * scope for a real per-session `prompt_cache_key` (derived from + * `metadata.user_id`) so Gemini/Antigravity thought signatures can be remembered + * by call_id. The shared Desktop `prompt_cache_key` cohort must NOT get a scope. + */ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import type { ProviderAdapter } from "../src/adapters/base"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const actualResolver = await import("../src/server/adapter-resolve"); + +let adapterFactory: ((provider: OcxProviderConfig) => ProviderAdapter) | undefined; + +mock.module("../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { + return adapterFactory?.(provider) ?? actualResolver.resolveAdapter(provider, cacheRetention); + }, +})); + +const { handleResponses } = await import("../src/server/responses"); + +afterEach(() => { + adapterFactory = undefined; +}); + +function captureAdapter(captured: OcxParsedRequest[]): ProviderAdapter { + return { + name: "capture-replay-scope", + buildRequest: () => ({ url: "https://capture.test", method: "POST", headers: {}, body: "{}" }), + async *parseStream(): AsyncGenerator { + yield { type: "done" }; + }, + async runTurn(parsed: OcxParsedRequest, _incoming, emit) { + captured.push(parsed); + emit({ type: "done" }); + }, + }; +} + +function testConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://capture.test", + authMode: "key", + apiKey: "capture-key", + models: ["m1"], + }, + }, + } as OcxConfig; +} + +async function drive(options: { + promptCacheKey?: string; + promptCacheKeyIsSharedCohort?: boolean; +}): Promise { + const captured: OcxParsedRequest[] = []; + adapterFactory = () => captureAdapter(captured); + const body: Record = { + model: "m1", + stream: true, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }], + }; + if (options.promptCacheKey !== undefined) body.prompt_cache_key = options.promptCacheKey; + + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + testConfig(), + { model: "", provider: "" }, + { + inboundWire: "anthropic", + ...(options.promptCacheKeyIsSharedCohort === undefined + ? {} + : { promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort }), + }, + ); + await response.text(); + expect(captured.length).toBe(1); + return captured[0]!; +} + +describe("Claude Code Anthropic inbound reasoning-replay scope", () => { + test("a real per-session prompt_cache_key creates a call_id replay scope", async () => { + const parsed = await drive({ promptCacheKey: "session-key-123", promptCacheKeyIsSharedCohort: false }); + expect(parsed._clientThreadId).toBeUndefined(); + expect(parsed._reasoningReplayScope?.clientThreadId).toBe("session-key-123"); + }); + + 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(); + }); + + test("an Anthropic replay without prompt_cache_key does not create a scope", async () => { + const parsed = await drive({}); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); + + test("an overlong prompt_cache_key is hashed, not stored raw", async () => { + const overlong = "k".repeat(200); + const parsed = await drive({ promptCacheKey: overlong, promptCacheKeyIsSharedCohort: false }); + const scope = parsed._reasoningReplayScope?.clientThreadId; + expect(scope).toBeDefined(); + expect(scope).not.toBe(overlong); + expect(scope!.length).toBeLessThanOrEqual(128); + }); + + test("a whitespace-only prompt_cache_key does not create a scope", async () => { + const parsed = await drive({ promptCacheKey: " ", promptCacheKeyIsSharedCohort: false }); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); +}); diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index c914825bee..571ce27c1a 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -118,6 +118,20 @@ describe("#1735 thought signature survives history replay", () => { .toBe(SIGNATURE); }); + test("a functionCall part with nested extra_content.google.thought_signature is read", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(firstTurn()); + const events = await adapter.parseResponse!(new Response(JSON.stringify(googleBody([ + { + functionCall: { name: "shell_command", args: { command: "pwd" } }, + extra_content: { google: { thought_signature: SIGNATURE } }, + }, + ])))); + const start = events.find((e: AdapterEvent) => e.type === "tool_call_start"); + expect(start && "providerMetadata" in start ? start.providerMetadata?.google?.thoughtSignature : undefined) + .toBe(SIGNATURE); + }); + test("parallel calls each keep their own signature", async () => { const adapter = createGoogleAdapter(provider); await adapter.buildRequest(firstTurn());