Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
}
}
2 changes: 1 addition & 1 deletion structure/transports/responses-failover.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the stale mapped compaction contracts

This source-area contract change leaves structure/overview.md:313, structure/config.md:33, and structure/dashboard-and-usage.md:132 stating that retained reasoning returns only after a readable summary or that opaque output stays unchanged. Those statements now contradict the documented native Combo failover behavior on this line, where an opaque result gains retained messages or an archive notice; update the affected mapped documents so the repository's architecture contracts agree.

AGENTS.md reference: structure/AGENTS.md:L49-L52

Useful? React with 👍 / 👎.

## Ambiguous connection-reset replay boundary

Three failures look alike from the outside — the turn may have executed and we cannot
Expand Down
58 changes: 58 additions & 0 deletions tests/responses/reasoning-retention-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,13 +19,17 @@ 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;
releaseSpendHome = acquireOwnedSpendHome();
});
afterEach(async () => {
globalThis.fetch = originalFetch;
clearComboSelectionState();
clearComboTargetCooldowns();
try { await drainCompactionResponseState(); } finally {
releaseSpendHome();
await removeCompactionFixture(home);
Expand All @@ -43,6 +48,59 @@ const historical = (text: string) => ({ type: "message", role: "user",
+ text + "\n</assistant_reasoning>" }] });

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 };
Expand Down
Loading