From 37e0fb38a0de7516aab3c73a8c5cb1660e5f7a5a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 18:27:02 +0900 Subject: [PATCH 1/2] test(compact): cover retained reasoning across canonical failover Co-authored-by: panyuanyuan Co-authored-by: yuanyuanlove --- .../reasoning-retention-routing.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/responses/reasoning-retention-routing.test.ts b/tests/responses/reasoning-retention-routing.test.ts index e610dc503e..211cc707f4 100644 --- a/tests/responses/reasoning-retention-routing.test.ts +++ b/tests/responses/reasoning-retention-routing.test.ts @@ -43,6 +43,59 @@ const historical = (text: string) => ({ type: "message", role: "user", + text + "\n" }] }); describe("retained reasoning public wire contracts", () => { + test.each([undefined, 1])("translated Combo failover to native compact preserves local reasoning (cap=%s)", async maxTokens => { + const config = keyProviderConfig({ retryOn429: { attempts: 0, intervalMs: 1, maxIntervalMs: 1 } }); + config.reasoningRetention = { enabled: true, maxTokens }; + config.providers.openai = { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex" }; + config.combos = { retained: { targets: [ + { provider: "gw", model: "some-model" }, { provider: "openai", model: "gpt-5.6-luna" }, + ] } }; + const original = " withheld reasoning\n保留原文 "; + const item = { type: "compaction", id: "cmp_combo_retention", encrypted_content: " native-opaque+/==\n" }; + const calls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const req = new Request(url, init); + // Ignore quota/metadata probes: only actual dispatches establish failover order. + if (!req.url.endsWith("/responses")) return new Response("probe", { status: 403 }); + calls.push(req.url); + const sent = await req.text(); + expect(sent).not.toContain("withheld reasoning"); + expect(sent).not.toContain("opaque-provider-secret"); + if (req.url.startsWith("https://gateway.example/")) { + return Response.json({ error: { message: "rate limit" } }, { status: 429 }); + } + expect(JSON.parse(sent).stream).toBe(true); + return sseResponse([{ type: "response.completed", response: { + id: "resp_combo_retention", status: "completed", output: [item], + } }]); + }) as typeof fetch; + const input = [{ type: "message", role: "user", content: "retain history" }, reasoning(original)]; + const snapshot = structuredClone(input); + const response = await handleResponsesCompact(compactionRequest({ model: "combo/retained", input }, undefined, { + authorization: "Bearer " + fakeChatGptJwt({ chatgpt_account_id: "retention-fixture" }), + "chatgpt-account-id": "retention-fixture", + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const body = await response.json() as { output: unknown[] }; + expect(body.output[0]).toEqual(item); + expect(calls).toEqual(["https://gateway.example/v1/responses", "https://chatgpt.com/backend-api/codex/responses"]); + expect(input).toEqual(snapshot); + const archiveDir = join(home, "reasoning-archive"); + if (maxTokens === undefined) { + expect(body.output).toContainEqual(historical(original)); + expect(existsSync(archiveDir)).toBe(false); + } else { + const files = readdirSync(archiveDir); + expect(files).toHaveLength(1); + const path = join(archiveDir, files[0]!); + expect(readFileSync(path, "utf-8")).toBe(original); + expect(JSON.stringify(body.output)).toContain(jsonText(path)); + expect(JSON.stringify(body.output)).toContain("[reasoning retention notice]"); + expect(JSON.stringify(body.output)).not.toContain("withheld reasoning"); + } + }); + test.each([undefined, 1])("routed fallback preserves opaque compaction exactly (cap=%s)", async maxTokens => { const config = keyProviderConfig(); config.reasoningRetention = { enabled: true }; From 6e34d48ed9c7e33d88853b820fbce68f3de3e113 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 18:33:50 +0900 Subject: [PATCH 2/2] fix(compact): retain reasoning across native combo failover Publish locally withheld reasoning or its archive notice alongside successful opaque native compaction output. Isolate combo state between regression cases and document the retention boundary. Co-authored-by: panyuanyuan Co-authored-by: yuanyuanlove --- .../src/content/docs/reference/configuration/server.md | 10 ++++++---- src/server/responses/compact.ts | 10 ++++++++-- structure/transports/responses-failover.md | 2 +- tests/responses/reasoning-retention-routing.test.ts | 5 +++++ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 50284c8195..08bc166c3b 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -827,8 +827,9 @@ The estimate includes each retained message's historical-context frame, not just Above the limit, readable text is written verbatim under the resolved OpenCodex home's `reasoning-archive/` (normally `~/.opencodex/reasoning-archive/`). The summarizer receives a path note instead of the text, and the finished readable summary includes a notice naming that file. -New archives are private to the user on POSIX. An archive is provisional until a readable summary -returns its path: failures, cancellation, invalid summaries, and opaque compaction responses remove +New archives are private to the user on POSIX. An archive is provisional until successful replacement history +returns its path, either in a readable summary or in a separate notice alongside a native result +after routed v1 Combo failover. Failures, cancellation, and invalid summaries remove the current attempt's file. Internal retries reuse it; previously successful archives are preserved. Failed compaction returns no replacement history. @@ -850,8 +851,9 @@ block with a warning, keeping unrelated providers and settings. Private provider ciphertext and signatures are not decrypted or copied into portable retained messages. An upstream native opaque compaction response retains its exact output item and -ciphertext, without added retained messages or a rewritten summary. Native compact endpoints -keep their existing behavior. +ciphertext. If routed v1 Combo failover reaches a native result after withholding readable reasoning, +replacement history also includes the retained historical messages or a notice naming their archive. +Native compact endpoints keep their existing behavior. Routed v2 still returns exactly one compaction item. Its proxy-owned `ocx2:` envelope stores the summary and retained historical reasoning separately as JSON/Base64; this is encoding, diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index cf581085fa..23ce5c7e30 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1792,10 +1792,16 @@ export async function handleResponsesCompact( if (typeof compactionItems[0]!.encrypted_content === "string" && compactionItems[0]!.encrypted_content.trim().length > 0 && !compactionItems[0]!.encrypted_content.startsWith("ocx1:")) { - const result = new Response(JSON.stringify({ output: compactionItems }), { + // Failover may reach native compaction after readable reasoning was withheld locally. + // The opaque item cannot contain that text, so publish it (or its archive notice) alongside. + const output = [...compactionItems, ...(retention.retainedReasoning ?? [])]; + if (retention.archived) output.push({ type: "message", role: "user", + content: [{ type: "input_text", text: appendRetentionNotice("", retention.archived).trimStart() }] }); + const result = new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" }, }); if (!options.compactionRoutingOverride) rememberCompactHandoffRoute(req, admission, raw.model); + keepArchive = true; return result; } const encrypted = compactionItems[0]!.encrypted_content; @@ -1812,7 +1818,7 @@ export async function handleResponsesCompact( keepArchive = true; return result; } finally { - // Only a readable replacement summary publishes this request's archive path. + // Keep an archive only after successful replacement history publishes its path. if (!keepArchive && retention.archived) rmSync(retention.archived.path, { force: true }); } } diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 7b55a19c1a..4116d06a62 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -336,7 +336,7 @@ build both honor for canonical ChatGPT and explicitly native-capable custom dest The portable Responses summarizer in `src/adapters/openai-responses/passthrough.ts` uses `src/adapters/openai-responses/compaction-search-history.ts` to render remaining top-level `web_search_call` items, after bridge restoration, as assistant reference notes labeled untrusted historical metadata (not instructions or fetched page content). The allowlist keeps string status and action fields (type, query/queries, URL, pattern, source URL/title/type); opaque state, IDs, unknown fields and malformed values are omitted. Strings are cut at 2048 code units, lists at 20 entries, and one request carries at most 64 KiB of notes; later hosted cells collapse into a single omission note reserved inside that budget. Tool declarations stay absent, bridge-restored call/result pairs and existing messages/citations stay intact, and this portable path changes only compact summary input, never stored history or native compaction. Ordinary Codex continuation policy is separate: see [hosted-search-continuation](../providers-and-adapters.md#hosted-search-continuation-binding). A failed summary publishes no replacement history. Lite requests retain `parallel_tool_calls=false`, which the upstream requires even without tool declarations. `tests/responses/responses-compaction-override.test.ts` covers source filtering, trigger selection, config -validation, native and routed handlers, credential retention, portable summaries and replay, and combo failover. Only explicit `reasoningRetention.enabled: true` (default off, including legacy limit-only blocks) makes routed v1 `/responses/compact` and direct routed v2 `compaction_trigger` use `src/responses/reasoning-retention.ts` to withhold readable reasoning from its summarizer; exact text including its historical-context frame within the smaller configured percentage/token cap is restored as framed historical context only after a readable summary succeeds. Repeated compact removes that frame locally again; ordinary requests replay it as a user context message, not a fresh reasoning item. Overflow is provisionally archived once per routed turn and kept only when the returned readable summary names its path; failed, cancelled, invalid, or opaque outcomes remove that attempt's file without deleting earlier successful archives. The percentage/token cap bounds retained context only; each overflow file is request-admission bounded under the resolved OpenCodex home’s `reasoning-archive/`, with no aggregate quota or automatic expiry. Successful archives require manual deletion; switching off or resetting does not delete them. Crash or filesystem cleanup failure can leave provisional files. Failure yields no replacement history. Opaque compaction returns only its unchanged item (no added retained message or rewritten ciphertext); native compact keeps existing behavior. Direct routed v2 remains a single compaction output item: `src/responses/retained-compaction.ts` encodes summary and framed reasoning separately as versioned `ocx2:` JSON/Base64 (encoding, not encryption). Replay expands them into separate historical messages; repeated v1/v2 compaction withholds the reasoning again. Existing `ocx1:` summaries remain readable. `src/codex/ocx-compaction-history.ts` also lowers `ocx2:` into separate summary and reasoning messages for explicit native-history recovery, so later proxy compaction still withholds reasoning. `src/server/responses/compaction-retention.ts` owns provisional archives and request-budget retention; failure or body cancellation releases the attempt. Emergency recovery preserves the structured envelope for JSON and SSE instead of flattening reasoning into the summary. `tests/responses/reasoning-retention-v2.test.ts` and `tests/responses/responses-compaction-recovery.test.ts` cover v2 replay and recovery; `tests/responses/reasoning-retention-routing.test.ts` covers these public contracts; `tests/responses/reasoning-retention.test.ts` covers envelopes, limits and archive failures. +validation, native and routed handlers, credential retention, portable summaries and replay, and combo failover. Only explicit `reasoningRetention.enabled: true` (default off, including legacy limit-only blocks) makes routed v1 `/responses/compact` and direct routed v2 `compaction_trigger` use `src/responses/reasoning-retention.ts` to withhold readable reasoning from its summarizer; exact text including its historical-context frame within the smaller configured percentage/token cap is restored as framed historical context after successful compaction, including routed v1 Combo failover to an opaque native result. Repeated compact removes that frame locally again; ordinary requests replay it as a user context message, not a fresh reasoning item. Overflow is provisionally archived once per routed turn and kept only when successful replacement history names its path, in the readable summary or a separate historical notice alongside an opaque native v1 result; failed, cancelled, or invalid outcomes remove that attempt's file without deleting earlier successful archives. The percentage/token cap bounds retained context only; each overflow file is request-admission bounded under the resolved OpenCodex home’s `reasoning-archive/`, with no aggregate quota or automatic expiry. Successful archives require manual deletion; switching off or resetting does not delete them. Crash or filesystem cleanup failure can leave provisional files. Failure yields no replacement history. Opaque compaction preserves its unchanged item and ciphertext. Routed v1 failover appends any locally withheld retained messages or an archive notice; direct native compact keeps existing behavior. Direct routed v2 remains a single compaction output item: `src/responses/retained-compaction.ts` encodes summary and framed reasoning separately as versioned `ocx2:` JSON/Base64 (encoding, not encryption). Replay expands them into separate historical messages; repeated v1/v2 compaction withholds the reasoning again. Existing `ocx1:` summaries remain readable. `src/codex/ocx-compaction-history.ts` also lowers `ocx2:` into separate summary and reasoning messages for explicit native-history recovery, so later proxy compaction still withholds reasoning. `src/server/responses/compaction-retention.ts` owns provisional archives and request-budget retention; failure or body cancellation releases the attempt. Emergency recovery preserves the structured envelope for JSON and SSE instead of flattening reasoning into the summary. `tests/responses/reasoning-retention-v2.test.ts` and `tests/responses/responses-compaction-recovery.test.ts` cover v2 replay and recovery; `tests/responses/reasoning-retention-routing.test.ts` covers these public contracts; `tests/responses/reasoning-retention.test.ts` covers envelopes, limits and archive failures. ## Ambiguous connection-reset replay boundary Three failures look alike from the outside — the turn may have executed and we cannot diff --git a/tests/responses/reasoning-retention-routing.test.ts b/tests/responses/reasoning-retention-routing.test.ts index 211cc707f4..d071e0a54a 100644 --- a/tests/responses/reasoning-retention-routing.test.ts +++ b/tests/responses/reasoning-retention-routing.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { compactionRequest, completedPayload, drainCompactionResponseState, @@ -18,6 +19,8 @@ let home: string; let releaseSpendHome: () => void; installCompactionRoutingAclFixture(); beforeEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); home = mkdtempSync(join(tmpdir(), "ocx-retention-routing-")); process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = home; @@ -25,6 +28,8 @@ beforeEach(() => { }); afterEach(async () => { globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); try { await drainCompactionResponseState(); } finally { releaseSpendHome(); await removeCompactionFixture(home);