Skip to content
2 changes: 1 addition & 1 deletion devlog/_plan/260821_bug_merge_train/000_triage_matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions devlog/_plan/260821_bug_merge_train/065_merge_2281.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 16 additions & 3 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ interface GoogleResponsePart {
thought?: boolean;
thoughtSignature?: string;
thought_signature?: string;
extra_content?: { google?: { thought_signature?: unknown } };
functionCall?: unknown;
}

Expand All @@ -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
Expand All @@ -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 } } };
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
21 changes: 21 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
125 changes: 125 additions & 0 deletions tests/claude-code-thought-signature-scope.test.ts
Original file line number Diff line number Diff line change
@@ -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<AdapterEvent> {
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<OcxParsedRequest> {
const captured: OcxParsedRequest[] = [];
adapterFactory = () => captureAdapter(captured);
const body: Record<string, unknown> = {
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();
});
});
14 changes: 14 additions & 0 deletions tests/google-signature-history-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading