diff --git a/scripts/check_codex_comments_test.py b/scripts/check_codex_comments_test.py index 0a6aac6112..8e47649302 100644 --- a/scripts/check_codex_comments_test.py +++ b/scripts/check_codex_comments_test.py @@ -141,16 +141,20 @@ def test_observed_comments_only_exempt_completed_informational_reviews(self): cache=data if cached else None, ) - def test_auto_triggered_security_heading_does_not_hide_findings(self): - header = "### 🛡️ Codex Security Review · _Automatically triggered_\n\n" + def test_security_headings_do_not_hide_findings(self): clean = FIXTURES["security_no_findings"]["body"] - for body, expected in ( - (clean, 0), - (clean + "\n\n[P1] A security issue still needs fixing.", 1), - ("Security review completed. Found a P1 credential disclosure.", 1), + for header in ( + "### 🛡️ Codex Security Review\n\n", + "### 🛡️ Codex Security Review · _Automatically triggered_\n\n", ): - with self.subTest(body=body): - self.assert_gate(expected, snapshot([comment(header + body)])) + for body, expected in ( + (clean, 0), + (clean + "\n\n[P1] A security issue still needs fixing.", 1), + ("Security review completed. Found a P1 credential disclosure.", 1), + (header + clean, 1), + ): + with self.subTest(header=header, body=body): + self.assert_gate(expected, snapshot([comment(header + body)])) def test_summary_completion_requires_metadata_and_every_review_row(self): completed = FIXTURES["summary"]["body"] diff --git a/scripts/lib/codex_comments.jq b/scripts/lib/codex_comments.jq index f917e5a48d..9163095a4b 100644 --- a/scripts/lib/codex_comments.jq +++ b/scripts/lib/codex_comments.jq @@ -4,7 +4,7 @@ # Strip only the observed help text: a heading alone must not hide a finding # added inside the details section, or a second section appended after it. def codex_without_help: - ltrimstr("### 🛡️ Codex Security Review · _Automatically triggered_\n\n") + sub("^### 🛡️ Codex Security Review( · _Automatically triggered_)?\n\n"; "") | rtrimstr("\n\n
ℹ️ About Codex in GitHub\n
\n\n" + "[Your team has set up Codex to review pull requests in this repo](https://chatgpt.com/codex/cloud/settings/general). Reviews are triggered when you\n" + "- Open a pull request for review\n- Mark a draft as ready\n- Comment \"@codex review\" or \"@codex security review\".\n\n" diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index d88422fba1..7ebbedae99 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -1187,6 +1187,36 @@ describe("ProviderModelFactory GitHub Copilot", () => { expect(headers.get("authorization")).toBe("Bearer test-access-token"); expect(headers.get("chatgpt-account-id")).toBe("test-account-id"); expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("session-id")).toBeNull(); + + for (const [promptCacheKey, sessionId, expectedSessionId] of [ + ["mux-v1-project-scope", undefined, "mux-v1-project-scope"], + ["mux-v1-日本語-scope", undefined, "mux-v1-%E6%97%A5%E6%9C%AC%E8%AA%9E-scope"], + ["mux-v1-project\nscope", undefined, "mux-v1-project%0Ascope"], + ["mux-v1-\ud800-scope", undefined, "mux-v1-%EF%BF%BD-scope"], + ["mux-v1-project-scope", "explicit-session", "explicit-session"], + ] as const) { + const cacheHeaders = new Headers(request.headers); + if (sessionId) cacheHeaders.set("session-id", sessionId); + for (let turn = 0; turn < 2; turn++) { + await capturedFetch(request.url, { + method: "POST", + headers: cacheHeaders, + body: JSON.stringify({ + model: "gpt-5.3-codex", + input: [{ role: "user", content: `Turn ${turn}` }], + prompt_cache_key: promptCacheKey, + store: true, + truncation: "auto", + }), + }); + const outgoing = requests.at(-1); + expect(outgoing?.input).toBe(CODEX_ENDPOINT); + expect(JSON.parse(outgoing?.init?.body as string)).toMatchObject({ store: false }); + expect(JSON.parse(outgoing?.init?.body as string)).not.toHaveProperty("truncation"); + expect(new Headers(outgoing?.init?.headers).get("session-id")).toBe(expectedSessionId); + } + } } finally { PROVIDER_REGISTRY.openai = originalOpenAIRegistry; } diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index f5d7fb31bc..f5ddca9e4b 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -1728,6 +1728,22 @@ export class ProviderModelFactory { try { const headers = new Headers(init?.headers); headers.delete("content-length"); + // Codex derives its cache routing key from session-id, not the body key. + // Reuse Xum's stable scope so successive OAuth turns reach the same cache. + const { prompt_cache_key: promptCacheKey } = JSON.parse(body) as { + prompt_cache_key?: unknown; + }; + if ( + !headers.has("session-id") && + typeof promptCacheKey === "string" && + promptCacheKey.length > 0 + ) { + // UTF-8 replaces lone surrogates before encoding project names for HTTP headers. + headers.set( + "session-id", + encodeURIComponent(Buffer.from(promptCacheKey).toString()) + ); + } nextInit = { ...init, headers,