diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index bf590b687a5..88896a5838b 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -68,7 +68,7 @@ Il s'agit d'un réétiquetage large et destructif : chaque fil contenant un mess ### `ocx recover-history --ocx-compaction --yes` -Réparez l'historique d'une tâche compactée par un fournisseur routé avant de la reprendre avec Codex natif. La commande sélectionne exactement une tâche par UUID, enregistre d'abord une sauvegarde privée octet par octet, puis convertit uniquement l'état de compaction `ocx1:` propre à OpenCodeX en résumé ordinaire relisible par Codex natif. Le contenu chiffré natif et les autres tâches restent inchangés. Fermez la tâche sélectionnée avant d'exécuter la commande ; toute modification simultanée du rollout interrompt la récupération sans remplacer le fichier. +Réparez l'historique d'une tâche compactée par un fournisseur routé avant de la reprendre avec Codex natif. La commande sélectionne exactement une tâche par UUID, enregistre d'abord une sauvegarde privée octet par octet, puis convertit uniquement l'état de compaction `ocx1:`/`ocx2:` propre à OpenCodeX en résumé ordinaire relisible par Codex natif. Le contenu chiffré natif et les autres tâches restent inchangés. Fermez la tâche sélectionnée avant d'exécuter la commande ; toute modification simultanée du rollout interrompt la récupération sans remplacer le fichier. ### `ocx uninstall` · `ocx remove` diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index acf3da827d7..fd76dd85a4f 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -464,10 +464,10 @@ other providers keep their defaults. Authenticated loopback routing normally keeps Codex on its built-in `openai` provider identity. That preserves native thread identity, but it also makes Codex request native remote compaction. When a routed provider cannot return a native compaction blob, OpenCodeX stores the summary in its -own `ocx1:` envelope. Native ChatGPT cannot verify that envelope if OpenCodeX is later removed from +own `ocx1:`/`ocx2:` envelope. Native ChatGPT cannot verify that envelope if OpenCodeX is later removed from the request path. -On an authenticated loopback route, enable client-side compaction to keep V2 sub-agent routing while preventing new `ocx1:` compaction summaries. Non-loopback and API-key routes retain their existing provider and authentication behavior: +On an authenticated loopback route, enable client-side compaction to keep V2 sub-agent routing while preventing new `ocx1:`/`ocx2:` compaction summaries. Non-loopback and API-key routes retain their existing provider and authentication behavior: ```bash ocx system settings --client-compaction on # or "codexClientCompaction": true in config.json @@ -495,7 +495,7 @@ selected provider. V2 sub-agent requests keep their existing provider selection accounting. Client-side compaction does not change plaintext delivery, encrypted task passthrough through `allowEncryptedV2AgentTasks`, or configured recovery and fallback behavior. -This preference affects future compactions only, and it rewrites no existing `ocx1:` payload in +This preference affects future compactions only, and it rewrites no existing `ocx1:`/`ocx2:` payload in any configuration, so use the explicit history recovery workflow for a thread that needs one. Whether resume-history metadata is re-tagged depends on which form the injection takes. On its diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 84acd32d7f1..a99c2b13cc1 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -63,7 +63,7 @@ ocx eject back ### `ocx recover-history --ocx-compaction --yes` -ルーティングされたプロバイダーで圧縮されたタスクをネイティブ Codex で再開する前に、その履歴を修復します。このコマンドは UUID で 1 つのタスクだけを選択し、非公開のバイト単位バックアップを保存してから、OpenCodeX 所有の `ocx1:` 圧縮状態だけをネイティブ Codex が再生できる通常の要約に変換します。ネイティブの暗号化コンテンツと他のタスクは変更しません。実行前に対象タスクを閉じてください。処理中に rollout が変更された場合、ファイルを置き換えずに修復を中止します。 +ルーティングされたプロバイダーで圧縮されたタスクをネイティブ Codex で再開する前に、その履歴を修復します。このコマンドは UUID で 1 つのタスクだけを選択し、非公開のバイト単位バックアップを保存してから、OpenCodeX 所有の `ocx1:`/`ocx2:` 圧縮状態だけをネイティブ Codex が再生できる通常の要約に変換します。ネイティブの暗号化コンテンツと他のタスクは変更しません。実行前に対象タスクを閉じてください。処理中に rollout が変更された場合、ファイルを置き換えずに修復を中止します。 ### `ocx uninstall`・`ocx remove` diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index aa2e4a8db43..3eb3719cc80 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -106,7 +106,7 @@ dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 ### `ocx recover-history --ocx-compaction --yes` -라우팅된 provider를 통해 압축된 작업을 native Codex에서 다시 열기 전에 해당 기록을 복구합니다. 이 명령은 UUID로 정확히 하나의 작업을 선택하고 비공개 바이트 단위 백업을 저장한 뒤, OpenCodeX가 소유한 `ocx1:` 압축 상태만 native Codex가 재생할 수 있는 일반 요약으로 변환합니다. native 암호화 콘텐츠와 다른 작업은 변경하지 않습니다. 실행 전에 선택한 작업을 닫으십시오. 처리 중 rollout이 변경되면 파일을 교체하지 않고 복구를 중단합니다. +라우팅된 provider를 통해 압축된 작업을 native Codex에서 다시 열기 전에 해당 기록을 복구합니다. 이 명령은 UUID로 정확히 하나의 작업을 선택하고 비공개 바이트 단위 백업을 저장한 뒤, OpenCodeX가 소유한 `ocx1:`/`ocx2:` 압축 상태만 native Codex가 재생할 수 있는 일반 요약으로 변환합니다. native 암호화 콘텐츠와 다른 작업은 변경하지 않습니다. 실행 전에 선택한 작업을 닫으십시오. 처리 중 rollout이 변경되면 파일을 교체하지 않고 복구를 중단합니다. ### `ocx uninstall` · `ocx remove` diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 1a8f7a509d1..0f38fee7c17 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -245,7 +245,7 @@ intended. Repair one thread that was compacted through a routed provider before resuming it through native Codex. The command reads the exact thread selected by UUID, saves a private byte-for-byte backup, -then converts only OpenCodeX-owned `ocx1:` compaction state into a plain summary that native Codex +then converts only OpenCodeX-owned `ocx1:`/`ocx2:` compaction state into a plain summary that native Codex can replay. Native encrypted content and other threads are left unchanged. Close the selected thread before running the command; a concurrent rollout change makes recovery stop without replacing the file. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 45da7168de8..3e031580b16 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -757,6 +757,66 @@ without tool declarations. This changes only the summary request, not stored his compaction; a failed summary does not replace the history. Restart the proxy after editing `config.json` by hand. Dashboard saves apply immediately. +### Reasoning retention during compaction + +Readable reasoning retention is an advanced, explicit opt-in and is **off by default**. +Only `reasoningRetention.enabled: true` enables it; an omitted block, `enabled: false`, +or legacy limits without `enabled: true` leave ordinary compaction behavior unchanged. +When enabled for routed v1 `POST /v1/responses/compact` and routed v2 `compaction_trigger` requests, readable reasoning stays local while the selected +model summarizes other context. After a readable summary succeeds, exact retained text returns +alongside it in framed historical context messages, not as new assistant reasoning or instructions. +Repeated compaction withholds those messages again. Ordinary subsequent turns replay them as history. + +```json +{ + "reasoningRetention": { + "enabled": true, + "maxContextPercent": 20, + "maxTokens": 100000 + } +} +``` + +Both limit fields are optional and, **when enabled**, default at runtime to `20` percent and `100000` estimated tokens. +The effective limit is the smaller of the percentage of the compacting model's context window +and the absolute token cap; when the context window is unknown, only the absolute cap applies. +The estimate includes each retained message's historical-context frame, not just its reasoning text. +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 +the current attempt's file. Internal retries reuse it; previously successful archives are preserved. +Failed compaction returns no replacement history. + +The percentage/token limits bound reasoning returned in context, **not cumulative archive storage**. +Each archive contains only readable reasoning admitted in that request; request admission is bounded +by `maxInboundBodyBytes` (256 MiB by default, configurable up to 512 MiB), plus small block separators. +There is **no aggregate archive byte/file limit, automatic expiry, or background archive cleanup**. +Successful archives remain until you manually delete their files, including after disabling retention +or resetting the setting. They are plaintext, contain potentially sensitive conversation reasoning, +and share the resolved OpenCodex home of that proxy instance. Deleting an archive makes any existing +path notice unable to recover its contents; save anything you need first. A process crash or failed +filesystem write/cleanup can also leave a provisional file requiring manual cleanup. + +**Dashboard → Overview → Advanced settings → Reasoning retention** provides the explicit switch +and both limits; **Reset and turn off** removes the override and switches retention off. GET/PUT `/api/settings` exposes `reasoningRetention`: null clears the block, +and omission preserves it. `enabled` must be a boolean when present. Percentages must be greater than 0 and at most 100; token caps must +be positive integers, and unknown fields are rejected. Invalid hand edits disable only this +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. + +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, +not provider encryption. Subsequent requests restore separate historical messages, and +repeated compaction withholds the reasoning again. Old `ocx1:` summaries remain supported. +Opt-in emergency compaction recovery also preserves this separation for JSON and SSE. +Explicit native-history recovery restores separate summary and historical reasoning messages too. + ## Memory routing In **Dashboard → Overview → Memory routing**, choose a model and an optional reasoning effort for diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 8ed62d8c7d7..1110ac3ba21 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -101,7 +101,7 @@ ocx eject back ### `ocx recover-history --ocx-compaction --yes` -Исправьте историю одной задачи, сжатой через маршрутизируемого провайдера, перед её возобновлением в нативном Codex. Команда выбирает ровно одну задачу по UUID, сначала сохраняет приватную побайтовую резервную копию, а затем преобразует только принадлежащее OpenCodeX состояние сжатия `ocx1:` в обычную сводку, которую может воспроизвести нативный Codex. Нативное зашифрованное содержимое и другие задачи не изменяются. Перед запуском закройте выбранную задачу; если rollout изменится во время обработки, восстановление остановится без замены файла. +Исправьте историю одной задачи, сжатой через маршрутизируемого провайдера, перед её возобновлением в нативном Codex. Команда выбирает ровно одну задачу по UUID, сначала сохраняет приватную побайтовую резервную копию, а затем преобразует только принадлежащее OpenCodeX состояние сжатия `ocx1:`/`ocx2:` в обычную сводку, которую может воспроизвести нативный Codex. Нативное зашифрованное содержимое и другие задачи не изменяются. Перед запуском закройте выбранную задачу; если rollout изменится во время обработки, восстановление остановится без замены файла. ### `ocx uninstall` · `ocx remove` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index f5791a12523..736b005dbee 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -105,7 +105,7 @@ dahildir. Durumu yedekleyin ve yalnızca bu kapsamın tamamını istiyorsanız ### `ocx recover-history --ocx-compaction --yes` -Yönlendirilmiş bir sağlayıcı üzerinden sıkıştırılmış bir görevi yerel Codex ile sürdürmeden önce geçmişini onarın. Komut UUID ile yalnızca bir görevi seçer, önce özel ve bayt bayt bir yedek kaydeder, ardından yalnızca OpenCodeX'e ait `ocx1:` sıkıştırma durumunu yerel Codex'in yeniden oynatabileceği düz bir özete dönüştürür. Yerel şifreli içerik ve diğer görevler değişmeden kalır. Komutu çalıştırmadan önce seçili görevi kapatın; işlem sırasında rollout değişirse kurtarma dosyayı değiştirmeden durur. +Yönlendirilmiş bir sağlayıcı üzerinden sıkıştırılmış bir görevi yerel Codex ile sürdürmeden önce geçmişini onarın. Komut UUID ile yalnızca bir görevi seçer, önce özel ve bayt bayt bir yedek kaydeder, ardından yalnızca OpenCodeX'e ait `ocx1:`/`ocx2:` sıkıştırma durumunu yerel Codex'in yeniden oynatabileceği düz bir özete dönüştürür. Yerel şifreli içerik ve diğer görevler değişmeden kalır. Komutu çalıştırmadan önce seçili görevi kapatın; işlem sırasında rollout değişirse kurtarma dosyayı değiştirmeden durur. ### `ocx uninstall` · `ocx remove` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index c13b8aa9797..6f150c9c7a6 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -62,7 +62,7 @@ ocx eject back ### `ocx recover-history --ocx-compaction --yes` -在通过原生 Codex 恢复某个曾由路由提供方压缩的任务前,修复该任务的历史记录。此命令按 UUID 精确选择一个任务,先保存私有的逐字节备份,然后仅将 OpenCodeX 自有的 `ocx1:` 压缩状态转换为原生 Codex 可重放的普通摘要。原生加密内容和其他任务保持不变。运行前请关闭所选任务;如果 rollout 在处理期间发生变化,恢复会停止且不会替换原文件。 +在通过原生 Codex 恢复某个曾由路由提供方压缩的任务前,修复该任务的历史记录。此命令按 UUID 精确选择一个任务,先保存私有的逐字节备份,然后仅将 OpenCodeX 自有的 `ocx1:`/`ocx2:` 压缩状态转换为原生 Codex 可重放的普通摘要。原生加密内容和其他任务保持不变。运行前请关闭所选任务;如果 rollout 在处理期间发生变化,恢复会停止且不会替换原文件。 ### `ocx uninstall` · `ocx remove` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index c67785a0f30..b452d93b7a5 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -61,7 +61,7 @@ ocx eject back ### `ocx recover-history --ocx-compaction --yes` -在透過原生 Codex 恢復曾由路由提供方壓縮的工作前,修復該工作的歷史記錄。此命令依 UUID 精確選取一個工作,先儲存私有的逐位元組備份,然後只把 OpenCodeX 自有的 `ocx1:` 壓縮狀態轉換成原生 Codex 可重播的普通摘要。原生加密內容與其他工作不會變更。執行前請關閉所選工作;若 rollout 在處理期間發生變化,復原會停止且不會取代原始檔案。 +在透過原生 Codex 恢復曾由路由提供方壓縮的工作前,修復該工作的歷史記錄。此命令依 UUID 精確選取一個工作,先儲存私有的逐位元組備份,然後只把 OpenCodeX 自有的 `ocx1:`/`ocx2:` 壓縮狀態轉換成原生 Codex 可重播的普通摘要。原生加密內容與其他工作不會變更。執行前請關閉所選工作;若 rollout 在處理期間發生變化,復原會停止且不會取代原始檔案。 ### `ocx uninstall` · `ocx remove` diff --git a/gui/src/components/ReasoningRetentionPanel.tsx b/gui/src/components/ReasoningRetentionPanel.tsx new file mode 100644 index 00000000000..9d8d3c130bd --- /dev/null +++ b/gui/src/components/ReasoningRetentionPanel.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +import { createBoundedFetch } from "../bounded-fetch"; +import { requireJson } from "../pages/dashboard-shared"; +import { Switch } from "../ui"; + +type Setting = { enabled?: boolean; maxContextPercent?: number; maxTokens?: number } | null; +function readSetting(payload: { reasoningRetention?: Setting }): Setting { + if (!("reasoningRetention" in payload)) throw new Error("Missing reasoningRetention settings"); + return payload.reasoningRetention ?? null; +} + +export default function ReasoningRetentionPanel({ apiBase }: { apiBase: string }) { + const t = useT(); + return
+ {t("reasoningRetention.advanced")} + +
; +} +function ReasoningRetentionControls({ apiBase }: { apiBase: string }) { + const t = useT(); + const [saved, setSaved] = useState(); + const [enabled, setEnabled] = useState(false); + const [percent, setPercent] = useState("20"); + const [tokens, setTokens] = useState("100000"); + const [busy, setBusy] = useState(false); + const [loadError, setLoadError] = useState(false); + const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); + const active = useRef(false); + const pending = useRef | null>(null); + const accept = useCallback((value: Setting) => { + setSaved(value); + setEnabled(value?.enabled === true); + setPercent(String(value?.maxContextPercent ?? 20)); + setTokens(String(value?.maxTokens ?? 100000)); + }, []); + const load = useCallback(async () => { + if (pending.current) return; + const request = createBoundedFetch(15_000); + pending.current = request; + setLoadError(false); + try { + const response = await fetch(`${apiBase}/api/settings`, { signal: request.signal }); + const value = readSetting(await requireJson(response)); + if (active.current && pending.current === request) accept(value); + } catch { + if (active.current && pending.current === request) setLoadError(true); + } finally { + request.clear(); + if (pending.current === request) pending.current = null; + } + }, [apiBase, accept]); + useEffect(() => { + active.current = true; + const timer = window.setTimeout(() => { void load(); }, 0); + return () => { + window.clearTimeout(timer); + active.current = false; + pending.current?.controller.abort(); + pending.current?.clear(); + pending.current = null; + }; + }, [load]); + const validPercent = percent.trim() !== "" && Number.isFinite(Number(percent)) && Number(percent) > 0 && Number(percent) <= 100; + const validTokens = tokens.trim() !== "" && Number.isSafeInteger(Number(tokens)) && Number(tokens) > 0; + const valid = !enabled || (validPercent && validTokens); + const changed = enabled !== (saved?.enabled === true) || (enabled && ( + Number(percent) !== (saved?.maxContextPercent ?? 20) || Number(tokens) !== (saved?.maxTokens ?? 100000) + )); + const save = async (reset = false) => { + if (pending.current || saved === undefined || (!reset && !valid)) return; + const request = createBoundedFetch(15_000); + pending.current = request; + setBusy(true); setFeedback(null); + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", headers: { "Content-Type": "application/json" }, signal: request.signal, + body: JSON.stringify({ reasoningRetention: reset ? null : enabled + ? { enabled: true, maxContextPercent: Number(percent), maxTokens: Number(tokens) } + : { enabled: false } }), + }); + const value = readSetting(await requireJson(response)); + if (active.current && pending.current === request) { accept(value); setFeedback("saved"); } + } catch { + if (active.current && pending.current === request) setFeedback("failed"); + } finally { + request.clear(); + if (active.current && pending.current === request) setBusy(false); + if (pending.current === request) pending.current = null; + } + }; + const disabled = busy || saved === undefined || loadError; + return
+
{t("reasoningRetention.title")}
+

{t("reasoningRetention.description")}

+

{t("reasoningRetention.storageHint")}

+ { setEnabled(value => !value); setFeedback(null); }} /> +
+ {enabled && <> + } + + +
+ {!valid && } + {loadError &&
{t("reasoningRetention.loadFailed")}
} + {feedback === "failed" &&
{t("reasoningRetention.saveFailed")}
} + {feedback === "saved" &&
{t("reasoningRetention.saved")}
} +
; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1844dfbaeea..8ca77ecf44d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -6,6 +6,18 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "reasoningRetention.advanced": "Erweiterte Einstellungen", + "reasoningRetention.title": "Aufbewahrung von Denktext", + "reasoningRetention.enable": "Aufbewahrung von Denktext aktivieren", + "reasoningRetention.description": "Standardmäßig ausgeschaltet. Bei aktivierter Aufbewahrung für geroutete v1/v2-Komprimierung mit lesbarer Zusammenfassung kehrt Denktext bis zum kleineren Wert aus Kontextfensteranteil und Token-Limit zusammen mit der Zusammenfassung als historischer Kontext zurück. Darüber wird er lokal archiviert und durch einen Pfadhinweis ersetzt. Standardwerte bei Aktivierung: 20 % und 100.000 Token. Private Anbieter-Denkdaten werden nicht entschlüsselt; undurchsichtige Komprimierungsausgaben bleiben unverändert.", + "reasoningRetention.storageHint": "Durch das Aktivieren kann Denktext im Klartext im lokalen Verlauf und in Archiven erhalten bleiben. Für abgeschlossene Archive gibt es weder ein Limit für die Gesamtgröße noch eine Ablauffrist; löschen Sie nicht mehr benötigte Archive manuell.", + "reasoningRetention.percent": "Kontextfenster (%)", + "reasoningRetention.tokens": "Token-Limit", + "reasoningRetention.reset": "Zurücksetzen und ausschalten", + "reasoningRetention.invalid": "Prozentsatz größer als 0 und höchstens 100 sowie eine positive ganze Tokenzahl eingeben.", + "reasoningRetention.loadFailed": "Einstellungen konnten nicht geladen werden.", + "reasoningRetention.saveFailed": "Einstellungen konnten nicht gespeichert werden.", + "reasoningRetention.saved": "Einstellungen gespeichert.", ...desktopCompatibilityCopy("de"), "claude.tierDefaults.unset": "Nicht festgelegt (Claude-Einstellung beibehalten)", "claude.tierDefaults.recovery": "Anwendung gestoppt; automatische Wiederherstellung fehlgeschlagen. Prüfen Sie vor einem erneuten Versuch die Sicherung unten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index bff6fef8e56..3bbe423f8fe 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -7,6 +7,18 @@ import { desktopCompatibilityCopy } from "./desktop-compatibility-copy"; * `{var}` are plain interpolations. */ export const en = { + "reasoningRetention.advanced": "Advanced settings", + "reasoningRetention.title": "Reasoning retention", + "reasoningRetention.enable": "Enable reasoning retention", + "reasoningRetention.description": "Off by default. When enabled for routed v1/v2 compaction with a readable summary, reasoning within the smaller of the context-window percentage and token cap returns as historical context alongside the summary. Above the limit, it is archived locally and replaced by a path note. Defaults when enabled: 20% and 100,000 tokens. Private provider reasoning is not decrypted; opaque compaction output stays unchanged.", + "reasoningRetention.storageHint": "Enabling can preserve plaintext reasoning in local history and archives. Completed archives have no total-size limit or expiry; delete unneeded archives manually.", + "reasoningRetention.percent": "Context window (%)", + "reasoningRetention.tokens": "Token cap", + "reasoningRetention.reset": "Reset and turn off", + "reasoningRetention.invalid": "Enter a percentage greater than 0 and at most 100, and a positive whole token count.", + "reasoningRetention.loadFailed": "Could not load reasoning retention settings.", + "reasoningRetention.saveFailed": "Could not save reasoning retention settings.", + "reasoningRetention.saved": "Reasoning retention settings saved.", ...desktopCompatibilityCopy("en"), "claude.tierDefaults.unset": "Unset (keep existing Claude setting)", "claude.tierDefaults.recovery": "Apply stopped and automatic restore failed. Check the backup below before retrying.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5681f60e2d2..fb2365c8652 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -5,6 +5,18 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "reasoningRetention.advanced": "Paramètres avancés", + "reasoningRetention.title": "Conservation du raisonnement", + "reasoningRetention.enable": "Activer la conservation du raisonnement", + "reasoningRetention.description": "Désactivée par défaut. Lorsqu’elle est activée pour une compression v1/v2 routée avec un résumé lisible, le raisonnement dans la limite du plus petit des deux seuils, pourcentage de la fenêtre de contexte et plafond de tokens, revient comme contexte historique avec le résumé. Au-delà, il est archivé localement et remplacé par une indication du chemin. Valeurs par défaut à l’activation : 20 % et 100 000 tokens. Le raisonnement privé du fournisseur n’est pas déchiffré ; les sorties de compression opaques restent inchangées.", + "reasoningRetention.storageHint": "L’activation peut conserver le raisonnement en texte clair dans l’historique local et les archives. La taille totale des archives terminées n’est pas limitée et celles-ci n’expirent pas ; supprimez manuellement les archives inutiles.", + "reasoningRetention.percent": "Fenêtre de contexte (%)", + "reasoningRetention.tokens": "Plafond de tokens", + "reasoningRetention.reset": "Réinitialiser et désactiver", + "reasoningRetention.invalid": "Saisissez un pourcentage supérieur à 0 et inférieur ou égal à 100, et un nombre entier positif de tokens.", + "reasoningRetention.loadFailed": "Impossible de charger les paramètres.", + "reasoningRetention.saveFailed": "Impossible de sauvegarder les paramètres.", + "reasoningRetention.saved": "Paramètres enregistrés.", ...desktopCompatibilityCopy("fr"), "claude.tierDefaults.unset": "Non défini (conserver le réglage Claude)", "claude.tierDefaults.recovery": "Application arrêtée et restauration automatique échouée. Vérifiez la sauvegarde ci-dessous avant de réessayer.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ce6e00d9c7e..4bb5978a202 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -5,6 +5,18 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "reasoningRetention.advanced": "詳細設定", + "reasoningRetention.title": "推論の保持", + "reasoningRetention.enable": "推論の保持を有効にする", + "reasoningRetention.description": "既定では無効です。読み取り可能な要約を返すルーティング v1/v2 圧縮で有効にすると、コンテキストウィンドウの割合とトークン上限の小さい方までの推論が、要約とともに履歴コンテキストとして戻されます。上限を超えると、ローカルにアーカイブされ、パスの説明に置き換えられます。有効時の既定値:20% と 100,000 トークン。プロバイダーの非公開推論は復号せず、不透明な圧縮出力は変更しません。", + "reasoningRetention.storageHint": "有効にすると、平文の推論がローカルの履歴とアーカイブに残る場合があります。作成済みのアーカイブには合計サイズの上限や有効期限がありません。不要なアーカイブは手動で削除してください。", + "reasoningRetention.percent": "コンテキスト割合(%)", + "reasoningRetention.tokens": "トークン上限", + "reasoningRetention.reset": "リセットして無効にする", + "reasoningRetention.invalid": "割合は 0 より大きく 100 以下、トークン上限は正の整数にしてください。", + "reasoningRetention.loadFailed": "設定を読み込めませんでした。", + "reasoningRetention.saveFailed": "設定を保存できませんでした。", + "reasoningRetention.saved": "設定を保存しました。", ...desktopCompatibilityCopy("ja"), "claude.tierDefaults.unset": "未指定(既存のClaude設定を維持)", "claude.tierDefaults.recovery": "適用が停止し、自動復元に失敗しました。再試行する前に以下のバックアップを確認してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c1e1e77d9f2..4d31ccb67da 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -5,6 +5,18 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "reasoningRetention.advanced": "고급 설정", + "reasoningRetention.title": "추론 보존", + "reasoningRetention.enable": "추론 보존 활성화", + "reasoningRetention.description": "기본적으로 꺼져 있습니다. 읽을 수 있는 요약을 반환하는 라우팅 v1/v2 압축에서 활성화하면 컨텍스트 창 비율과 토큰 한도 중 작은 값 이내의 추론이 요약과 함께 과거 컨텍스트로 돌아옵니다. 한도를 초과하면 로컬에 보관되고 경로 안내로 대체됩니다. 활성화 시 기본값: 20% 및 100,000 토큰. 제공자의 비공개 추론을 복호화하지 않으며 불투명한 압축 출력은 그대로 유지합니다.", + "reasoningRetention.storageHint": "활성화하면 평문 추론이 로컬 기록과 보관 파일에 남을 수 있습니다. 완성된 보관 파일에는 전체 크기 제한이나 만료 기한이 없으므로 불필요한 파일은 직접 삭제하세요.", + "reasoningRetention.percent": "컨텍스트 비율 (%)", + "reasoningRetention.tokens": "토큰 한도", + "reasoningRetention.reset": "초기화하고 끄기", + "reasoningRetention.invalid": "비율은 0 초과 100 이하, 토큰 한도는 양의 정수여야 합니다.", + "reasoningRetention.loadFailed": "설정을 불러올 수 없습니다.", + "reasoningRetention.saveFailed": "설정을 저장할 수 없습니다.", + "reasoningRetention.saved": "설정을 저장했습니다.", ...desktopCompatibilityCopy("ko"), "claude.tierDefaults.unset": "미지정 (기존 Claude 설정 유지)", "claude.tierDefaults.recovery": "적용이 중단되었고 자동 복원에 실패했어요. 다시 시도하기 전에 아래 백업을 확인하세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index cca8aaa6964..1b5146b1016 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -7,6 +7,18 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const pt: Record = { + "reasoningRetention.advanced": "Configurações avançadas", + "reasoningRetention.title": "Retenção de raciocínio", + "reasoningRetention.enable": "Ativar retenção de raciocínio", + "reasoningRetention.description": "Desativada por padrão. Quando ativada para compactação v1/v2 roteada com resumo legível, o raciocínio até o menor valor entre a porcentagem da janela de contexto e o limite de tokens retorna como contexto histórico junto com o resumo. Acima do limite, ele é arquivado localmente e substituído por uma indicação do caminho. Padrões quando ativada: 20% e 100.000 tokens. O raciocínio privado do provedor não é descriptografado; a saída opaca de compactação permanece inalterada.", + "reasoningRetention.storageHint": "A ativação pode preservar o raciocínio em texto não criptografado no histórico local e nos arquivos. Os arquivos concluídos não têm limite de tamanho total nem prazo de expiração; exclua manualmente os arquivos desnecessários.", + "reasoningRetention.percent": "Janela de contexto (%)", + "reasoningRetention.tokens": "Limite de tokens", + "reasoningRetention.reset": "Redefinir e desativar", + "reasoningRetention.invalid": "Informe uma porcentagem maior que 0 e no máximo 100, e um número inteiro positivo de tokens.", + "reasoningRetention.loadFailed": "Não foi possível carregar as configurações de retenção de raciocínio.", + "reasoningRetention.saveFailed": "Não foi possível salvar as configurações de retenção de raciocínio.", + "reasoningRetention.saved": "Configurações de retenção de raciocínio salvas.", ...desktopCompatibilityCopy("pt"), "claude.tierDefaults.unset": "Não definido (manter configuração Claude)", "claude.tierDefaults.recovery": "Aplicação interrompida; restauração automática falhou. Verifique o backup abaixo antes de tentar novamente.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2709713c0bb..5e558710bb7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -5,6 +5,18 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "reasoningRetention.advanced": "Расширенные настройки", + "reasoningRetention.title": "Сохранение рассуждений", + "reasoningRetention.enable": "Включить сохранение рассуждений", + "reasoningRetention.description": "По умолчанию выключено. Если включено для маршрутизируемого сжатия v1/v2 с читаемой сводкой, рассуждения в пределах меньшего из доли окна контекста и лимита токенов возвращаются как исторический контекст вместе со сводкой. При превышении лимита они архивируются локально и заменяются примечанием с путём. Значения по умолчанию при включении: 20% и 100 000 токенов. Приватные рассуждения провайдера не расшифровываются; непрозрачный результат сжатия не изменяется.", + "reasoningRetention.storageHint": "При включении рассуждения могут сохраняться открытым текстом в локальной истории и архивах. У готовых архивов нет ограничения на общий размер или срока хранения; удаляйте ненужные архивы вручную.", + "reasoningRetention.percent": "Доля контекста (%)", + "reasoningRetention.tokens": "Лимит токенов", + "reasoningRetention.reset": "Сбросить и выключить", + "reasoningRetention.invalid": "Введите процент больше 0 и не больше 100 и положительное целое число токенов.", + "reasoningRetention.loadFailed": "Не удалось загрузить настройки.", + "reasoningRetention.saveFailed": "Не удалось сохранить настройки.", + "reasoningRetention.saved": "Настройки сохранены.", ...desktopCompatibilityCopy("ru"), "claude.tierDefaults.unset": "Не задано (сохранить настройку Claude)", "claude.tierDefaults.recovery": "Применение остановлено, автоматическое восстановление не удалось. Проверьте резервную копию ниже перед повтором.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 154d0356c53..ed0005d2a4c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -6,6 +6,18 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "reasoningRetention.advanced": "Gelişmiş ayarlar", + "reasoningRetention.title": "Akıl yürütmeyi saklama", + "reasoningRetention.enable": "Akıl yürütmeyi saklamayı etkinleştir", + "reasoningRetention.description": "Varsayılan olarak kapalıdır. Okunabilir özet döndüren yönlendirilmiş v1/v2 sıkıştırma için etkinleştirildiğinde, bağlam penceresi yüzdesi ile token sınırının küçüğü içinde kalan akıl yürütme, özetle birlikte geçmiş bağlam olarak geri getirilir. Sınır aşılırsa yerel olarak arşivlenir ve dosya yolu notuyla değiştirilir. Etkinleştirildiğindeki varsayılanlar: %20 ve 100.000 token. Sağlayıcının özel akıl yürütmesinin şifresi çözülmez; opak sıkıştırma çıktısı değişmeden kalır.", + "reasoningRetention.storageHint": "Etkinleştirmek, akıl yürütmenin yerel geçmişte ve arşivlerde düz metin olarak saklanmasına yol açabilir. Tamamlanan arşivlerin toplam boyut sınırı veya son kullanma süresi yoktur; gereksiz arşivleri elle silin.", + "reasoningRetention.percent": "Bağlam penceresi (%)", + "reasoningRetention.tokens": "Token sınırı", + "reasoningRetention.reset": "Sıfırla ve kapat", + "reasoningRetention.invalid": "0’dan büyük, 100’ü aşmayan bir yüzde ve pozitif tam sayı token sınırı girin.", + "reasoningRetention.loadFailed": "Ayarlar yüklenemedi.", + "reasoningRetention.saveFailed": "Ayarlar kaydedilemedi.", + "reasoningRetention.saved": "Ayarlar kaydedildi.", ...desktopCompatibilityCopy("tr"), "claude.tierDefaults.unset": "Belirtilmedi (mevcut Claude ayarını koru)", "claude.tierDefaults.recovery": "Uygulama durdu ve otomatik geri yükleme başarısız oldu. Yeniden denemeden önce aşağıdaki yedeği kontrol edin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a0b33ac014d..116ff9f5f51 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -7,6 +7,18 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const vi: Record = { + "reasoningRetention.advanced": "Cài đặt nâng cao", + "reasoningRetention.title": "Lưu giữ suy luận", + "reasoningRetention.enable": "Bật lưu giữ suy luận", + "reasoningRetention.description": "Mặc định tắt. Khi được bật cho quá trình nén v1/v2 qua định tuyến có bản tóm tắt đọc được, suy luận trong ngưỡng nhỏ hơn giữa tỷ lệ cửa sổ ngữ cảnh và giới hạn token sẽ được trả lại dưới dạng ngữ cảnh lịch sử cùng bản tóm tắt. Vượt ngưỡng, suy luận được lưu trữ cục bộ và thay bằng ghi chú đường dẫn. Giá trị mặc định khi bật: 20% và 100.000 token. Không giải mã suy luận riêng tư của nhà cung cấp; đầu ra nén không đọc được giữ nguyên.", + "reasoningRetention.storageHint": "Việc bật có thể lưu suy luận ở dạng văn bản thuần trong lịch sử cục bộ và các bản lưu trữ. Các bản lưu trữ đã hoàn tất không có giới hạn tổng dung lượng hay thời hạn hết hạn; hãy xóa thủ công những bản không cần thiết.", + "reasoningRetention.percent": "Cửa sổ ngữ cảnh (%)", + "reasoningRetention.tokens": "Giới hạn token", + "reasoningRetention.reset": "Đặt lại và tắt", + "reasoningRetention.invalid": "Nhập tỷ lệ lớn hơn 0 và không quá 100, cùng số token nguyên dương.", + "reasoningRetention.loadFailed": "Không thể tải cài đặt.", + "reasoningRetention.saveFailed": "Không thể lưu cài đặt.", + "reasoningRetention.saved": "Đã lưu cài đặt.", ...desktopCompatibilityCopy("vi"), "claude.tierDefaults.unset": "Chưa đặt (giữ cài đặt Claude hiện có)", "claude.tierDefaults.recovery": "Đã dừng áp dụng và khôi phục tự động thất bại. Kiểm tra bản sao lưu bên dưới trước khi thử lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f2378eaedfd..f5c76c8a954 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3,6 +3,18 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "reasoningRetention.advanced": "進階設定", + "reasoningRetention.title": "推理保留", + "reasoningRetention.enable": "啟用推理保留", + "reasoningRetention.description": "預設關閉。對於傳回可讀摘要的路由 v1/v2 壓縮,啟用後,在上下文視窗比例與 token 上限兩者較小值以內的推理會隨摘要作為歷史上下文帶回。超過上限時,推理會在本機封存並由路徑說明取代。啟用時的預設值:20% 和 100,000 token。不會解密供應商的私有推理;不透明的壓縮輸出保持原樣。", + "reasoningRetention.storageHint": "啟用後,明文推理可能會保留在本機歷史記錄與封存檔中。已完成的封存檔沒有總大小限制或到期時間;請手動刪除不需要的封存檔。", + "reasoningRetention.percent": "上下文視窗比例(%)", + "reasoningRetention.tokens": "Token 上限", + "reasoningRetention.reset": "重設並關閉", + "reasoningRetention.invalid": "比例須大於 0 且不超過 100,token 上限須為正整數。", + "reasoningRetention.loadFailed": "無法載入推理保留設定。", + "reasoningRetention.saveFailed": "無法儲存推理保留設定。", + "reasoningRetention.saved": "推理保留設定已儲存。", ...desktopCompatibilityCopy("zh-TW"), "claude.tierDefaults.unset": "未設定(保留現有 Claude 設定)", "claude.tierDefaults.recovery": "套用已停止,自動還原失敗。重試前請檢查下方備份。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cf32dcc5a88..1f24233c95c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -5,6 +5,18 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "reasoningRetention.advanced": "高级设置", + "reasoningRetention.title": "推理保留", + "reasoningRetention.enable": "启用推理保留", + "reasoningRetention.description": "默认关闭。对于返回可读摘要的路由 v1/v2 压缩,启用后,在上下文窗口比例与 token 上限两者较小值以内的推理会随摘要作为历史上下文带回。超过上限时,推理会在本地归档并由路径说明替代。启用时的默认值:20% 和 100,000 token。不会解密提供商的私有推理;不透明的压缩输出保持原样。", + "reasoningRetention.storageHint": "启用后,明文推理可能会保留在本地历史记录和归档中。已完成的归档没有总大小限制或到期时间;请手动删除不需要的归档。", + "reasoningRetention.percent": "上下文窗口比例(%)", + "reasoningRetention.tokens": "Token 上限", + "reasoningRetention.reset": "重置并关闭", + "reasoningRetention.invalid": "比例须大于 0 且不超过 100,token 上限须为正整数。", + "reasoningRetention.loadFailed": "无法加载推理保留设置。", + "reasoningRetention.saveFailed": "无法保存推理保留设置。", + "reasoningRetention.saved": "推理保留设置已保存。", ...desktopCompatibilityCopy("zh"), "claude.tierDefaults.unset": "未设置(保留现有 Claude 设置)", "claude.tierDefaults.recovery": "应用已停止,自动恢复失败。重试前请检查下方备份。", diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx index 5d7347aa52d..da86d2e73c3 100644 --- a/gui/src/pages/dashboard-overview-panels.tsx +++ b/gui/src/pages/dashboard-overview-panels.tsx @@ -1,4 +1,5 @@ import CompactionRoutingPanel from "../components/CompactionRoutingPanel"; +import ReasoningRetentionPanel from "../components/ReasoningRetentionPanel"; import MemoryObservabilityCard from "../components/MemoryObservabilityCard"; import MemoryModelsPanel from "../components/MemoryModelsPanel"; import type { useDashboardData } from "./use-dashboard-data"; @@ -23,6 +24,7 @@ export function DashboardOverviewPanels(props: Dash) { + ); } diff --git a/gui/tests/reasoning-retention-panel.test.tsx b/gui/tests/reasoning-retention-panel.test.tsx new file mode 100644 index 00000000000..ef07a19838a --- /dev/null +++ b/gui/tests/reasoning-retention-panel.test.tsx @@ -0,0 +1,225 @@ +/** @jsxImportSource react */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, StrictMode } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import ReasoningRetentionPanel from "../src/components/ReasoningRetentionPanel"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record; +let win: Window; +let root: Root | undefined; +let container: HTMLDivElement; +let setting: { enabled?: boolean; maxContextPercent?: number; maxTokens?: number } | null; +let failLoad: boolean; +let failSave: boolean; +let writes: unknown[]; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + for (const key of ["document", "window", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + win.localStorage.setItem("ocx-lang", "en"); + setting = null; failLoad = false; failSave = false; writes = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (_input: unknown, init?: RequestInit) => { + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); + writes.push(body); + if (failSave) return Response.json({ error: "fixture failure" }, { status: 500 }); + setting = body.reasoningRetention; + } else if (failLoad) return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json({ reasoningRetention: setting }); + } }); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + root = undefined; win.close(); + for (const key of globals) { + if (previous[key]) Object.defineProperty(globalThis, key, previous[key]!); + else delete (globalThis as Record)[key]; + } +}); + +async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); } +async function render(base = "") { + if (!root) { + container = win.document.createElement("div") as unknown as HTMLDivElement; + win.document.body.appendChild(container); + root = (await import("react-dom/client")).createRoot(container); + } + await act(async () => { root!.render(); }); + await flush(); +} +function button(text: string) { return [...container.querySelectorAll('button')].find(b => b.textContent === text)!; } +async function change(id: string, value: string) { + await act(async () => { + const input = container.querySelector(`#reasoning-retention-${id}`)!; + input.value = value; + input.dispatchEvent(new win.Event("input", { bubbles: true }) as unknown as Event); + }); +} +async function toggleEnabled() { + await act(async () => { button("Enable reasoning retention").click(); }); +} +function enabledButton() { return button("Enable reasoning retention"); } + +test.each([null, { enabled: false }, { maxTokens: 50000, maxContextPercent: 10 }])( + "starts off without explicit opt-in: %j", async initial => { + setting = initial; + await render(); + expect(enabledButton().getAttribute("aria-pressed")).toBe("false"); + expect(container.querySelector("#reasoning-retention-percent")).toBeNull(); + expect(container.querySelector("#reasoning-retention-tokens")).toBeNull(); + expect(button("Save").disabled).toBe(true); + expect(writes).toEqual([]); + }, +); + +test("advanced settings starts collapsed and opens through its summary", async () => { + await render(); + const details = container.querySelector("details")!; + const summary = details.querySelector("summary")!; + expect(summary.textContent).toBe("Advanced settings"); + expect(details.open).toBe(false); + await act(async () => { summary.click(); }); + expect(details.open).toBe(true); + expect(details.contains(enabledButton())).toBe(true); + expect(container.textContent).toContain("plaintext reasoning"); + expect(container.textContent).toContain("no total-size limit or expiry"); +}); + +test("explicit enable saves numeric defaults and disable persists off", async () => { + await render(); + await toggleEnabled(); + expect(writes).toEqual([]); + expect(button("Save").disabled).toBe(false); + await act(async () => { button("Save").click(); }); + expect(writes).toEqual([{ reasoningRetention: { enabled: true, maxContextPercent: 20, maxTokens: 100000 } }]); + await toggleEnabled(); + expect(container.querySelector("#reasoning-retention-tokens")).toBeNull(); + await act(async () => { button("Save").click(); }); + expect(writes.at(-1)).toEqual({ reasoningRetention: { enabled: false } }); + await render("/reload"); + expect(enabledButton().getAttribute("aria-pressed")).toBe("false"); + expect(button("Save").disabled).toBe(true); +}); + +test("legacy threshold-only settings remain off until explicitly enabled", async () => { + setting = { maxContextPercent: 12.5, maxTokens: 50000 }; + await render(); + await toggleEnabled(); + expect(container.querySelector("#reasoning-retention-tokens")!.value).toBe("50000"); + await act(async () => { button("Save").click(); }); + expect(writes).toEqual([{ reasoningRetention: { enabled: true, maxContextPercent: 12.5, maxTokens: 50000 } }]); +}); + +test("saves custom limits and reset removes settings and turns off", async () => { + await render(); + await toggleEnabled(); + expect(container.querySelector('#reasoning-retention-percent')!.value).toBe("20"); + expect(container.querySelector('#reasoning-retention-tokens')!.value).toBe("100000"); + await change("percent", "12.5"); + await change("tokens", "50000"); + await act(async () => { button("Save").click(); }); + expect(writes).toEqual([{ reasoningRetention: { enabled: true, maxContextPercent: 12.5, maxTokens: 50000 } }]); + await render("/reload"); + expect(container.querySelector('#reasoning-retention-tokens')!.value).toBe("50000"); + await act(async () => { button("Reset and turn off").click(); }); + expect(writes.at(-1)).toEqual({ reasoningRetention: null }); + expect(enabledButton().getAttribute("aria-pressed")).toBe("false"); + expect(container.querySelector("#reasoning-retention-percent")).toBeNull(); + expect(button("Reset and turn off").disabled).toBe(true); +}); +test("invalid numeric limits disable save and expose validation", async () => { + await render(); + await toggleEnabled(); + for (const [id, value] of [["percent", "0"], ["percent", "101"], ["tokens", "1.5"], ["tokens", ""]]) { + await change(id, value); + expect(button("Save").disabled).toBe(true); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + await change("percent", "20"); await change("tokens", "100000"); + } + expect(writes).toEqual([]); +}); +test.each([["percent", "0", "tokens", "12.5"], ["tokens", "1.5", "percent", "50000"]])( + "only invalid %s is associated with the validation alert", async (id, invalidValue, otherId, validValue) => { + await render(); + await toggleEnabled(); + await change(id!, invalidValue!); + const invalid = container.querySelector(`#reasoning-retention-${id}`)!; + const valid = container.querySelector(`#reasoning-retention-${otherId}`)!; + expect(invalid.getAttribute("aria-invalid")).toBe("true"); + expect(invalid.getAttribute("aria-describedby")).toBe("reasoning-retention-error"); + expect(valid.getAttribute("aria-invalid")).toBeNull(); + expect(valid.getAttribute("aria-describedby")).toBeNull(); + expect(container.querySelector("#reasoning-retention-error")?.getAttribute("role")).toBe("alert"); + expect(button("Save").disabled).toBe(true); + await change(id!, validValue!); + expect(invalid.getAttribute("aria-invalid")).toBeNull(); + expect(invalid.getAttribute("aria-describedby")).toBeNull(); + expect(container.querySelector("#reasoning-retention-error")).toBeNull(); + expect(button("Save").disabled).toBe(false); + expect(writes).toEqual([]); + }, +); +test("save failures keep edits and load failures can retry", async () => { + failLoad = true; await render(); + expect(button("Save").disabled).toBe(true); + failLoad = false; + await act(async () => { button("Retry").click(); }); + await toggleEnabled(); + await change("tokens", "50000"); failSave = true; + await act(async () => { button("Save").click(); }); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not save"); + expect(container.querySelector('#reasoning-retention-tokens')!.value).toBe("50000"); +}); + +test("missing settings payload disables writes instead of assuming defaults", async () => { + const normalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({})) as typeof fetch; + await render(); + expect(button("Save").disabled).toBe(true); + expect(button("Reset and turn off").disabled).toBe(true); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load"); + globalThis.fetch = normalFetch; + await act(async () => { button("Retry").click(); }); + await toggleEnabled(); + await change("tokens", "50000"); + expect(button("Save").disabled).toBe(false); +}); + +test("pending save disables controls and duplicate clicks send only one update", async () => { + await render(); + await toggleEnabled(); + await change("tokens", "50000"); + const pending = Promise.withResolvers(); + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + writes.push(JSON.parse(String(init?.body))); + return pending.promise; + }) as typeof fetch; + await act(async () => { button("Save").click(); button("Save").click(); }); + expect(writes).toHaveLength(1); + expect(button("Save").disabled).toBe(true); + expect(enabledButton().disabled).toBe(true); + expect(button("Reset and turn off").disabled).toBe(true); + expect(container.querySelector('#reasoning-retention-tokens')!.disabled).toBe(true); + await act(async () => { pending.resolve(Response.json({ reasoningRetention: { enabled: true, maxTokens: 50000, maxContextPercent: 20 } })); }); + expect(container.querySelector('[role="status"]')?.textContent).toContain("saved"); +}); + +test("a late response from the previous API target cannot replace current settings", async () => { + const pending = Promise.withResolvers(); + globalThis.fetch = (async (input: unknown) => String(input).startsWith("/old/") + ? pending.promise : Response.json({ reasoningRetention: { enabled: true, maxTokens: 60000 } })) as typeof fetch; + await render("/old"); + expect(button("Save").disabled).toBe(true); + await render("/new"); + expect(container.querySelector('#reasoning-retention-tokens')!.value).toBe("60000"); + await act(async () => { pending.resolve(Response.json({ reasoningRetention: { enabled: true, maxTokens: 123 } })); }); + expect(container.querySelector('#reasoning-retention-tokens')!.value).toBe("60000"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..610caea0899 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1400,7 +1400,7 @@ "reasoning-effort-summary-default.test.ts": "responses", "reasoning-effort.test.ts": "codex-integration", "reasoning-envelope.test.ts": "responses", - "reasoning-metadata.test.ts": "codex-integration", + "reasoning-retention.test.ts": "responses", "reasoning-retention-routing.test.ts": "responses", "reasoning-retention-v2.test.ts": "responses", "reasoning-metadata.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", @@ -1629,7 +1629,7 @@ "settings-desktop-switch-apply.test.ts": "config", "settings-fast-rows.test.ts": "config", "settings-main-account-hard-lock.test.ts": "config", - "settings-memory-models.test.ts": "config", + "settings-memory-models.test.ts": "config", "settings-reasoning-retention.test.ts": "config", "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config", "settings-stream-mode.test.ts": "config", diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 429a66cdd8d..07a8dd2a9a1 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -1,3 +1,4 @@ +import { expandRetainedCompactionInput } from "../../responses/retained-compaction"; import { createHash } from "node:crypto"; import { COMPACT_PROMPT, appendCheckpointContinuationNote, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../../responses/compaction"; import { debugProviderDiagnostic } from "../../lib/debug"; @@ -246,7 +247,7 @@ export function stripItemIdsWhenUnstored( * Normalize replayed compaction items for the destination backend. * * A compaction item carries an `encrypted_content` blob the client replays verbatim on every later - * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are + * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` and structured `ocx2:` envelopes are * transparent base64 rather than encryption, so no upstream can read them and they always become * plain user messages. Native blobs have multiple possible minters, so a destination's ability to * decode its own blobs does not make a blob from a previous serving identity portable. On a known @@ -264,9 +265,10 @@ export function scrubOcxCompactionItems( ): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - let changed = false; + const expanded = expandRetainedCompactionInput(body.input) as unknown[]; + let changed = expanded !== body.input; let decodedCheckpoint = false; - const input = body.input.map(item => { + const input = expanded.map(item => { if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item; const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined; if (encrypted === undefined) return item; diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index f74ebccf77b..c3aa465a51e 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -67,6 +67,8 @@ export function buildResponseJSON( if (options?.translatorBudget && options.recordBufferedDelivery !== false) { attemptDeliveryRecorder(budget)?.noteBufferedDelivery(body); } + if (body.status === "completed" && options?.compaction + && !events.some(event => event.type === "done" && event.compactionEncryptedContent)) options.compactionRetention?.commit(); return body; } finally { for (const event of events) { @@ -98,6 +100,7 @@ function buildResponseJSONWithBudget( toolSearchToolNames?: Set; /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */ compaction?: boolean; + compactionRetention?: { encode(summary: string): string; commit(): void }; onProviderState?: (state: OcxProviderContinuationState) => void; /** Raw adapter-reported usage before wire normalization (see bridgeToResponsesSSE onUsage). */ onUsage?: (usage: OcxUsage | undefined) => void; @@ -616,7 +619,7 @@ function buildResponseJSONWithBudget( ) { const item = { type: "compaction", id: `cmp_${uuid()}`, - encrypted_content: compactionEncryptedContent ?? encodeCompactionSummary(joinChunks(batchCompaction)), + encrypted_content: compactionEncryptedContent ?? (options?.compactionRetention?.encode(joinChunks(batchCompaction)) ?? encodeCompactionSummary(joinChunks(batchCompaction))), }; pushOutput(item, compactionEncryptedContent ? 0 : batchCompaction.bytes); } diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index e34c3bebdf6..9a6a19f1ccb 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -88,6 +88,7 @@ export function bridgeToResponsesSSE( * response.completed — codex-rs collect_compaction_output requires exactly one. */ compaction?: boolean; + compactionRetention?: { encode(summary: string): string; commit(): void }; /** One-shot: first non-empty text/thinking/raw-reasoning/tool-input delta observed. */ onFirstOutput?: () => void; onTerminal?: (status: ResponsesTerminalStatus) => void; @@ -1233,7 +1234,7 @@ export function bridgeToResponsesSSE( // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0. const item = { type: "compaction", id: `cmp_${uuid()}`, - encrypted_content: event.compactionEncryptedContent ?? encodeCompactionSummary(joinChunks(compaction)), + encrypted_content: event.compactionEncryptedContent ?? (options?.compactionRetention?.encode(joinChunks(compaction)) ?? encodeCompactionSummary(joinChunks(compaction))), }; emit("response.output_item.done", { output_index: outputIndex, item }); retainFinishedItem(item as OutputItem, event.compactionEncryptedContent ? 0 : compaction.bytes); @@ -1271,6 +1272,7 @@ export function bridgeToResponsesSSE( emit("response.completed", { response, }); + if (!closed && !terminated && options?.compaction && !event.compactionEncryptedContent) options.compactionRetention?.commit(); reportTerminal("completed"); } terminalEvent = true; diff --git a/src/codex/ocx-compaction-history.ts b/src/codex/ocx-compaction-history.ts index 7cab765c4f0..3168a2e118e 100644 --- a/src/codex/ocx-compaction-history.ts +++ b/src/codex/ocx-compaction-history.ts @@ -26,6 +26,7 @@ import { isCompactionItemType, SUMMARY_PREFIX, } from "../responses/compaction"; +import { decodeRetainedCompaction } from "../responses/retained-compaction"; import { resolveCodexHomeDir } from "./home"; import { resolveCodexStateDbPath } from "./paths"; @@ -96,21 +97,22 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function lowerCompactionItem(item: unknown): { item: unknown; changed: boolean } { +function lowerCompactionItem(item: unknown): { items: unknown[]; changed: boolean } { if (!isRecord(item) || !isCompactionItemType(item.type)) { - return { item, changed: false }; + return { items: [item], changed: false }; } if (typeof item.encrypted_content !== "string") { - return { item, changed: false }; + return { items: [item], changed: false }; } - const summary = decodeCompactionSummary(item.encrypted_content); - if (summary === null) return { item, changed: false }; + const retained = decodeRetainedCompaction(item.encrypted_content); + const summary = retained?.summary ?? decodeCompactionSummary(item.encrypted_content); + if (summary === null) return { items: [item], changed: false }; return { - item: { + items: [{ type: "message", role: "user", content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n${summary}` }], - }, + }, ...(retained?.reasoning ?? []).map(text => ({ type: "message", role: "user", content: [{ type: "input_text", text }] }))], changed: true, }; } @@ -129,10 +131,10 @@ function rewriteJsonlLine(line: string): { line: string; replaced: number } { if (!Array.isArray(history)) return { line, replaced: 0 }; let replaced = 0; - const replacementHistory = history.map(item => { + const replacementHistory = history.flatMap(item => { const lowered = lowerCompactionItem(item); if (lowered.changed) replaced += 1; - return lowered.item; + return lowered.items; }); if (replaced === 0) return { line, replaced: 0 }; @@ -146,7 +148,7 @@ function rewriteJsonlLine(line: string): { line: string; replaced: number } { } /** - * Convert OpenCodeX-owned `ocx1:` compaction items into ordinary replayable user messages. + * Convert OpenCodeX-owned `ocx1:`/`ocx2:` compaction items into ordinary replayable user messages. * * Only the authoritative `compacted.payload.replacement_history` snapshot is changed. Earlier * response-item events are historical output and are deliberately preserved byte-for-byte. diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index d91f8bd602b..8ec01a4e462 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -25,6 +25,7 @@ import { sanitizeModelCostsForLoad, sanitizeAutoReviewForLoad, degradedListenerWarnings, + degradedReasoningRetentionWarning, degradedCodexAccountPriorityWarnings, degradedCodexQuotaAutoRefreshWarning, normalizeApiKeyIds, @@ -71,6 +72,7 @@ import { chatgptDesktopSchema, chatgptDesktopConfigIssue, compactionRoutingSchema, + reasoningRetentionSchema, skillsConfigSchema, memoryModelsSchema, } from "./schema/leaf-validators"; @@ -164,6 +166,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (codexPoolWarning) warnings.push(codexPoolWarning); const spendWarning = malformedSpendWarning(rawParsed); if (spendWarning) warnings.push(spendWarning); + const retentionWarning = degradedReasoningRetentionWarning(rawParsed, normalized); + if (retentionWarning) warnings.push(retentionWarning); if (rawPoolAliasesError(rawParsed)) { warnings.push("spendPoolAliases invalid: configured pool admission is refused until the mapping is corrected"); } @@ -681,6 +685,10 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx return { ok: false, error: "schema_invalid: chatgptDesktop: requires an optional boolean appServerShim and no other fields" }; } const compactionRouting = rawConfigRecord(value)?.compactionRouting; + const reasoningRetention = rawConfigRecord(value)?.reasoningRetention; + if (reasoningRetention !== undefined && !reasoningRetentionSchema.safeParse(reasoningRetention).success) { + return { ok: false, error: "schema_invalid: reasoningRetention: requires optional boolean enabled, maxContextPercent greater than 0 and at most 100 and/or positive integer maxTokens, with no other fields" }; + } if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) { return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" }; } diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 2dd4c46644f..550de5489b5 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -132,6 +132,8 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx */ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void { setCodexWsReuseAcrossTurns(validated.codexWsReuseAcrossTurns === true); + const retentionWarning = degradedReasoningRetentionWarning(rawParsed, validated); + if (retentionWarning) console.warn(retentionWarning); if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved"); if (desktopCompatibilityConfigError(rawParsed)) console.warn("Invalid desktopCompatibility startup preference ignored; no desktop compatibility service will start automatically."); if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available"); @@ -147,6 +149,11 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon } } +export function degradedReasoningRetentionWarning(rawParsed: unknown, validated: OcxConfig): string | null { + if (rawConfigRecord(rawParsed)?.reasoningRetention === undefined || validated.reasoningRetention !== undefined) return null; + return "config.json reasoningRetention is invalid — reasoning retention is disabled"; +} + /** * A malformed `memoryModels` phase disables that phase rather than failing the whole schema, so * say so once: silently keeping whatever route the phase already had — which may be the shadow diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 36a09ba78f9..ee0f0760888 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -30,6 +30,7 @@ import { codexAccountNamespacesSchema, modelPinnedEffortsSchema, compactionRoutingSchema, + reasoningRetentionSchema, memoryModelSettingSchema, memoryModelsSchema, modelPreferHostedToolsConfigError, @@ -193,6 +194,8 @@ export const configSchema = z.object({ modelPinnedEfforts: modelPinnedEffortsSchema.optional(), compactionRouting: compactionRoutingSchema.optional().catch(undefined), compactionRecovery: compactionRecoverySchema.optional().catch(undefined), + // A bad hand edit disables only this block; writes validate the catch-free leaf first. + reasoningRetention: reasoningRetentionSchema.optional().catch(undefined), desktopCompatibility: desktopCompatibilitySchema.optional().catch(undefined), // A hand-edited malformed phase disables only that phase instead of rejecting // providers/apiKeys, matching the load-time degradation notice; the management write diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 75b39a29ba3..a6ab9ce4204 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -63,6 +63,12 @@ export const compactionRoutingSchema = z.object({ .optional(), }).strict(); +export const reasoningRetentionSchema = z.object({ + enabled: z.boolean().optional(), + maxContextPercent: z.number().gt(0).max(100).optional(), + maxTokens: z.number().int().positive().optional(), +}).strict(); + /** * One phase of Codex's memory pipeline. A present phase must name a model: the GUI's "Off" * removes the phase instead of blanking it, so an empty entry would only ever come from a diff --git a/src/responses/compaction.ts b/src/responses/compaction.ts index b3286033ac9..1e77f35b841 100644 --- a/src/responses/compaction.ts +++ b/src/responses/compaction.ts @@ -15,6 +15,7 @@ * routed models get a short "history was compacted" note instead. */ +import { decodeRetainedCompaction, RETAINED_COMPACTION_PREFIX } from "./retained-compaction"; import type { TranslatorBudget } from "../lib/translator-budget"; const ciphertextLeases = new WeakMap(); @@ -57,7 +58,7 @@ export const SUMMARY_PREFIX = "Another language model started to solve this prob export const OPAQUE_COMPACTION_NOTE = "[earlier conversation was compacted; the summary is stored in a format this model cannot read]"; /** - * Prompt contract for a routed model receiving a decoded `ocx1` checkpoint as user text (#6764). + * Prompt contract for a routed model receiving a decoded `ocx1`/`ocx2` checkpoint as user text (#6764). * It preserves existing authorization scope and permission gates; it grants no authority. * Kept apart from SUMMARY_PREFIX, which the v1 retention and recovery paths match exactly. */ @@ -94,6 +95,10 @@ export function encodeCompactionSummary(summary: string): string { /** Decode an `ocx1:` envelope; returns null for real (OpenAI-encrypted) blobs or garbage. */ export function decodeCompactionSummary(encryptedContent: string): string | null { + if (encryptedContent.startsWith(RETAINED_COMPACTION_PREFIX)) { + const retained = decodeRetainedCompaction(encryptedContent); + return retained ? [retained.summary, ...retained.reasoning].join("\n\n") : null; + } if (!encryptedContent.startsWith(OCX_COMPACTION_PREFIX)) return null; try { return Buffer.from(encryptedContent.slice(OCX_COMPACTION_PREFIX.length), "base64").toString("utf-8"); diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 0c372164de0..275914482bd 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -15,7 +15,8 @@ import { createToolChoiceResolver, namespacedToolName } from "../types"; import { responsesRequestSchema } from "./schema"; import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata"; import { lookupReplayThoughtSignature } from "./thought-signature-replay"; -import { CHECKPOINT_CONTINUATION_NOTE, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "./compaction"; +import { decodeRetainedCompaction, RETAINED_COMPACTION_PREFIX } from "./retained-compaction"; +import { CHECKPOINT_CONTINUATION_NOTE, compactionItemToText, decodeCompactionSummary, isCompactionItemType, SUMMARY_PREFIX } from "./compaction"; import { previousResponseReplayPrefixLength } from "./state"; import { decodeReasoningEnvelope } from "./reasoning-envelope"; import { hasRoutedIdentity, nameRoutedIdentity } from "../adapters/identity"; @@ -281,7 +282,7 @@ export function parseRequest( } if (isCompactionItemType(effectiveType)) { - // A stored summary from a previous compaction. Decode our ocx1 envelope into plain text so + // A stored summary from a previous compaction. Decode our ocx1/ocx2 envelopes into plain text so // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note. // `context_compaction` (encrypted_content optional) is codex-rs's local-compaction marker; // with no payload it is a pure marker (the summary follows as its own user message), so it @@ -293,6 +294,13 @@ export function parseRequest( if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue; if (typeof encrypted === "string" && decodeCompactionSummary(encrypted) !== null) decodedCheckpoint = true; pendingReasoning.length = 0; + if (typeof encrypted === "string" && encrypted.startsWith(RETAINED_COMPACTION_PREFIX)) { + const retained = decodeRetainedCompaction(encrypted); + if (!retained) throw new Error("Invalid OpenCodex retained compaction envelope"); + messages.push({ role: "user", content: `${SUMMARY_PREFIX}\n\n${retained.summary}`, timestamp: now }); + messages.push(...retained.reasoning.map(content => ({ role: "user" as const, content, timestamp: now }))); + continue; + } messages.push({ role: "user", content: compactionItemToText(typeof encrypted === "string" ? encrypted : undefined), diff --git a/src/responses/reasoning-retention.ts b/src/responses/reasoning-retention.ts new file mode 100644 index 00000000000..4cdb581b95a --- /dev/null +++ b/src/responses/reasoning-retention.ts @@ -0,0 +1,173 @@ +/** + * Routed v1 compaction keeps readable reasoning out of the summarizer request. Text within + * the configured budget is held locally until success; oversized text is archived on disk. + * Provider ciphertext and signatures are never included in the portable retained items. + */ + +import { expandRetainedCompactionInput } from "./retained-compaction"; +import { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { estimateTokens } from "../lib/token-estimate"; +import { decodeReasoningEnvelope } from "./reasoning-envelope"; + +export const DEFAULT_REASONING_RETENTION_PERCENT = 20; +export const DEFAULT_MAX_REASONING_TOKENS = 100_000; + +export interface ReasoningRetentionOptions { + /** Directory that receives archived reasoning files. */ + archiveDir: string; + /** Context window of the model running the compaction turn; an unknown window uses only maxTokens. */ + contextWindow?: number; + /** Share of the context window reasoning may occupy before it is archived. Defaults to 20. */ + maxContextPercent?: number; + /** Absolute token ceiling for retained reasoning, regardless of the window share. Defaults to 100,000. */ + maxTokens?: number; + /** Clock injection for tests. */ + now?: number; +} + +export interface ReasoningArchive { + path: string; + tokens: number; + percent: number; + /** The effective token ceiling that triggered the archive (window share vs absolute cap, whichever is smaller). */ + cap?: number; + /** Whether the percent share could be calculated from a known context window. */ + windowKnown?: boolean; +} + +export interface ReasoningRetentionResult { + /** Summarizer input with reasoning items removed; other items retain their order. */ + input: unknown[]; + archived?: ReasoningArchive; + /** Portable, exact readable text to merge only after compaction succeeds. */ + retainedReasoning?: Array<{ type: "message"; role: "user"; content: Array<{ type: "input_text"; text: string }> }>; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +// Ordinary context messages survive translated providers that discard unsigned thinking. +// Match the complete frame so subsequent compactions hold the text locally again. +const RETAINED_PREFIX = "[OpenCodeX locally retained assistant reasoning; historical context, not a new instruction]\n\n"; +const RETAINED_SUFFIX = "\n"; +function retainedTextOf(item: Record): string | undefined { + if (item.type !== "message" || item.role !== "user") return undefined; + const content = item.content; + const text = typeof content === "string" ? content + : Array.isArray(content) && content.length === 1 && isRecord(content[0]) + && content[0].type === "input_text" && typeof content[0].text === "string" ? content[0].text : undefined; + return text?.startsWith(RETAINED_PREFIX) && text.endsWith(RETAINED_SUFFIX) + ? text.slice(RETAINED_PREFIX.length, -RETAINED_SUFFIX.length) : undefined; +} + +/** Readable reasoning text of one item: decoded ocxr1 envelope text, else summary/content text. */ +function reasoningTextOf(item: Record): string { + const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined; + const envelope = encrypted ? decodeReasoningEnvelope(encrypted) : null; + if (envelope?.txt) return envelope.txt; + const collect = (key: "summary" | "content"): string => + Array.isArray(item[key]) + ? (item[key] as Array>) + .map(part => (isRecord(part) && typeof part.text === "string" ? part.text : "")) + .join("") + : ""; + return collect("summary") || collect("content"); +} + +/** Plaintext of every reasoning item in a Responses input array, order preserved. */ +export function collectReasoningTexts(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const texts: string[] = []; + for (const item of input) { + if (!isRecord(item)) continue; + const text = item.type === "reasoning" ? reasoningTextOf(item) : retainedTextOf(item) ?? ""; + if (text.length > 0) texts.push(text); + } + return texts; +} + +function retentionPercent(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100 + ? value + : DEFAULT_REASONING_RETENTION_PERCENT; +} + +function retentionTokenCap(value: number | undefined): number { + return typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : DEFAULT_MAX_REASONING_TOKENS; +} + +/** Separate reasoning locally before dispatch; unknown windows enforce the absolute cap. */ +export function applyReasoningRetention( + input: unknown, + options: ReasoningRetentionOptions, +): ReasoningRetentionResult { + if (!Array.isArray(input)) return { input: [] }; + input = expandRetainedCompactionInput(input); + if (!Array.isArray(input)) return { input: [] }; + const texts = collectReasoningTexts(input); + const retained = input.filter(item => !(isRecord(item) && (item.type === "reasoning" || retainedTextOf(item) !== undefined))); + if (texts.length === 0) return { input: retained.length === input.length ? input : retained }; + const window = options.contextWindow; + + const percent = retentionPercent(options.maxContextPercent); + const cap = retentionTokenCap(options.maxTokens); + // The window share alone inflates without bound on 1M-window models (20% of 1M is ~210K + // tokens), so an absolute ceiling applies on top of it: reasoning keeps at most the + // SMALLER of the two limits. An unknown window leaves the percent share unusable, so the + // absolute cap alone stands guard. + const windowKnown = typeof window === "number" && Number.isInteger(window) && window > 0; + const limit = windowKnown ? Math.min(Math.floor(window * (percent / 100)), cap) : cap; + const framedTexts = texts.map(text => RETAINED_PREFIX + text + RETAINED_SUFFIX); + // Budget the historical context that is actually replayed, including every block's frame. + const tokens = estimateTokens(framedTexts.join("\n")); + if (tokens <= limit) return { + input: retained, + retainedReasoning: framedTexts.map(text => ({ type: "message", role: "user", + content: [{ type: "input_text", text }] })), + }; + + mkdirSync(options.archiveDir, { recursive: true, mode: 0o700 }); + const stamp = new Date(options.now ?? Date.now()).toISOString().replace(/[:.]/g, "-"); + const path = join(options.archiveDir, "reasoning-" + stamp + "-" + Math.random().toString(36).slice(2, 8) + ".md"); + // Only a successful exclusive open grants ownership; an occupied path is never removed. + const fd = openSync(path, "wx", 0o600); + let failure: unknown; + try { writeFileSync(fd, texts.join("\n\n---\n\n"), "utf-8"); } catch (error) { failure = error; } + try { closeSync(fd); } catch (error) { failure ??= error; } + if (failure !== undefined) { + try { rmSync(path); } catch { /* preserve the original I/O error; cleanup is best-effort */ } + throw failure; + } + + const limitDescription = windowKnown + ? percent + "% of the context window, capped at " + limit + " tokens" + : "absolute cap of " + limit + " tokens (context window unknown)"; + const note = [ + "[reasoning archived locally] " + texts.length + " reasoning block(s) totalling ~" + tokens + " tokens", + "exceeded the configured retention limit (" + limitDescription + ") and were moved out of band.", + "Full text: " + path, + ].join(" "); + const triggerIndex = retained.findIndex(item => isRecord(item) && item.type === "compaction_trigger"); + const noteItem = { type: "message", role: "user", content: note }; + if (triggerIndex >= 0) retained.splice(triggerIndex, 0, noteItem); + else retained.push(noteItem); + return { input: retained, archived: { path, tokens, percent, cap: limit, windowKnown } }; +} + +/** + * User-visible notice appended to a compaction summary when reasoning was archived. The summary + * is what the next model and the user both read, so the notice lives there rather than in a log. + */ +export function appendRetentionNotice(summary: string, archived: ReasoningArchive | undefined): string { + if (!archived) return summary; + const limitDescription = archived.windowKnown === false + ? "absolute cap of " + (archived.cap ?? "unknown") + " tokens (context window unknown)" + : archived.percent + "% of the context window (effective cap: " + (archived.cap ?? "unknown") + " tokens)"; + return summary + "\n\n[reasoning retention notice] Reasoning of ~" + archived.tokens + " tokens exceeded " + limitDescription + + " and was archived locally at " + archived.path + + ". This summary covers conclusions only; read that file for the full reasoning."; +} diff --git a/src/responses/retained-compaction.ts b/src/responses/retained-compaction.ts new file mode 100644 index 00000000000..4552503a207 --- /dev/null +++ b/src/responses/retained-compaction.ts @@ -0,0 +1,46 @@ +/** Versioned, proxy-owned compaction data. This is encoding, never provider ciphertext. */ +export const RETAINED_COMPACTION_PREFIX = "ocx2:"; + +export interface RetainedCompaction { + version: 2; + summary: string; + /** Already framed historical context, preserved byte-for-byte on replay. */ + reasoning: string[]; +} + +export function encodeRetainedCompaction(summary: string, reasoning: string[]): string { + return RETAINED_COMPACTION_PREFIX + Buffer.from(JSON.stringify({ version: 2, summary, reasoning }), "utf-8").toString("base64"); +} + +export function decodeRetainedCompaction(value: string): RetainedCompaction | null { + if (!value.startsWith(RETAINED_COMPACTION_PREFIX)) return null; + try { + const payload = value.slice(RETAINED_COMPACTION_PREFIX.length); + const bytes = Buffer.from(payload, "base64"); + if (bytes.toString("base64") !== payload) return null; + const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const item = parsed as Record; + if (Object.keys(item).some(key => !["version", "summary", "reasoning"].includes(key)) + || item.version !== 2 || typeof item.summary !== "string" || !item.summary.trim() + || !Array.isArray(item.reasoning) || !item.reasoning.every(text => typeof text === "string")) return null; + return item as unknown as RetainedCompaction; + } catch { return null; } +} + +/** Expand only our structured envelopes; callers retain their existing native/ocx1 policy. */ +export function expandRetainedCompactionInput(input: unknown): unknown { + if (!Array.isArray(input)) return input; + if (!input.some(item => item && typeof item === "object" && ["compaction", "compaction_summary", "context_compaction"].includes(item.type) + && typeof item.encrypted_content === "string" && item.encrypted_content.startsWith(RETAINED_COMPACTION_PREFIX))) return input; + return input.flatMap(item => { + if (!item || typeof item !== "object" || !["compaction", "compaction_summary", "context_compaction"].includes(item.type) + || typeof item.encrypted_content !== "string" || !item.encrypted_content.startsWith(RETAINED_COMPACTION_PREFIX)) return [item]; + const data = decodeRetainedCompaction(item.encrypted_content); + if (!data) throw new Error("Invalid OpenCodex retained compaction envelope"); + return [ + { ...item, encrypted_content: "ocx1:" + Buffer.from(data.summary, "utf-8").toString("base64") }, + ...data.reasoning.map(text => ({ type: "message", role: "user", content: [{ type: "input_text", text }] })), + ]; + }); +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index ce6de3b2d52..a77d496d28e 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -2,7 +2,7 @@ import { MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "../../codex/quota-types"; import { resolveMainAccountHardLockThresholds } from "../../codex/main-account-hard-lock"; import { getMainAccountExternalUsageWarning } from "../../codex/main-account-external-usage"; import { getObservedMainQuotaIdentityKey } from "../../codex/main-account-cache"; -import { compactionRoutingSchema, memoryModelsSchema } from "../../config/schema/leaf-validators"; +import { compactionRoutingSchema, memoryModelsSchema, reasoningRetentionSchema } from "../../config/schema/leaf-validators"; import { compactionRecoverySchema } from "../../config/schema/compaction-recovery"; import { anthropicSidecarPatchError } from "../../config/schema/anthropic-account-pool"; import { isAnthropicInstanceId } from "../../providers/anthropic-instance-id"; @@ -437,6 +437,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { @@ -293,7 +293,7 @@ export async function deliverAdapterResponse( toolParameterSchemas, freeformToolNames, toolSearchToolNames, - ...(routedCompaction ? { compaction: true } : {}), + ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), onProviderState: state => { providerState = state; }, onUsage: usage => { transportState.bindKeyUsageFromBridge(usage); diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index c3c74102035..b00bc8223a0 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -3,6 +3,8 @@ import { createPhysicalSendReporter } from "../../lib/request-execution-budget"; import { createInferenceSendBudget } from "../inference/context"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { capturePoolQuotaWriter } from "../../codex/account-store"; +import { join } from "node:path"; +import { rmSync } from "node:fs"; import { previewXaiOauthWireModel } from "./core-normalize"; import { admissionModelDeniedResponse, @@ -14,11 +16,13 @@ import type { Server } from "bun"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { getConfigPath, + getConfigDir, multiAgentGuidanceEnabled, } from "../../config"; import { resolveProviderApiKey } from "../../providers/key-store"; import { parseRequest } from "../../responses/parser"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; +import { applyReasoningRetention, appendRetentionNotice, type ReasoningRetentionResult } from "../../responses/reasoning-retention"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair"; @@ -1535,112 +1539,137 @@ export async function handleResponsesCompact( // ROUTED model: run the v2 synthetic-compaction turn internally (appends COMPACT_PROMPT, no // tools) and decode the resulting ocx1 envelope into plain v1 replacement-history items. - const inputItems = Array.isArray(raw.input) ? (raw.input as unknown[]) : []; - const internalBody = { - ...raw, - // Canonical ChatGPT Responses rejects non-streaming turns. Daybreak cannot use the - // native compact endpoint either, so run its synthetic compaction as SSE and collapse - // the completed event back into the v1 compact JSON contract below. Combo-dispatched - // turns also go out as SSE: failover can land on a canonical child that rejects a - // non-streaming turn, and every combo-capable provider already serves streaming traffic. - stream: isCanonicalOpenAiForwardProvider(route.provider) || accountGatedCompactWireModel || route.combo ? true : false, - input: [...inputItems, { type: "compaction_trigger" }], - }; - const internalHeaders = new Headers({ "content-type": "application/json" }); - for (const name of FORWARD_HEADERS) { - const value = req.headers.get(name); - if (value) internalHeaders.set(name, value); - } - const internalReq = new Request("http://localhost/v1/responses", { - method: "POST", - headers: internalHeaders, - body: JSON.stringify(internalBody), - }); - linkRequestSessionLane(req, internalReq); - // The routed compaction turn is a handoff inside the same logical request, so it draws the - // REMAINDER. Minting here is what let a native attempt spend three sends and the routed - // fallback spend four more. - const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, ...(admission ? { admission } : {}) }); - if (!response.ok) return response; - let json: { output?: unknown[]; status?: unknown; error?: unknown }; - if (response.headers.get("content-type")?.includes("text/event-stream")) { - if (!response.body) { - return formatErrorResponse(502, "server_error", "compaction turn returned an empty event stream"); + const rawInputItems = Array.isArray(raw.input) ? (raw.input as unknown[]) : []; + // Keep readable reasoning local until success, outside the summarizer request. + // Opaque compaction responses below keep their original protocol and ciphertext. + const retention: ReasoningRetentionResult = config.reasoningRetention?.enabled === true ? applyReasoningRetention(rawInputItems, { + archiveDir: join(getConfigDir(), "reasoning-archive"), + contextWindow: route.staticPolicy.model.contextWindow ?? route.staticPolicy.model.maxInputTokens, + maxContextPercent: config.reasoningRetention?.maxContextPercent, + maxTokens: config.reasoningRetention?.maxTokens, + }) : { input: rawInputItems }; + let keepArchive = false; + try { + const inputItems = retention.input; + const internalBody = { + ...raw, + // Canonical ChatGPT Responses rejects non-streaming turns. Daybreak cannot use the + // native compact endpoint either, so run its synthetic compaction as SSE and collapse + // the completed event back into the v1 compact JSON contract below. Combo-dispatched + // turns also go out as SSE: failover can land on a canonical child that rejects a + // non-streaming turn, and every combo-capable provider already serves streaming traffic. + stream: isCanonicalOpenAiForwardProvider(route.provider) || accountGatedCompactWireModel || route.combo ? true : false, + input: [...inputItems, { type: "compaction_trigger" }], + }; + const internalHeaders = new Headers({ "content-type": "application/json" }); + for (const name of FORWARD_HEADERS) { + const value = req.headers.get(name); + if (value) internalHeaders.set(name, value); } - const terminal = { status: "incomplete" as "completed" | "failed" | "incomplete" }; - let completed: { id?: unknown; output?: unknown; status?: unknown } | undefined; - await new Promise(resolve => { - consumeForInspection( - response.body!, - status => { terminal.status = status; }, - req.signal, - resolve, - undefined, - undefined, - value => { completed = value; }, - ); + const internalReq = new Request("http://localhost/v1/responses", { + method: "POST", + headers: internalHeaders, + body: JSON.stringify(internalBody), }); + linkRequestSessionLane(req, internalReq); + // The routed compaction turn is a handoff inside the same logical request, so it draws the + // REMAINDER. Minting here is what let a native attempt spend three sends and the routed + // fallback spend four more. + const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, ...(admission ? { admission } : {}) }); if (req.signal.aborted) { + void response.body?.cancel(req.signal.reason).catch(() => undefined); return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } - if (terminal.status !== "completed" || !completed) { - return formatErrorResponse(502, "upstream_error", `compaction turn did not complete (status: ${terminal.status})`); + if (!response.ok) return response; + let json: { output?: unknown[]; status?: unknown; error?: unknown }; + if (response.headers.get("content-type")?.includes("text/event-stream")) { + if (!response.body) { + return formatErrorResponse(502, "server_error", "compaction turn returned an empty event stream"); + } + const terminal = { status: "incomplete" as "completed" | "failed" | "incomplete" }; + let completed: { id?: unknown; output?: unknown; status?: unknown } | undefined; + await new Promise(resolve => { + consumeForInspection( + response.body!, + status => { terminal.status = status; }, + req.signal, + resolve, + undefined, + undefined, + value => { completed = value; }, + ); + }); + if (req.signal.aborted) { + return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); + } + if (terminal.status !== "completed" || !completed) { + return formatErrorResponse(502, "upstream_error", `compaction turn did not complete (status: ${terminal.status})`); + } + json = completed as { output?: unknown[]; status?: unknown; error?: unknown }; + } else { + try { + json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown }; + } catch { + return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response"); + } } - json = completed as { output?: unknown[]; status?: unknown; error?: unknown }; - } else { - try { - json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown }; - } catch { - return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response"); + if (req.signal.aborted) { + return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } - } - // The internal turn answers 200 even when it failed or was truncated, so the body - // has to be inspected. Reporting a failure beats installing "(no summary - // available)" as replacement history and silently losing the conversation (#422). - if (json.error) { - const message = typeof json.error === "string" - ? json.error - : (json.error as { message?: unknown })?.message; - return formatErrorResponse(502, "upstream_error", typeof message === "string" ? message : "compaction turn failed"); - } - if (json.status !== "completed") { - return formatErrorResponse( - 502, - "upstream_error", - `compaction turn did not complete (status: ${String(json.status ?? "unknown")})`, - ); - } - const compactionItems = (json.output ?? []).filter( - (item): item is { type: string; encrypted_content?: string } => - !!item && typeof item === "object" && (item as { type?: string }).type === "compaction", - ); - if (compactionItems.length !== 1) { - return formatErrorResponse( - 502, - "invalid_response_error", - `compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`, + // The internal turn answers 200 even when it failed or was truncated, so the body + // has to be inspected. Reporting a failure beats installing "(no summary + // available)" as replacement history and silently losing the conversation (#422). + if (json.error) { + const message = typeof json.error === "string" + ? json.error + : (json.error as { message?: unknown })?.message; + return formatErrorResponse(502, "upstream_error", typeof message === "string" ? message : "compaction turn failed"); + } + if (json.status !== "completed") { + return formatErrorResponse( + 502, + "upstream_error", + `compaction turn did not complete (status: ${String(json.status ?? "unknown")})`, + ); + } + const compactionItems = (json.output ?? []).filter( + (item): item is { type: string; encrypted_content?: string } => + !!item && typeof item === "object" && (item as { type?: string }).type === "compaction", ); - } - // Native Responses backends return a real opaque OpenAI-encrypted compaction item. OCX cannot - // and should not decrypt it; preserve that item for /responses/compact callers. Synthetic - // routed summaries are our `ocx1:` envelope and must be decoded into v1 history items. - 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 }), { - headers: { "Content-Type": "application/json" }, - }); + if (compactionItems.length !== 1) { + return formatErrorResponse( + 502, + "invalid_response_error", + `compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`, + ); + } + // Native Responses backends return a real opaque OpenAI-encrypted compaction item. OCX cannot + // and should not decrypt it; preserve that item for /responses/compact callers. Synthetic + // routed summaries are our `ocx1:` envelope and must be decoded into v1 history items. + 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 }), { + headers: { "Content-Type": "application/json" }, + }); + if (!options.compactionRoutingOverride) rememberCompactHandoffRoute(req, admission, raw.model); + return result; + } + const encrypted = compactionItems[0]!.encrypted_content; + const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null; + // An empty `ocx1:` envelope decodes to "" rather than null, so length is what matters. + if (decoded === null || decoded.trim().length === 0) { + return formatErrorResponse(502, "invalid_response_error", "compaction turn produced an empty summary"); + } + const summary = appendRetentionNotice(decoded, retention.archived); + const output = [...buildCompactV1Output(extractCompactUserMessages(inputItems), summary), + ...(retention.retainedReasoning ?? [])]; if (!options.compactionRoutingOverride) rememberCompactHandoffRoute(req, admission, raw.model); + const result = new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } }); + keepArchive = true; return result; + } finally { + // Only a readable replacement summary publishes this request's archive path. + if (!keepArchive && retention.archived) rmSync(retention.archived.path, { force: true }); } - const encrypted = compactionItems[0]!.encrypted_content; - const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null; - // An empty `ocx1:` envelope decodes to "" rather than null, so length is what matters. - if (decoded === null || decoded.trim().length === 0) { - return formatErrorResponse(502, "invalid_response_error", "compaction turn produced an empty summary"); - } - const summary = decoded; - const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary); - if (!options.compactionRoutingOverride) rememberCompactHandoffRoute(req, admission, raw.model); - return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } }); } diff --git a/src/server/responses/compaction-recovery.ts b/src/server/responses/compaction-recovery.ts index 16a2b9e9182..9895140ce3c 100644 --- a/src/server/responses/compaction-recovery.ts +++ b/src/server/responses/compaction-recovery.ts @@ -10,6 +10,8 @@ import { isCyberPolicyCode, isTerminalRefusalCode } from "../../lib/errors"; import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; import { bridgeToResponsesSSE, formatErrorResponse } from "../../bridge"; import { buildCompactV1Output, decodeCompactionSummary, encodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; +import { decodeRetainedCompaction, encodeRetainedCompaction } from "../../responses/retained-compaction"; +import { collectReasoningTexts } from "../../responses/reasoning-retention"; import { finishRequestAttempt, usageFromResponsesPayload, type RequestLogContext } from "../request-log"; import { linkRequestSessionLane } from "../request-log-conversation"; import { isNativePassthroughSseResponse, markNativePassthroughSseResponse, isEagerRelaySseResponse, markEagerRelaySseResponse } from "../relay"; @@ -55,7 +57,7 @@ function portableBody(body: Record): boolean { if (!Array.isArray(body.input) || body.store === true || conversationCarriesUploadedFiles(body)) return false; // Native ciphertext cannot be summarized by another provider. Never silently replace it with a note. if (body.input.some(item => record(item) && ["compaction", "compaction_summary", "context_compaction"].includes(String(item.type)) - && typeof item.encrypted_content === "string" && !item.encrypted_content.startsWith("ocx1:"))) return false; + && typeof item.encrypted_content === "string" && !item.encrypted_content.startsWith("ocx1:") && !decodeRetainedCompaction(item.encrypted_content))) return false; const input = body.input.filter(item => !record(item) || item.type !== "compaction_trigger"); return selfContainedResponsesBody({ ...body, store: false, input }); } @@ -289,7 +291,8 @@ export async function runWithCompactionRecovery( const compactions = items.filter(value => record(value) && value.type === "compaction"); const permitted = items.every(value => record(value) && (value.type === "compaction" || value.type === "reasoning")); const item = permitted && compactions.length === 1 ? compactions[0] as Record : undefined; - const summary = item && typeof item.encrypted_content === "string" ? decodeCompactionSummary(item.encrypted_content) : null; + const structured = item && typeof item.encrypted_content === "string" ? decodeRetainedCompaction(item.encrypted_content) : null; + const summary = structured?.summary ?? (item && typeof item.encrypted_content === "string" ? decodeCompactionSummary(item.encrypted_content) : null); if (json?.status !== "completed" || !summary?.trim()) { void completed.response.body?.cancel().catch(() => undefined); restoreSourceLog?.(completed.response.status); @@ -298,11 +301,13 @@ export async function runWithCompactionRecovery( // v1 unpacks this text through buildCompactV1Output, which already re-adds retained user // messages as items; embedding them here too would duplicate the same text in the output. const preserved = options.compactionRecoveryKind === "compaction-v1" ? summary : (() => { - const retained = buildCompactV1Output(extractCompactUserMessages(snapshot.input), summary).slice(0, -1); + const usersOnly = Array.isArray(snapshot.input) ? snapshot.input.filter(item => collectReasoningTexts([item]).length === 0) : snapshot.input; + const retained = buildCompactV1Output(extractCompactUserMessages(usersOnly), summary).slice(0, -1); const userText = extractCompactUserMessages(retained).map((text, index) => `User message ${index + 1}:\n${text}`).join("\n\n"); return `${summary}\n\nRetained original user messages (verbatim; preserve their goals and constraints):\n${userText}`; })(); - item!.encrypted_content = encodeCompactionSummary(preserved); + const encryptedContent = structured ? encodeRetainedCompaction(preserved, structured.reasoning) : encodeCompactionSummary(preserved); + item!.encrypted_content = encryptedContent; json.model = originalModel; void completed.response.body?.cancel().catch(() => undefined); void response.body?.cancel().catch(() => undefined); @@ -310,7 +315,7 @@ export async function runWithCompactionRecovery( const usage = usageFromResponsesPayload(json.usage); async function* events(): AsyncGenerator { yield { type: "text_delta", text: preserved }; - yield { type: "done", ...(usage ? { usage } : {}) }; + yield { type: "done", compactionEncryptedContent: encryptedContent, ...(usage ? { usage } : {}) }; } return new Response(bridgeToResponsesSSE(events(), originalModel, undefined, undefined, undefined, undefined, 2_000, { compaction: true, translatorBudget: options.translatorBudget, onCompletedResponse: () => options.onResponseComplete?.(originalModel) }), { headers: { "content-type": "text/event-stream" } }); diff --git a/src/server/responses/compaction-retention.ts b/src/server/responses/compaction-retention.ts new file mode 100644 index 00000000000..e82289be8b6 --- /dev/null +++ b/src/server/responses/compaction-retention.ts @@ -0,0 +1,61 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../../config"; +import type { OcxConfig, OcxParsedRequest } from "../../types"; +import type { TranslatorBudget } from "../../lib/translator-budget"; +import { parseRequest } from "../../responses/parser"; +import { applyReasoningRetention, appendRetentionNotice } from "../../responses/reasoning-retention"; +import { encodeRetainedCompaction } from "../../responses/retained-compaction"; +import { finalizeAccountLease } from "./core-lifetime"; + +export class InvalidRetainedCompactionSummary extends Error {} + +/** One routed v2 attempt owns its local retention until its response ends or is cancelled. */ +export function prepareCompactionRetention( + parsed: OcxParsedRequest, + config: OcxConfig, + contextWindow: number | undefined, + budget: TranslatorBudget, + signal: AbortSignal, +) { + if (config.reasoningRetention?.enabled !== true) return undefined; + const raw = parsed._rawBody as Record; + const retention = applyReasoningRetention(raw.input, { + archiveDir: join(getConfigDir(), "reasoning-archive"), contextWindow, + ...config.reasoningRetention, + }); + if (!retention.retainedReasoning && !retention.archived) return undefined; + let committed = false; + let disposed = false; + let bytes = 0; + const dispose = () => { + if (disposed) return; + disposed = true; + signal.removeEventListener("abort", dispose); + delete parsed._compactionRetention; + budget.releaseRetained(bytes, { kind: "retained_collectors" }); + if (!committed && retention.archived) rmSync(retention.archived.path, { force: true }); + }; + try { + const reasoning = retention.retainedReasoning?.map(item => item.content[0]!.text) ?? []; + const retainedBytes = Buffer.byteLength(JSON.stringify(reasoning)); + budget.chargeRetained(retainedBytes, { kind: "retained_collectors" }); + bytes = retainedBytes; + const body = { ...raw, model: parsed.modelId, input: retention.input }; + const messages = parseRequest(body, { replayCacheScope: parsed._reasoningReplayScope }).context.messages; + parsed._rawBody = body; + parsed.context.messages = messages; + const callbacks: NonNullable = { + encode(summary) { + if (disposed || signal.aborted) throw new Error("Compaction was cancelled"); + if (!summary.trim()) throw new InvalidRetainedCompactionSummary("Compaction returned an empty summary"); + return encodeRetainedCompaction(appendRetentionNotice(summary, retention.archived), reasoning); + }, + commit() { if (!disposed && !signal.aborted) committed = true; }, + }; + Object.defineProperty(parsed, "_compactionRetention", { value: callbacks, configurable: true }); + if (signal.aborted) { dispose(); throw signal.reason; } + signal.addEventListener("abort", dispose, { once: true }); + return { dispose, deliver: (response: Response) => finalizeAccountLease(response, dispose, dispose) }; + } catch (error) { dispose(); throw error; } +} diff --git a/src/server/responses/core-lifetime.ts b/src/server/responses/core-lifetime.ts index 2ecab488cd8..2dd337ae534 100644 --- a/src/server/responses/core-lifetime.ts +++ b/src/server/responses/core-lifetime.ts @@ -48,11 +48,13 @@ export function finalizeOwnedTranslatorBudget(response: Response, budget: Transl markEagerRelaySseResponse(finalizedResponse); } if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(finalizedResponse); + if (isNonReplayableResponse(response)) markResponseNonReplayable(finalizedResponse); + if (runTurnAdapterSseResponses.has(response)) runTurnAdapterSseResponses.add(finalizedResponse); return finalizedResponse; } /** Release a serving-account slot when the client body ends, errors or is cancelled. */ -export function finalizeAccountLease(response: Response, release: () => void): Response { +export function finalizeAccountLease(response: Response, release: () => void, onCancel?: () => void): Response { if (!response.body) { release(); return response; } const reader = response.body.getReader(); let done = false; @@ -65,13 +67,14 @@ export function finalizeAccountLease(response: Response, release: () => void): R else controller.enqueue(next.value); } catch (error) { finish(); controller.error(error); } }, - async cancel(reason) { try { await reader.cancel(reason); } finally { finish(); } }, + async cancel(reason) { onCancel?.(); try { await reader.cancel(reason); } finally { finish(); } }, }); const wrapped = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers }); if (isNonReplayableResponse(response)) markResponseNonReplayable(wrapped); if (isNativePassthroughSseResponse(response)) markNativePassthroughSseResponse(wrapped); if (isEagerRelaySseResponse(response)) markEagerRelaySseResponse(wrapped); if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(wrapped); + if (runTurnAdapterSseResponses.has(response)) runTurnAdapterSseResponses.add(wrapped); return wrapped; } diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts new file mode 100644 index 00000000000..7995472f89a --- /dev/null +++ b/src/server/responses/core-pipeline.ts @@ -0,0 +1,141 @@ +import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import type { OcxConfig } from "../../types"; +import type { RequestLogContext } from "../request-log"; +import type { + HandleResponsesOptions, + ResponsesRequestContext, + ResponsesAdmissionState, + ResponsesDispatchers, +} from "./core-options"; +import type { TranslatorBudget } from "../../lib/translator-budget"; +import { prepareResponsesRequest } from "./request-prepare"; +import { prepareResponsesTransport } from "./request-transport"; +import { prepareResponsesSidecarAuth } from "./request-sidecar-auth"; +import { createResponsesEffects } from "./response-effects"; +import { createResponsesSendBudget } from "./request-send-budget"; +import { executePassthroughResponse } from "./passthrough-execution"; +import { executeResponsesSidecars } from "./sidecar-execution"; +import { createResponsesCompletionPolicy } from "./completion-policy"; +import { executeResponsesRunTurn } from "./run-turn-execution"; +import { prepareAdapterExchange } from "./adapter-dispatch"; +import { createAdapterContinuations } from "./adapter-continuation"; +import { deliverAdapterResponse } from "./adapter-delivery"; +import { releaseUpstreamHostAdmission } from "../../codex/upstream-host-health"; +import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context"; +import { formatErrorResponse } from "../../bridge"; +import { InvalidRetainedCompactionSummary, prepareCompactionRetention } from "./compaction-retention"; + +/** Compose request phases while retaining the original admission-finally ownership. */ +export async function handleResponsesInner( + req: Request, + config: OcxConfig, + logCtx: RequestLogContext, + options: HandleResponsesOptions & { translatorBudget: TranslatorBudget }, + requestDispatchers: ResponsesDispatchers, +): Promise { + const requestContext: ResponsesRequestContext = { req, config, logCtx, options }; + const admissionState: ResponsesAdmissionState = { + pendingHostAdmissionLease: null, + authCtx: { kind: "main", accountId: null }, + }; + let retention: ReturnType; + let releasePendingSend = () => {}; + try { + const requestState = await prepareResponsesRequest(requestContext, admissionState, requestDispatchers); + if (requestState instanceof Response) return requestState; + const transportState = await prepareResponsesTransport(requestContext, admissionState, requestState); + if (transportState instanceof Response) return transportState; + options.onCompactionRecoveryRoute?.(requestState.route); + if (requestState.parsed._compactionRequest && options.compactionRecoveryKind !== "compaction-v1" + && (requestState.parsed._portableCompaction || !isCanonicalOpenAiForwardProvider(requestState.route.provider))) { + retention = prepareCompactionRetention(requestState.parsed, config, + requestState.route.staticPolicy.model.contextWindow ?? requestState.route.staticPolicy.model.maxInputTokens, + options.translatorBudget, options.abortSignal ?? req.signal); + } + const execute = async (): Promise => { + const sidecarState = await prepareResponsesSidecarAuth(requestContext, requestState, transportState); + if (sidecarState instanceof Response) return sidecarState; + const responseEffects = createResponsesEffects( + requestContext, + admissionState, + requestState, + sidecarState, + ); + const sendBudgetState = createResponsesSendBudget(requestContext); + if (sendBudgetState instanceof Response) return sendBudgetState; + if ("passthrough" in transportState.adapter && transportState.adapter.passthrough && !sidecarState.routedCompaction) { + const passthroughResult = await executePassthroughResponse( + requestContext, + admissionState, + requestState, + transportState, + sidecarState, + responseEffects, + sendBudgetState, + ); + if (passthroughResult instanceof Response) return passthroughResult; + const unclaimedHop = sendBudgetState.pendingHopPermit; + releasePendingSend = () => { if (sendBudgetState.pendingHopPermit === unclaimedHop) { unclaimedHop?.release(); sendBudgetState.pendingHopPermit = undefined; } }; + } + const sidecarPlans = await executeResponsesSidecars( + requestContext, + requestState, + transportState, + sidecarState, + responseEffects, + sendBudgetState, + ); + if (sidecarPlans instanceof Response) return sidecarPlans; + const completionPolicy = createResponsesCompletionPolicy(requestContext, sidecarState); + if (transportState.adapter.runTurn) return await executeResponsesRunTurn( + requestContext, + admissionState, + requestState, + transportState, + sidecarState, + responseEffects, + sendBudgetState, + completionPolicy, + ); + const adapterExchange = await prepareAdapterExchange( + requestContext, + admissionState, + requestState, + transportState, + responseEffects, + sendBudgetState, + ); + if (adapterExchange instanceof Response) return adapterExchange; + const continuationState = createAdapterContinuations( + requestContext, + requestState, + transportState, + sidecarState, + sendBudgetState, + adapterExchange, + ); + return await deliverAdapterResponse( + requestContext, + requestState, + transportState, + sidecarState, + responseEffects, + completionPolicy, + adapterExchange, + continuationState, + ); + }; + const response = await execute(); + return retention ? retention.deliver(response) : response; + } catch (error) { + retention?.dispose(); + if (error instanceof InvalidRetainedCompactionSummary) return formatErrorResponse(502, "invalid_compaction_summary", error.message); + throw error; + } finally { + releasePendingSend(); + if (admissionState.pendingHostAdmissionLease) { + releaseUpstreamHostAdmission(admissionState.pendingHostAdmissionLease); + releaseCodexAuthContextProbeLease(admissionState.authCtx); + } + } +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fd0046d0335..efe171ec163 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1,9 +1,8 @@ +import { handleResponsesInner } from "./core-pipeline"; import type { OcxConfig } from "../../types"; import type { RequestLogContext } from "../request-log"; import type { HandleResponsesOptions, - ResponsesRequestContext, - ResponsesAdmissionState, ResponsesDispatchers, } from "./core-options"; import { createTranslatorBudget } from "../../lib/translator-budget"; @@ -13,20 +12,6 @@ import { createInferenceSendBudget } from "../inference/context"; import { finalizeOwnedTranslatorBudget, finalizeAccountLease } from "./core-lifetime"; import type { TranslatorBudget } from "../../lib/translator-budget"; import { executeComboResponses } from "./core-combo"; -import { prepareResponsesRequest } from "./request-prepare"; -import { prepareResponsesTransport } from "./request-transport"; -import { prepareResponsesSidecarAuth } from "./request-sidecar-auth"; -import { createResponsesEffects } from "./response-effects"; -import { createResponsesSendBudget } from "./request-send-budget"; -import { executePassthroughResponse } from "./passthrough-execution"; -import { executeResponsesSidecars } from "./sidecar-execution"; -import { createResponsesCompletionPolicy } from "./completion-policy"; -import { executeResponsesRunTurn } from "./run-turn-execution"; -import { prepareAdapterExchange } from "./adapter-dispatch"; -import { createAdapterContinuations } from "./adapter-continuation"; -import { deliverAdapterResponse } from "./adapter-delivery"; -import { releaseUpstreamHostAdmission } from "../../codex/upstream-host-health"; -import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context"; import { runWithCompactionRecovery } from "./compaction-recovery"; /** * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough @@ -62,7 +47,7 @@ export async function handleResponses( accountLoad, // Once at ingress, spend observer included: a combo child inherits the parent's holder. sendBudget: options.sendBudget ?? createInferenceSendBudget(req, logCtx), - }, handleResponsesInner); + }, (req, config, logCtx, options) => handleResponsesInner(req, config, logCtx, options, requestDispatchers)); const finalResponse = ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget, abortSignal) : response; if (!accountLoad.lease) { release(); return finalResponse; } return finalizeAccountLease(finalResponse, release); @@ -92,104 +77,6 @@ export async function handleComboResponses( requestDispatchers, ); } -/** Compose request phases while retaining the original admission-finally ownership. */ -async function handleResponsesInner( - req: Request, - config: OcxConfig, - logCtx: RequestLogContext, - options: HandleResponsesOptions & { translatorBudget: TranslatorBudget }, -): Promise { - const requestContext: ResponsesRequestContext = { req, config, logCtx, options }; - const admissionState: ResponsesAdmissionState = { - pendingHostAdmissionLease: null, - authCtx: { kind: "main", accountId: null }, - }; - let releasePendingSend = () => {}; - try { - const requestState = await prepareResponsesRequest(requestContext, admissionState, requestDispatchers); - if (requestState instanceof Response) return requestState; - const transportState = await prepareResponsesTransport(requestContext, admissionState, requestState); - if (transportState instanceof Response) return transportState; - options.onCompactionRecoveryRoute?.(requestState.route); - const sidecarState = await prepareResponsesSidecarAuth(requestContext, requestState, transportState); - if (sidecarState instanceof Response) return sidecarState; - const responseEffects = createResponsesEffects( - requestContext, - admissionState, - requestState, - sidecarState, - ); - const sendBudgetState = createResponsesSendBudget(requestContext); - if (sendBudgetState instanceof Response) return sendBudgetState; - if ("passthrough" in transportState.adapter && transportState.adapter.passthrough && !sidecarState.routedCompaction) { - const passthroughResult = await executePassthroughResponse( - requestContext, - admissionState, - requestState, - transportState, - sidecarState, - responseEffects, - sendBudgetState, - ); - if (passthroughResult instanceof Response) return passthroughResult; - const unclaimedHop = sendBudgetState.pendingHopPermit; - releasePendingSend = () => { if (sendBudgetState.pendingHopPermit === unclaimedHop) { unclaimedHop?.release(); sendBudgetState.pendingHopPermit = undefined; } }; - } - const sidecarPlans = await executeResponsesSidecars( - requestContext, - requestState, - transportState, - sidecarState, - responseEffects, - sendBudgetState, - ); - if (sidecarPlans instanceof Response) return sidecarPlans; - const completionPolicy = createResponsesCompletionPolicy(requestContext, sidecarState); - if (transportState.adapter.runTurn) return await executeResponsesRunTurn( - requestContext, - admissionState, - requestState, - transportState, - sidecarState, - responseEffects, - sendBudgetState, - completionPolicy, - ); - const adapterExchange = await prepareAdapterExchange( - requestContext, - admissionState, - requestState, - transportState, - responseEffects, - sendBudgetState, - ); - if (adapterExchange instanceof Response) return adapterExchange; - const continuationState = createAdapterContinuations( - requestContext, - requestState, - transportState, - sidecarState, - sendBudgetState, - adapterExchange, - ); - return await deliverAdapterResponse( - requestContext, - requestState, - transportState, - sidecarState, - responseEffects, - completionPolicy, - adapterExchange, - continuationState, - ); - } finally { - releasePendingSend(); - if (admissionState.pendingHostAdmissionLease) { - releaseUpstreamHostAdmission(admissionState.pendingHostAdmissionLease); - releaseCodexAuthContextProbeLease(admissionState.authCtx); - } - } -} const requestDispatchers: ResponsesDispatchers = { handleResponses, handleComboResponses }; export { adapterNeedsForcedContinuation } from "./core-replay"; export { diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 689730cfae0..6fb10443548 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -842,7 +842,7 @@ export async function executeResponsesRunTurn( enforceDeclaredToolNames, toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), - ...(routedCompaction ? { compaction: true } : {}), + ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), // grok-build's strict decoder dies on the typed response.heartbeat frame; its // eventsource layer tolerates comment keep-alives. Codex needs the opposite. ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}), @@ -997,7 +997,7 @@ export async function executeResponsesRunTurn( freeformToolNames, bareCustomToolNames, toolSearchToolNames, - ...(routedCompaction ? { compaction: true } : {}), + ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), onProviderState: state => { providerState = state; }, onUsage: usage => { transportState.bindKeyUsageFromBridge(usage); diff --git a/src/types/config.ts b/src/types/config.ts index 9b56063c760..61dad96a8ec 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -783,6 +783,11 @@ export interface OcxConfig { }; /** Opt-in failure-only recovery; never replaces the initial compaction model. */ compactionRecovery?: { enabled: boolean; model: string; allowDevinInvalidArgument?: boolean }; + /** Explicit opt-in (enabled === true), default off, for routed v1/v2 readable reasoning retention. + * Defaults: 20% of the compacting model's context window and 100,000 tokens (the smaller limit). + * Unknown windows use maxTokens alone; overflow is archived locally with a path notice. + * Native and opaque compaction output keep their existing protocols. */ + reasoningRetention?: { enabled?: boolean; maxContextPercent?: number; maxTokens?: number }; /** Explicit opt-in; resumes Observe only using an existing trusted certificate and endpoints. */ desktopCompatibility?: { startOnProxyStart: boolean }; /** diff --git a/src/types/request.ts b/src/types/request.ts index 804199c7919..28d8bb6fbb6 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -150,6 +150,8 @@ export interface OcxParsedRequest { * (see src/responses/compaction.ts). */ _compactionRequest?: boolean; + /** Request-owned encoding of readable routed v2 retention; never used for native ciphertext. */ + _compactionRetention?: { encode(summary: string): string; commit(): void }; /** Manual compaction moved to another provider: summarize portably even on a canonical ChatGPT target. */ _portableCompaction?: boolean; /** diff --git a/structure/config.md b/structure/config.md index f803d408c5c..f3168165ea1 100644 --- a/structure/config.md +++ b/structure/config.md @@ -30,7 +30,7 @@ the [source-owned credential contract](codex-home.md#orca-source-owned-account-i Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. -`skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). +`skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). Optional `reasoningRetention` is advanced and off by default: only explicit boolean `enabled: true` activates it, so omitted/false/malformed and legacy limit-only blocks stay off. When enabled, it accepts `maxContextPercent` (>0, <=100; default 20) and `maxTokens` (positive integer; default 100000), using the smaller limit for [routed v1/v2 compaction](transports/responses-failover.md#compaction-routing-overrides). An unknown context window uses the absolute cap only. Malformed disk values degrade only this block with a value-free warning; candidate writes reject them before the load schema's catch. Absence remains unpersisted, and native/opaque protocols are unchanged. Google providers may persist `googleToolSchemaPolicy` as `compatible` or `reject-lossy`. `ocx provider add --google-tool-schema-policy` is one authoring path and is accepted only when the effective adapter is `google`. Omission remains absent in `config.json`; the adapter resolves it to `compatible` in memory. Provider live lifecycle, pacing and public editor CAS are described in [CLI management](cli-management.md#provider-live-operations-and-bounded-editor-input); local auth/path additions retain canonical OpenAI validation before saving. diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 3cbebeb3a11..242152c9b46 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -129,7 +129,7 @@ The sidebar exposes eight rows (`gui/src/nav-groups.ts` `NAV_GROUPS`), in order: | Combos | Rail, detail panel, and an add flow (`gui/src/components/ComboWorkspace.tsx`). | | Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). The catalog browses four tabs — Accounts, Free, Local, Paid — where Local is a catalog-only bucket peeled out of `bucketPresets` after `presetTier` has classified; the workspace `providerTier` stays three-way, so the rail, the free-paid sort and the Free count still treat a local runtime as free. Search sits above the tabs and reaches every tab at once: while a query is live the list renders all four groups with headings and the strip becomes jump chips with counts rather than a tablist, because moving the selected tab would change the row kind under the user (a preset-select button becomes a login row). ArrowDown from the search input focuses the first enabled result action; if none is available, focus stays in the input. The tab strip wraps within narrow modals. Every nonempty note has a full-text button so narrow rows never hide content permanently; the native note dialog closes during teardown and restores focus to its trigger. Provider notes clamp to two lines and open in full in a stacked native `` owned by `AddProviderModal`, which also owns the search text so its `window` Escape handler can unwind popup, then query, then dialog. | | Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. The main card's native-main device reauth (#3898) is owned by `gui/src/components/use-main-device-reauth.ts`: the dedicated `/api/codex-auth/main/reauth-device` namespace only — never the pool login route — with flowId-owned polling, an allowlisted verification URL, and no token fields accepted from payloads. The main-device reauth hook retains flow ownership from the Cancel click, while DELETE is unresolved and after retryable failure; polling normally continues. A concurrent GET HTTP error cannot expose a replacement login POST before DELETE settles. If a retryable DELETE failure races with a non-2xx GET while the flow is pending or committing, either response order preserves same-flow Cancel retry, restores the last server-provided device code, verification URL, and phase when needed, and keeps the existing poll cadence so a later terminal result remains observable. Outside same-flow cancellation ownership, a GET HTTP failure still stops polling without starting a second login POST. The cancellation-failure indication survives pending status updates until a trusted terminal result releases ownership. A successful DELETE with a terminal `failed` DTO releases it and uses the same closed failure-code mapping as polling; only `succeeded` notifies login completion. Unrecognized or nonterminal DTO status values remain retryable. A DELETE response with HTTP 404 and code `unknown_flow` releases the expired flow and shows the existing generic failure state so device re-login is available again; it claims neither login success nor confirmed cancellation. Confirmed cancellation also makes device re-login available. Start, polling and cancellation completions verify their controller or flow ownership after asynchronous response reads; replaced flows and unmounted hooks cannot update a newer flow or notify completion. Effect setup restores mounted state after the StrictMode development cleanup cycle. | -| Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). | +| Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, reasoning-retention, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). The Reasoning retention controls (`gui/src/components/ReasoningRetentionPanel.tsx`) live in a collapsed Advanced settings disclosure at the end of Overview. They read and save `reasoningRetention` via GET/PUT `/api/settings` in `src/server/management/config-routes.ts`: only explicit `enabled: true` activates retention; absent, disabled, and legacy threshold-only settings remain off. The enable switch exposes numeric controls with defaults of 20% and 100000 tokens; disabling saves `enabled: false`, and null reset removes the setting and turns it off. Omission preserves it, validation precedes mutation, and save failure restores value and key presence. Copy warns that opt-in can preserve plaintext reasoning in local history/archive and that completed archives have no aggregate quota or expiry and require manual deletion. Loading disables writes, failures permit retry without losing edits, and stale responses cannot update a new API target. Its scope is the [routed v1/v2 compaction contract](transports/responses-failover.md#compaction-routing-overrides); opaque output stays unchanged. `tests/config/settings-reasoning-retention.test.ts` and `gui/tests/reasoning-retention-panel.test.tsx` cover the API and controls. Inputs expose invalid state and reference the validation alert only for the failing field. | JEV setup and its Stats tab reuse these shells; see [providers/jev-decision.md](./providers/jev-decision.md). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 961a55f07ed..4c72bb52fba 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -271,7 +271,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Endpoint area | Responsibility | | --- | --- | -| Config/settings | Read safe config/settings views; mutate supported settings only. Full `PUT /api/config` is disabled so masked secrets are not round-tripped. `PUT /api/settings` accepts `codexAutoStart`, `streamMode`, integer `appOwnedMemoryBudgetMb` (64..4096), strict boolean `codexAccountPickerEnabled`, strict boolean `fastRows`, and a validated per-account `codexQuotaAutoRefresh` toggle (each optional, at least one required). `fastRows` defaults on when absent: false is persisted, true deletes the key, and successful writes echo the effective boolean. An effective change converges the Codex catalog and refreshes enabled or already-owned client integrations after persistence. Picker enable initializes an empty UI-managed selector map, persists before one bounded catalog convergence, and reports only `catalogRefreshPending`; allocation/save failure restores every touched live field and skips convergence. Budget changes synchronously enforce the process-wide evictable retained-state cap; this is separate from RSS/native memory. `streamMode` persists the #314 stream-shape selection in config.json (Windows services need persisted input; macOS eager relay is explicit-only). | +| Config/settings | Reasoning retention follows the [panel and settings contract](dashboard-and-usage.md). Read safe config/settings views; mutate supported settings only. Full `PUT /api/config` is disabled so masked secrets are not round-tripped. `PUT /api/settings` accepts `codexAutoStart`, `streamMode`, integer `appOwnedMemoryBudgetMb` (64..4096), strict boolean `codexAccountPickerEnabled`, strict boolean `fastRows`, and a validated per-account `codexQuotaAutoRefresh` toggle (each optional, at least one required). `fastRows` defaults on when absent: false is persisted, true deletes the key, and successful writes echo the effective boolean. An effective change converges the Codex catalog and refreshes enabled or already-owned client integrations after persistence. Picker enable initializes an empty UI-managed selector map, persists before one bounded catalog convergence, and reports only `catalogRefreshPending`; allocation/save failure restores every touched live field and skips convergence. Budget changes synchronously enforce the process-wide evictable retained-state cap; this is separate from RSS/native memory. `streamMode` persists the #314 stream-shape selection in config.json (Windows services need persisted input; macOS eager relay is explicit-only). | | Startup safety | `GET /api/startup-health` reports whether injected Codex routing is restart-safe, with secret-free service/shim diagnostics. `POST /api/startup-action` provides allowlisted one-click installation for the background service or launcher shim. On Windows a healthy script shim is CLI-only; Codex Desktop requires the background service for full protection. Optional Codex compatibility uses the [confirmed local runtime](clients/codex-desktop.md#optional-compatibility-runtime), [machine-local dashboard consent](clients/codex-desktop.md#dashboard-controls), and [revision-bound next-start preference with shared awaited teardown](clients/codex-desktop.md#proxy-startup-preference); saving that preference performs no runtime action. | | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | diff --git a/structure/overview.md b/structure/overview.md index 3a5e2eabdc7..458ad7483bd 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -301,4 +301,4 @@ Dashboard Fast-row persistence and client refresh follow the [Fast selector rows Codex compaction can select a request-local model through the [existing Responses handlers](transports/responses-failover.md#compaction-routing-overrides) for the configured -manual and automatic triggers, while subsequent turns keep their conversation settings. The optional [ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) is distinct from default cache affinity and changes no credential-eligibility boundary. +manual and automatic triggers, while subsequent turns keep their conversation settings. The optional [ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) is distinct from default cache affinity and changes no credential-eligibility boundary. Only explicit `reasoningRetention.enabled: true` (advanced, default off) makes routed v1/v2 compaction use `src/responses/reasoning-retention.ts` to keep readable reasoning out of the summarizer: text within the smaller of 20% of the compacting window and 100000 estimated tokens (both configurable) returns as exact historical context after a readable summary succeeds; an unknown window uses the absolute cap. Overflow is archived under the resolved OpenCodex home with a path notice. Native compact and opaque compaction output retain their original protocols. diff --git a/structure/runtime.md b/structure/runtime.md index 2a31215cb52..809442dd0f6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -167,7 +167,7 @@ there. Feature code is grouped by responsibility: `src/server/` is split by responsibility: `index.ts` owns the listener and the startup transaction while `index/serve-options.ts` owns route ordering; `responses.ts` and `responses/core.ts` compose Responses handling from the owners inventoried in [Responses transport](transports/responses.md), -and `responses/compact.ts` owns compaction; `images.ts` owns the standalone Images relay; +and `responses/compact.ts` and `responses/compaction-retention.ts` own v1/v2 compaction under the [routed reasoning-retention contract](transports/responses-failover.md#compaction-routing-overrides) implemented by `src/responses/reasoning-retention.ts`; `images.ts` owns the standalone Images relay; `responses/codex-auth-error.ts` owns the shared Responses/compact Codex auth-context HTTP mapping. Model entitlement denial is a 400 request error and temporary exhaustion of every model-capable account is a retryable 429; neither is reported as an invalid API key. Images, Live, and Search diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 6bcf5538f8e..9018b7285d6 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -83,7 +83,7 @@ lifecycle, cancellation races, protocol envelopes, and the real HTTP admission b ## Stream-buffer accounting -HTTP owners in `src/server/responses/core.ts`, `src/server/chat-completions.ts` and `src/server/claude-messages.ts` pass the request abort signal to `src/lib/translator-budget.ts`. An aborted request releases its owned translator budget even when its response body is never consumed. Abort finalization changes accounting only; the transport keeps ownership of producer cancellation and response terminal precedence. Explicit body cancellation also releases the budget before awaiting upstream reader cancellation. EOF and read errors share the same idempotent finalizer, which removes its abort listener. Responses lifetime wrapping in `src/server/responses/core-lifetime.ts` preserves native, eager-relay and preinspected-response markers. `tests/adapters/translator-budget-lifetime.test.ts` holds cancellation pending and covers abort before wrapping, abort after wrapping, explicit cancellation and successful EOF. +HTTP owners in `src/server/responses/core.ts`, `src/server/chat-completions.ts` and `src/server/claude-messages.ts` pass the request abort signal to `src/lib/translator-budget.ts`. An aborted request releases its owned translator budget even when its response body is never consumed. Abort finalization changes accounting only; the transport keeps ownership of producer cancellation and response terminal precedence. Explicit body cancellation also releases the budget before awaiting upstream reader cancellation. EOF and read errors share the same idempotent finalizer, which removes its abort listener. Responses lifetime wrapping in `src/server/responses/core-lifetime.ts` preserves native, eager-relay, preinspected-response, run-turn and nonreplayable markers, including through an outer serving-account lease. `tests/adapters/translator-budget-lifetime.test.ts` holds cancellation pending and covers abort before wrapping, abort after wrapping, explicit cancellation, marker preservation and successful EOF. Disposed budgets ignore late call, reservation, charge and observation work; reservations created before disposal cannot resurrect per-turn or aggregate counters. The same Bun contract checks both accounting scopes before any producer cancellation and while an explicit cancellation is pending. Every producer must honor its transport abort link; disposed accounting does not provide a byte cap to work that outlives its request. @@ -275,6 +275,6 @@ Schema size still determines traversal work and the cost of copying a changed br Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). -The [compaction routing override](responses-failover.md#compaction-routing-overrides) changes model and effort scalars on the already-read request body, before parsing, within the existing body-reader budget. +The [compaction routing override](responses-failover.md#compaction-routing-overrides) changes model and effort scalars on the already-read request body, before parsing, within the existing body-reader budget. `src/server/responses/compaction-retention.ts` charges locally retained v2 reasoning as retained collectors before dispatch and releases it on response completion, error, cancellation or abort; bridge output remains subject to the same response budget. `src/lib/sse-decoder.ts` recognizes CR, LF and CRLF line endings, including CRLF split between fetch chunks. Delimiters are consumed before field retention; event/comment ordering, EOF dispatch and translator-budget release remain shared across all three forms. The delimiter search keeps native `indexOf` cursors for the next CR and LF in each decoded chunk, so scanning stays linear for every framing. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 535ffb4c842..345cd7fa450 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -336,7 +336,7 @@ build both honor for canonical ChatGPT destinations. Native ciphertext is replay 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 only the summary request changes, never stored history, ordinary turns or native compaction. 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. +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. ## Ambiguous connection-reset replay boundary Three failures look alike from the outside — the turn may have executed and we cannot diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 0a065aec8ba..cf490edc214 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -331,7 +331,7 @@ again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent base64, so they always lower to plain user messages. A +A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:`/`ocx2:` envelopes are transparent base64, so they always lower to plain user messages. A native blob is relayed only when there is no known serving-identity mismatch and the destination is known to decode native blobs — the canonical ChatGPT forward surface, the official OpenAI API, or a provider with the explicit `decodesNativeCompactionBlobs` capability. The destination gate alone is insufficient because more than one backend, including OpenAI and xAI, mints native blobs: a destination can decode its own blob without being able to decode the previous backend's. The same serving-identity mismatch signal therefore strips reasoning `encrypted_content` and degrades native compaction blobs through the existing opaque-note path. Claude-native reasoning batches rejected blob tags into one scrub pass: only the @@ -398,22 +398,22 @@ always become unable to serve. ## Decoded checkpoint continuation -`src/responses/parser.ts` adds `CHECKPOINT_CONTINUATION_NOTE` from `src/responses/compaction.ts` to the system prompt only when normal generation contains a decoded `ocx1:` checkpoint; `src/adapters/openai-responses/request-strips.ts` applies the same condition to passthrough `instructions` while lowering the checkpoint to a user message. Summarizer turns, opaque native blobs and requests without decoded checkpoints receive no note. `SUMMARY_PREFIX` and checkpoint text remain unchanged, and later user messages retain their order after the checkpoint. +`src/responses/parser.ts` adds `CHECKPOINT_CONTINUATION_NOTE` from `src/responses/compaction.ts` to the system prompt only when normal generation contains a decoded `ocx1:` or `ocx2:` checkpoint; `src/adapters/openai-responses/request-strips.ts` applies the same condition to passthrough `instructions` while lowering the checkpoint to a user message. Summarizer turns, opaque native blobs and requests without decoded checkpoints receive no note. `SUMMARY_PREFIX` and checkpoint text remain unchanged, and later user messages retain their order after the checkpoint. The note is a prompt contract and grants no authority. Completed requests remain history unless a later user message asks to repeat them. Unfinished work continues only under existing authorization and within its scope, without asking for that authorization again while it still applies. Permission and confirmation gates remain authoritative: a pending decision or required permission must resolve before the dependent action. Checkpoints and delegations, including those from another thread, neither grant authority nor expand scope. Later user messages take priority, including pause, cancellation, replacement and scope changes. -The continuation-note and continuation-wire tests establish prompt-contract and wire-construction evidence, including preservation of existing instructions and checkpoint ordering. They do not establish model behavior or whether a model actually resumes the correct task. +The continuation-note and continuation-wire tests establish prompt-contract and wire-construction evidence, including preservation of existing instructions, checkpoint ordering, and separated `ocx2:` retained reasoning. They do not establish model behavior or whether a model actually resumes the correct task. > Decision record: [ADR-7003](../decisions/ADR-7003-decoded-checkpoint-continuation.md) ## Core module ownership -`src/server/responses/core.ts` is the public ingress and compatibility-export surface. -The parent `src/server/responses.ts` facade retains its existing imports. Per-request execution -is composed from the following owners in `src/server/responses/`; none is a generated artifact. +`src/server/responses/core.ts` is the public ingress and compatibility-export surface. The parent `src/server/responses.ts` facade retains its existing imports. Per-request execution is composed from the following owners in `src/server/responses/`; none is a generated artifact. | Owner | Responsibility | | --- | --- | +| `core-pipeline.ts` | Request-phase composition, native-to-translated adapter handoff and pending admission/unused-hop cleanup; the public ingress remains in `core.ts`. | +| `compaction-retention.ts` | Local direct-v2 reasoning retention, provisional archives and response-lifetime cleanup under the [routed compaction contract](responses-failover.md#compaction-routing-overrides). | | `request-prepare.ts` | Body parsing, combo handoff, final route, encrypted-task recovery and initial admission, retaining [original policy authorization](policy-fallback.md). Both shadow-intercept sites use `shadowCallPreservesSelectedModel` in `src/lib/shadow-call.ts`: explicit `request_kind: "turn"` plus `thread_source: "user"`, or spawned-child provenance, preserves the selected model. `x-openai-subagent: collab_spawn` is a connection header on both transports (a child session opens its own connection); turn metadata comes from the HTTP `x-codex-turn-metadata` header or, on WebSocket, only from each frame's `client_metadata["x-codex-turn-metadata"]` (including `subagent_kind: "thread_spawn"`), never from stale upgrade metadata. Kind-only `turn` helpers and missing/malformed metadata retain interception; concrete self-targets remain no-ops and helper combos retain failover. `tests/responses/responses-shadow-intercept.test.ts` covers both sites and frame precedence. xAI OAuth model-scope admission previews the billed Fast lane using the serialization decision shared with Chat, Messages and routed compact admission; final normalization rechecks the actual wire destination. | | `shadow-target-availability.ts` | Shadow-call target resolution for `request-prepare.ts`: an unavailable target fails once with `409 intercept_target_unavailable` instead of reaching the native source model or the default provider. | | `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement and same-target request identity. Copilot Auto renewal onto Chat (including negotiation-401 refresh) signals the native owner before inference; `passthrough-dispatch.ts` reuses its inference-401/429 adapter handoff so JSON and SSE retain the Responses envelope. | @@ -448,9 +448,9 @@ lease while a block-local `admission` holds only the acquisition result. reads that holder rather than minting a per-phase allowance. Combo recursion is injected through `ResponsesDispatchers`: a child re-enters the public handler without a reverse runtime import from the combo implementation into `core.ts`. `core-lifetime.ts` owns the shared run-turn response -marker and translator-budget finalization, so the combo and delivery paths observe one identity. Owned-budget cleanup on request abort, including unread response bodies, follows the [byte-accounting lifetime contract](byte-accounting.md#stream-buffer-accounting). +marker and translator-budget finalization; cleanup wrappers preserve its run-turn classification and nonreplayable failures, so retention cannot reopen combo retries after a tool effect. Owned-budget cleanup on request abort, including unread response bodies, follows the [byte-accounting lifetime contract](byte-accounting.md#stream-buffer-accounting). -The outer admission `finally` remains in `core.ts`. Native execution explicitly transfers its +The pending admission `finally` lives in `core-pipeline.ts`. Native execution explicitly transfers its pending lease to `passthrough-execution.ts`; both owners await response construction before cleanup. Stream body ownership, cancellation and post-commit behavior stay in the delivery owners. This decomposition changes ownership boundaries, not credential-selection or retry policy. diff --git a/tests/adapters/translator-budget-lifetime.test.ts b/tests/adapters/translator-budget-lifetime.test.ts index e2a85cd8c9c..dc6de8e7c53 100644 --- a/tests/adapters/translator-budget-lifetime.test.ts +++ b/tests/adapters/translator-budget-lifetime.test.ts @@ -3,7 +3,10 @@ import { createTranslatorBudget, finalizeTranslatorBudgetResponse, translatorLiveBudgetCountForTests, translatorObservedBufferSnapshot, } from "../../src/lib/translator-budget"; -import { finalizeOwnedTranslatorBudget } from "../../src/server/responses/core-lifetime"; +import { + finalizeAccountLease, finalizeOwnedTranslatorBudget, runTurnAdapterSseResponses, +} from "../../src/server/responses/core-lifetime"; +import { isNonReplayableResponse, markResponseNonReplayable } from "../../src/lib/upstream-retry"; import { markNativePassthroughSseResponse, isNativePassthroughSseResponse, markEagerRelaySseResponse, isEagerRelaySseResponse, @@ -117,6 +120,7 @@ for (const [mark, inspect] of [ [markNativePassthroughSseResponse, isNativePassthroughSseResponse], [markEagerRelaySseResponse, isEagerRelaySseResponse], [markPreinspectedJsonResponse, isPreinspectedJsonResponse], + [(response: Response) => { markResponseNonReplayable(response); return response; }, isNonReplayableResponse], ] as const) { test(`Responses retains ${inspect.name} through budget wrapping`, async () => { const source = mark(new Response("unchanged")); @@ -127,6 +131,51 @@ for (const [mark, inspect] of [ }); } +for (const outerLease of [false, true]) { + for (const mode of ["before", "after", "cancel"] as const) { + test(`Responses runTurn/nonreplayable classification survives ${mode} cleanup (lease=${outerLease})`, async () => { + const baseline = translatorLiveBudgetCountForTests(); + const budget = createTranslatorBudget(); + budget.chargeRetained(512, { kind: "reasoning" }); + const controller = new AbortController(); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let cancellations = 0; + let leaseReleases = 0; + const source = new Response(new ReadableStream({ + cancel() { cancellations++; return held; }, + }), { status: 503, headers: { "x-should-retry": "false" } }); + markResponseNonReplayable(source); + runTurnAdapterSseResponses.add(source); + if (mode === "before") controller.abort(); + const finalized = finalizeOwnedTranslatorBudget(source, budget, controller.signal); + const response = outerLease + ? finalizeAccountLease(finalized, () => { leaseReleases++; }) : finalized; + let cancelled: Promise | undefined; + try { + if (mode === "after") controller.abort(); + if (mode === "cancel") cancelled = response.body!.cancel("client gone"); + await Promise.resolve(); + expect(translatorLiveBudgetCountForTests()).toBe(baseline); + expect(budget.snapshot().currentBytes).toBe(0); + expect(isNonReplayableResponse(response)).toBe(true); + expect(runTurnAdapterSseResponses.has(response)).toBe(true); + expect(response.status).toBe(503); + expect(response.headers.get("x-should-retry")).toBe("false"); + expect(cancellations).toBe(mode === "cancel" ? 1 : 0); + expect(leaseReleases).toBe(0); + } finally { + cancelled ??= response.body!.cancel("cleanup"); + release(); + await cancelled; + budget.dispose(); + } + expect(cancellations).toBe(1); + expect(leaseReleases).toBe(outerLease ? 1 : 0); + }); + } +} + for (const surface of ["responses", "chat-completions", "claude-messages"] as const) { test(`${surface}: HTTP owner links request abort to its unread response budget`, async () => { const handle = surface === "responses" diff --git a/tests/codex-integration/history-ocx-compaction-recovery.test.ts b/tests/codex-integration/history-ocx-compaction-recovery.test.ts index 325a83c93df..eac2cdc9f54 100644 --- a/tests/codex-integration/history-ocx-compaction-recovery.test.ts +++ b/tests/codex-integration/history-ocx-compaction-recovery.test.ts @@ -9,6 +9,8 @@ import { rewriteOcxCompactionsForNativeReplay, } from "../../src/codex/ocx-compaction-history"; import { encodeCompactionSummary, SUMMARY_PREFIX } from "../../src/responses/compaction"; +import { encodeRetainedCompaction } from "../../src/responses/retained-compaction"; +import { applyReasoningRetention } from "../../src/responses/reasoning-retention"; import { removeTreeWithRetry } from "../helpers/remove-tree"; describe("OpenCodeX compaction history recovery", () => { @@ -53,6 +55,21 @@ describe("OpenCodeX compaction history recovery", () => { expect(result.content.endsWith("\n")).toBe(true); }); + test("structured v2 recovery keeps reasoning separate for another compaction", () => { + const retained = applyReasoningRetention([{ type: "reasoning", summary: [{ type: "summary_text", text: "Exact original reasoning." }] }], { archiveDir: "/unused" }); + const reasoning = retained.retainedReasoning!.map(item => item.content[0]!.text); + const source = JSON.stringify({ type: "compacted", payload: { replacement_history: [ + { type: "compaction", encrypted_content: encodeRetainedCompaction("summary", reasoning) }, + ] } }); + const result = rewriteOcxCompactionsForNativeReplay(source); + const history = JSON.parse(result.content).payload.replacement_history; + expect(result.replaced).toBe(1); + expect(history).toHaveLength(2); + expect(history[0].content[0].text).not.toContain("Exact original reasoning."); + expect(history[1].content[0].text).toBe(reasoning[0]); + expect(applyReasoningRetention(history, { archiveDir: "/unused" }).retainedReasoning).toEqual(retained.retainedReasoning); + }); + test("is byte-stable when no repairable compaction exists", () => { const source = `${JSON.stringify({ type: "compacted", diff --git a/tests/config/settings-reasoning-retention.test.ts b/tests/config/settings-reasoning-retention.test.ts new file mode 100644 index 00000000000..ca75a1d0f3a --- /dev/null +++ b/tests/config/settings-reasoning-retention.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { getDefaultConfig, loadConfig, readConfigDiagnostics, validateConfigCandidate } from "../../src/config"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleConfigRoutes } from "../../src/server/management/config-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; +import { warnDegradedTopLevelOptIns } from "../../src/config/load-degrade"; +import { codexWsReuseAcrossTurnsEnabled, setCodexWsReuseAcrossTurns } from "../../src/config/codex-ws-reuse-setting"; +function harness(failSave = false) { + const config = getDefaultConfig(); + let saves = 0; + const call = async (body?: unknown) => { + const url = new URL("http://localhost/api/settings"); + return handleConfigRoutes({ + url, req: new Request(url, { method: body === undefined ? "GET" : "PUT", headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), + config, version: "test", deps: { saveConfigPreservingClaudeCode: (_c: OcxConfig) => { + saves++; if (failSave) throw new Error("fixture-save-failed"); + } }, + } as unknown as ManagementContext); + }; + return { config, call, saves: () => saves }; +} + +test("reasoning retention settings round-trip, preserve omission, and reset", async () => { + const h = harness(); + expect(await (await h.call())!.json()).toMatchObject({ reasoningRetention: null }); + const value = { enabled: true, maxContextPercent: 12.5, maxTokens: 50000 }; + expect(await (await h.call({ reasoningRetention: value }))!.json()).toMatchObject({ reasoningRetention: value }); + expect(h.config.reasoningRetention).toEqual(value); + expect(await (await h.call())!.json()).toMatchObject({ reasoningRetention: value }); + await h.call({ streamMode: "auto" }); + expect(h.config.reasoningRetention).toEqual(value); + expect(await (await h.call({ reasoningRetention: null }))!.json()).toMatchObject({ reasoningRetention: null }); + expect(h.config.reasoningRetention).toBeUndefined(); +}); +test("reasoning retention rejects invalid values before persistence", async () => { + const h = harness(); + for (const value of [{ enabled: "true" }, { enabled: 1 }, { maxContextPercent: 0 }, { maxContextPercent: 101 }, { maxTokens: 0 }, { maxTokens: 1.5 }, { maxTokens: "100" }, { extra: true }]) { + expect((await h.call({ reasoningRetention: value }))!.status).toBe(400); + } + expect(h.saves()).toBe(0); +}); +test("reasoning retention save failure rolls back update and reset", async () => { + const h = harness(true); + const value = { maxTokens: 30000 }; + h.config.reasoningRetention = value; + for (const next of [null, { maxTokens: 1000 }]) { + await expect(h.call({ reasoningRetention: next })).rejects.toThrow("fixture-save-failed"); + expect(h.config.reasoningRetention).toEqual(value); + } +}); + +test("reasoning retention candidate writes strictly validate optional fields", () => { + const defaults = getDefaultConfig(); + expect(defaults.reasoningRetention).toBeUndefined(); + for (const value of [{ enabled: true }, { enabled: false }, { maxContextPercent: 12.5 }, { maxTokens: 50000 }, { maxContextPercent: 20, maxTokens: 100000 }]) { + const validated = validateConfigCandidate({ ...defaults, reasoningRetention: value }); + expect(validated.ok).toBe(true); + if (validated.ok) expect(validated.config.reasoningRetention).toEqual(value); + } + for (const value of [null, [], "20", { enabled: "false" }, { maxContextPercent: 0 }, { maxContextPercent: 101 }, + { maxContextPercent: Infinity }, { maxContextPercent: NaN }, { maxTokens: -1 }, + { maxTokens: 1.5 }, { maxTokens: "100" }, { maxTokens: 1, extra: true }]) { + expect(validateConfigCandidate({ ...defaults, reasoningRetention: value }).ok).toBe(false); + } +}); + +test("reasoning retention malformed disk settings degrade locally and warn without exposing values", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-retention-config-")); + const previousHome = process.env.OPENCODEX_HOME; + const previousWarn = console.warn; + const warnings: string[] = []; + process.env.OPENCODEX_HOME = home; + console.warn = (value: unknown) => { warnings.push(String(value)); }; + try { + const config = getDefaultConfig(); + writeFileSync(join(home, "config.json"), JSON.stringify({ ...config, + reasoningRetention: { maxTokens: "private-fixture-value" } })); + const loaded = loadConfig(); + expect(loaded.reasoningRetention).toBeUndefined(); + expect(loaded.providers).toEqual(config.providers); + expect(warnings.some(value => value.includes("reasoningRetention"))).toBe(true); + expect(warnings.join("\n")).not.toContain("private-fixture-value"); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.warnings?.some(value => value.includes("reasoningRetention"))).toBe(true); + expect(diagnostics.config.reasoningRetention).toBeUndefined(); + } finally { + console.warn = previousWarn; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + } +}); + +test("reasoning retention failed initial save restores key omission", async () => { + const h = harness(true); + await expect(h.call({ reasoningRetention: { maxTokens: 1000 } })).rejects.toThrow("fixture-save-failed"); + expect(Object.hasOwn(h.config, "reasoningRetention")).toBe(false); +}); + +for (const enabled of [false, true]) { + test(`malformed reasoning retention warns while WebSocket reuse stays ${enabled}`, () => { + const priorReuse = codexWsReuseAcrossTurnsEnabled(); + const priorWarn = console.warn; + const warnings: string[] = []; + console.warn = (value: unknown) => { warnings.push(String(value)); }; + setCodexWsReuseAcrossTurns(!enabled); + try { + const validated = { ...getDefaultConfig(), codexWsReuseAcrossTurns: enabled }; + const raw = { ...validated, reasoningRetention: { maxTokens: "private-fixture-value" } }; + warnDegradedTopLevelOptIns(raw, validated); + expect(codexWsReuseAcrossTurnsEnabled()).toBe(enabled); + expect(warnings.filter(value => value.includes("reasoningRetention"))).toHaveLength(1); + expect(warnings.join("\n")).not.toContain("private-fixture-value"); + expect(validated.reasoningRetention).toBeUndefined(); + } finally { + console.warn = priorWarn; + setCodexWsReuseAcrossTurns(priorReuse); + } + }); +} + +test("reasoning retention enable and disable preserve explicit booleans", async () => { + const h = harness(); + for (const enabled of [true, false]) { + const value = { enabled, maxTokens: 1000 }; + expect(await (await h.call({ reasoningRetention: value }))!.json()).toMatchObject({ reasoningRetention: value }); + expect(h.config.reasoningRetention?.enabled).toBe(enabled); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..2f10258103e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -931,6 +931,7 @@ "rate-limit-retry.test.ts": "providers", "raycast-client.test.ts": "clients", "raycast-detect.test.ts": "clients", "reasoning-effort-summary-default.test.ts": "responses", "reasoning-effort.test.ts": "codex-integration", "reasoning-envelope.test.ts": "responses", + "reasoning-retention.test.ts": "responses", "reasoning-retention-routing.test.ts": "responses", "reasoning-retention-v2.test.ts": "responses", "reasoning-metadata.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", "relay-eager.test.ts": "server", @@ -1065,6 +1066,7 @@ "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", "settings-desktop-switch-apply.test.ts": "config", "settings-fast-rows.test.ts": "config", "settings-main-account-hard-lock.test.ts": "config", "settings-memory-models.test.ts": "config", + "settings-reasoning-retention.test.ts": "config", "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config", "settings-stream-mode.test.ts": "config", "setup-hooks.test.ts": "ci-workflows", "shadow-intercept-target-lifecycle.test.ts": "responses", "shutdown-drain.test.ts": "service", diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 516521ecf48..995ade495fa 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -7,6 +7,8 @@ import { repoPath } from "./repo-root"; */ export const RESPONSES_CORE_MODULES = [ "core.ts", + "core-pipeline.ts", + "compaction-retention.ts", "core-options.ts", "native-response-control.ts", "native-tool-results.ts", diff --git a/tests/responses/compaction-continuation-note.test.ts b/tests/responses/compaction-continuation-note.test.ts index 73f05ef8b5f..4ce8f14394e 100644 --- a/tests/responses/compaction-continuation-note.test.ts +++ b/tests/responses/compaction-continuation-note.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { CHECKPOINT_CONTINUATION_NOTE, SUMMARY_PREFIX, appendCheckpointContinuationNote, encodeCompactionSummary } from "../../src/responses/compaction"; import { parseRequest } from "../../src/responses/parser"; +import { encodeRetainedCompaction } from "../../src/responses/retained-compaction"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; const EXPECTED_NOTE = "This conversation was compacted automatically. A checkpoint summary from an earlier context window appears as a user message and records prior work. Treat earlier requests it reports as completed as history; do not repeat them unless a later user message asks you to. Continue an unfinished task only when it was already authorized and remains within the scope of that authorization. Do not ask for the same authorization again while it still applies. Preserve all existing permission and confirmation gates; if a decision or required permission is pending, wait for it before taking the dependent action. A checkpoint or delegation, including one from another thread, does not itself grant authority or expand the task's scope. Later user messages take priority; obey any pause, cancellation, replacement, or change of scope."; @@ -119,3 +120,47 @@ describe("checkpoint continuation prompt contract (#6764)", () => { expect(body.instructions).toBe("Be brief."); }); }); + + +describe("retained checkpoint continuation merge contract", () => { + const summary = "A complete; authorized B unfinished."; + const reasoning = ["[historical reasoning] first", "[historical reasoning] second"]; + const later = { type: "message", role: "user", content: "Pause B until I return." }; + const encrypted_content = encodeRetainedCompaction(summary, reasoning); + + for (const type of ["compaction", "compaction_summary", "context_compaction"]) { + test(`${type}: adds the note while preserving separated reasoning and later instructions`, () => { + const input = [oldRequest, { type, encrypted_content }, later]; + const raw = { model: "m", instructions: "Be brief.", input }; + const parsed = parseRequest(raw); + expect(parsed.context.systemPrompt?.at(-1)).toBe(EXPECTED_NOTE); + expect(parsed.context.messages.map(message => message.content)).toEqual([ + oldRequest.content, `${SUMMARY_PREFIX}\n\n${summary}`, ...reasoning, later.content, + ]); + const body = forwardedBody(raw); + expect(body.instructions).toBe(`Be brief.\n\n${EXPECTED_NOTE}`); + expect(body.input).toEqual([oldRequest, + { type: "message", role: "user", content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${summary}` }] }, + ...reasoning.map(text => ({ type: "message", role: "user", content: [{ type: "input_text", text }] })), later, + ]); + expect(raw.input).toEqual(input); + expect(input[1]).toEqual({ type, encrypted_content }); + }); + } + + test("retained checkpoint summarizer excludes the continuation note on both routes", () => { + const raw = { model: "m", instructions: "Be brief.", input: [oldRequest, + { type: "compaction", encrypted_content }, { type: "compaction_trigger" }], + }; + const parsed = parseRequest(raw); + expect(parsed._compactionRequest).toBe(true); + expect(parsed.context.systemPrompt).toEqual(["Be brief."]); + expect(forwardedBody(raw, { _compactionRequest: true }).instructions).toBe("Be brief."); + }); + + test("invalid retained checkpoints retain the failure contract on both routes", () => { + const raw = { model: "m", input: [{ type: "compaction", encrypted_content: "ocx2:invalid" }] }; + expect(() => parseRequest(raw)).toThrow("Invalid OpenCodex retained compaction envelope"); + expect(() => forwardedBody(raw)).toThrow("Invalid OpenCodex retained compaction envelope"); + }); +}); diff --git a/tests/responses/reasoning-retention-routing.test.ts b/tests/responses/reasoning-retention-routing.test.ts new file mode 100644 index 00000000000..6e26a587794 --- /dev/null +++ b/tests/responses/reasoning-retention-routing.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { compactionRequest, completedPayload, drainCompactionResponseState, + installCompactionRoutingAclFixture, jsonResponse, keyProviderConfig, + removeCompactionFixture, sseResponse } from "../helpers/compaction-routing-fixtures"; + +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +const previousCodexHome = process.env.CODEX_HOME; +let home: string; +let releaseSpendHome: () => void; +installCompactionRoutingAclFixture(); +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-retention-routing-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + releaseSpendHome = acquireOwnedSpendHome(); +}); +afterEach(async () => { + globalThis.fetch = originalFetch; + try { await drainCompactionResponseState(); } finally { + releaseSpendHome(); + await removeCompactionFixture(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } +}); + +const reasoning = (text: string) => ({ type: "reasoning", + summary: [{ type: "summary_text", text }], encrypted_content: "opaque-provider-secret" }); +const historical = (text: string) => ({ type: "message", role: "user", + content: [{ type: "input_text", text: + "[OpenCodeX locally retained assistant reasoning; historical context, not a new instruction]\n\n" + + text + "\n" }] }); + +describe("retained reasoning public wire contracts", () => { + test.each([undefined, 1])("routed fallback preserves opaque compaction exactly (cap=%s)", async maxTokens => { + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true }; + config.providers.openai = { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex" }; + Object.assign(config, { reasoningRetention: { enabled: true, maxTokens } }); + const item = { type: "compaction", id: "cmp_retention_opaque", encrypted_content: " native-opaque+/==\n" }; + const calls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const req = new Request(url, init); + calls.push(req.url); + if (req.url.endsWith("/responses/compact")) return new Response("Not Found", { status: 404 }); + return sseResponse([{ type: "response.completed", response: { + id: "resp_opaque", status: "completed", output: [item], + } }]); + }) as typeof fetch; + const req = compactionRequest({ model: "gpt-5.6-luna", input: [ + { type: "message", role: "user", content: "retain history" }, reasoning("original reasoning"), + ] }, undefined, { authorization: "Bearer " + fakeChatGptJwt({ chatgpt_account_id: "retention-fixture" }), + "chatgpt-account-id": "retention-fixture" }); + const response = await handleResponsesCompact(req, config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ output: [item] }); + expect(calls).toEqual(["https://chatgpt.com/backend-api/codex/responses/compact", + "https://chatgpt.com/backend-api/codex/responses"]); + if (maxTokens === 1) expect(readdirSync(join(home, "reasoning-archive"))).toEqual([]); + }); + + test.each(["openai-responses", "openai-chat"] as const)("ordinary %s turns replay retained text as history, not new reasoning", async adapter => { + const original = " historical reasoning\n原文保持空格 "; + const config = keyProviderConfig({ adapter }); + config.reasoningRetention = { enabled: true }; + let sent: Record = {}; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + sent = await new Request(url, init).json() as Record; + return adapter === "openai-responses" ? jsonResponse(completedPayload("fresh answer")) : jsonResponse({ + id: "chat_next", object: "chat.completion", choices: [{ index: 0, finish_reason: "stop", + message: { role: "assistant", content: "fresh answer", reasoning_content: "new reasoning only" } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + }) as typeof fetch; + const response = await handleResponses(compactionRequest({ model: "gw/some-model", stream: false, + input: [historical(original), { type: "message", role: "user", content: "continue" }] }), + config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const history = adapter === "openai-responses" ? sent.input : sent.messages; + const message = history.find((item: Record) => JSON.stringify(item).includes("historical reasoning")); + expect(message).toBeDefined(); + expect(message.role).toBe("user"); + const text = typeof message.content === "string" ? message.content + : message.content.map((part: { text?: string }) => part.text ?? "").join(""); + expect(text).toContain(historical(original).content[0]!.text); + expect(JSON.stringify(history)).not.toContain('"type":"reasoning"'); + expect(JSON.stringify(history)).not.toContain("reasoning_content"); + expect(JSON.stringify(sent)).not.toContain("compaction_trigger"); + const output = await response.text(); + expect(output).toContain("fresh answer"); + expect(output).not.toContain("historical reasoning"); + }); + + test("routed v1 compact withholds readable reasoning and restores it after a successful summary", async () => { + const original = " original reasoning\n保留原文 "; + const input = [{ type: "message", role: "user", content: "summarize this conversation" }, reasoning(original), + { type: "function_call", call_id: "call_1", name: "shell", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "tool result" }]; + const snapshot = structuredClone(input); + let sent = ""; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + sent = await new Request(url, init).text(); + return jsonResponse(completedPayload("retained conversation summary")); + }) as typeof fetch; + const response = await handleResponsesCompact(compactionRequest({ model: "gw/some-model", input }), + keyProviderConfig(), { model: "", provider: "" }); + expect(response.status).toBe(200); + const body = await response.json() as { output: unknown[] }; + expect(sent).not.toContain("original reasoning"); + expect(sent).not.toContain("opaque-provider-secret"); + expect(sent).toContain("tool result"); + expect(body.output).toContainEqual(historical(original)); + expect(JSON.stringify(body.output)).toContain("retained conversation summary"); + expect(input).toEqual(snapshot); + const repeated = await handleResponsesCompact(compactionRequest({ model: "gw/some-model", input: body.output }), + keyProviderConfig(), { model: "", provider: "" }); + expect(repeated.status).toBe(200); + const repeatedBody = await repeated.json() as { output: unknown[] }; + expect(repeatedBody.output.filter(item => JSON.stringify(item).includes("original reasoning"))).toEqual([historical(original)]); + expect(sent).not.toContain("original reasoning"); + const next = await handleResponses(compactionRequest({ model: "gw/some-model", stream: false, + input: [...repeatedBody.output, { type: "message", role: "user", content: "continue" }] }), + keyProviderConfig(), { model: "", provider: "" }); + expect(next.status).toBe(200); + await next.text(); + expect(sent).toContain("original reasoning"); + expect(sent).not.toContain("opaque-provider-secret"); + }); + + test.each(["failed", "incomplete"])("a %s compaction returns no replacement history and leaves retry input intact", async status => { + const input = [historical("original reasoning"), { type: "message", role: "user", content: "continue" }]; + const before = structuredClone(input); + globalThis.fetch = (async () => jsonResponse({ ...completedPayload("partial"), status })) as typeof fetch; + const response = await handleResponsesCompact(compactionRequest({ model: "gw/some-model", input }), + keyProviderConfig(), { model: "", provider: "" }); + expect(response.status).toBe(502); + expect((await response.json() as { output?: unknown }).output).toBeUndefined(); + expect(input).toEqual(before); + }); + + test("oversized reasoning is archived verbatim and the replacement summary names the local file", async () => { + const original = "large original reasoning\n准确原文"; + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + let sent = ""; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + sent = await new Request(url, init).text(); + return jsonResponse(completedPayload("conclusions only")); + }) as typeof fetch; + const response = await handleResponsesCompact(compactionRequest({ model: "gw/some-model", + input: [reasoning(original), { type: "message", role: "user", content: "question" }] }), + config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const body = await response.json() as { output: unknown[] }; + const archiveDir = join(home, "reasoning-archive"); + const files = readdirSync(archiveDir); + expect(files).toHaveLength(1); + const path = join(archiveDir, files[0]!); + expect(readFileSync(path, "utf-8")).toBe(original); + expect(sent).not.toContain("large original reasoning"); + expect(sent).not.toContain("opaque-provider-secret"); + expect(sent).toContain(path); + expect(JSON.stringify(body.output)).toContain(path); + expect(JSON.stringify(body.output)).toContain("conclusions only"); + expect(JSON.stringify(body.output)).toContain("[reasoning retention notice]"); + expect(JSON.stringify(body.output)).not.toContain("large original reasoning"); + }); + + test.each(["http-error", "failed", "incomplete", "empty-summary", "non-json", "transport-error", "cancelled", "cancelled-body"])( + "%s removes only the new archive and preserves a previous successful archive", async outcome => { + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + const body = { model: "gw/some-model", input: [reasoning("original reasoning"), + { type: "message", role: "user", content: "question" }] }; + globalThis.fetch = (async () => jsonResponse(completedPayload("successful summary"))) as typeof fetch; + const successful = await handleResponsesCompact(compactionRequest(body), config, { model: "", provider: "" }); + expect(successful.status).toBe(200); + const summary = await successful.text(); + const archiveDir = join(home, "reasoning-archive"); + const previousFiles = readdirSync(archiveDir); + expect(previousFiles).toHaveLength(1); + expect(summary).toContain(join(archiveDir, previousFiles[0]!)); + // Repeated client attempts must not accumulate unreferenced files either. + for (let attempt = 0; attempt < 2; attempt++) { + const abort = new AbortController(); + globalThis.fetch = (async () => { + expect(readdirSync(archiveDir)).toHaveLength(2); + if (outcome === "http-error") return new Response("upstream rejected", { status: 400 }); + if (outcome === "non-json") return new Response("not JSON", { headers: { "content-type": "application/json" } }); + if (outcome === "transport-error") throw new Error("upstream transport failed"); + if (outcome === "cancelled") { + abort.abort(); + throw abort.signal.reason; + } + if (outcome === "cancelled-body") return new Response(new ReadableStream({ + pull(controller) { + abort.abort(); + controller.enqueue(new TextEncoder().encode(JSON.stringify(completedPayload("late summary")))); + controller.close(); + }, + }), { headers: { "content-type": "application/json" } }); + if (outcome === "empty-summary") return jsonResponse(completedPayload("")); + return jsonResponse({ ...completedPayload("partial summary"), status: outcome }); + }) as typeof fetch; + const response = await handleResponsesCompact(compactionRequest(body, abort.signal), config, { model: "", provider: "" }); + expect(response.status).toBe(outcome === "http-error" ? 400 : outcome.startsWith("cancelled") ? 499 : 502); + expect((await response.json() as { output?: unknown }).output).toBeUndefined(); + expect(readdirSync(archiveDir)).toEqual(previousFiles); + expect(readFileSync(join(archiveDir, previousFiles[0]!), "utf-8")).toBe("original reasoning"); + } + }, + ); + + test.each([true, false])("same-request retries reuse one archive (successful=%s)", async successful => { + const config = keyProviderConfig({ retryOn429: { attempts: 1, intervalMs: 1, maxIntervalMs: 1 } }); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + const archiveDir = join(home, "reasoning-archive"); + const sentArchives: string[][] = []; + globalThis.fetch = (async () => { + sentArchives.push(readdirSync(archiveDir)); + return sentArchives.length === 2 && successful ? jsonResponse(completedPayload("retry summary")) + : Response.json({ error: { message: "rate limit" } }, { status: 429 }); + }) as typeof fetch; + const response = await handleResponsesCompact(compactionRequest({ model: "gw/some-model", + input: [reasoning("original reasoning"), { type: "message", role: "user", content: "question" }] }), + config, { model: "", provider: "" }); + expect(response.status).toBe(successful ? 200 : 429); + const summary = await response.text(); + expect(sentArchives).toHaveLength(2); + expect(sentArchives[0]).toHaveLength(1); + expect(sentArchives[1]).toEqual(sentArchives[0]); + expect(readdirSync(archiveDir)).toEqual(successful ? sentArchives[0]! : []); + if (successful) expect(summary).toContain(join(archiveDir, sentArchives[0]![0]!)); + }); +}); diff --git a/tests/responses/reasoning-retention-v2.test.ts b/tests/responses/reasoning-retention-v2.test.ts new file mode 100644 index 00000000000..d562be4f1b9 --- /dev/null +++ b/tests/responses/reasoning-retention-v2.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { decodeCompactionSummary, encodeCompactionSummary } from "../../src/responses/compaction"; +import { decodeRetainedCompaction, encodeRetainedCompaction } from "../../src/responses/retained-compaction"; +import { prepareCompactionRetention } from "../../src/server/responses/compaction-retention"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { parseRequest } from "../../src/responses/parser"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { compactionRequest, completedPayload, drainCompactionResponseState, + installCompactionRoutingAclFixture, jsonResponse, keyProviderConfig, + removeCompactionFixture, sseResponse } from "../helpers/compaction-routing-fixtures"; + +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +const previousCodexHome = process.env.CODEX_HOME; +let home: string; +let releaseSpendHome: () => void; +installCompactionRoutingAclFixture(); +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-retention-v2-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + releaseSpendHome = acquireOwnedSpendHome(); +}); +afterEach(async () => { + globalThis.fetch = originalFetch; + try { await drainCompactionResponseState(); } finally { + releaseSpendHome(); + await removeCompactionFixture(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } +}); +const original = " excluded option A because of compatibility\n保留原文 "; +const reasoning = { type: "reasoning", summary: [{ type: "summary_text", text: original }], encrypted_content: "opaque-private-signature" }; +const body = (stream = false, input: unknown[] = [reasoning]) => ({ model: "gw/some-model", stream, + input: [{ type: "message", role: "user", content: "fix addition" }, ...input, { type: "compaction_trigger" }] }); +async function result(response: Response): Promise> { + expect(response.status).toBe(200); + if (!response.headers.get("content-type")?.includes("text/event-stream")) return await response.json() as Record; + const text = await response.text(); + const events = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + const completed = events.find(event => event.type === "response.completed"); + expect(completed).toBeDefined(); + expect(events.filter(event => event.type === "response.output_item.done")).toHaveLength(1); + return completed.response; +} +function archives(): string[] { + const dir = join(home, "reasoning-archive"); + return existsSync(dir) ? readdirSync(dir) : []; +} + +describe("routed v2 retention", () => { + test.each([undefined, {}, { maxTokens: 1 }, { enabled: false, maxTokens: 1 }])( + "retention stays off without an explicit true opt-in (%j)", async setting => { + let sent = ""; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + sent = await new Request(url, init).text(); + return jsonResponse(completedPayload("progress")); + }) as typeof fetch; + for (const v1 of [true, false]) { + const config = keyProviderConfig(); + config.reasoningRetention = setting; + const request = compactionRequest(v1 ? { model: "gw/some-model", input: [reasoning] } : body()); + const response = await (v1 ? handleResponsesCompact : handleResponses)(request, config, { model: "", provider: "" }); + const output = await result(response); + expect(sent).toContain("excluded option A"); + expect(JSON.stringify(output)).not.toContain("ocx2:"); + expect(JSON.stringify(output)).not.toContain("locally retained assistant reasoning"); + expect(archives()).toEqual([]); + } + }); + + test.each([false, true])("exact reasoning survives compaction, replay and repeated compaction (stream=%s)", async stream => { + let sent = ""; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + sent = await new Request(url, init).text(); + return stream ? sseResponse([ + { type: "response.output_text.delta", delta: "current progress" }, + { type: "response.completed", response: { status: "completed", output: [] } }, + ]) : jsonResponse(completedPayload("current progress")); + }) as typeof fetch; + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true }; + const first = await result(await handleResponses(compactionRequest(body(stream)), config, { model: "", provider: "" })); + expect(first.output).toHaveLength(1); + expect(first.output[0].type).toBe("compaction"); + expect(sent).not.toContain("excluded option A"); + expect(sent).not.toContain("opaque-private-signature"); + const data = decodeRetainedCompaction(first.output[0].encrypted_content)!; + expect(data.summary).toBe("current progress"); + expect(data.reasoning).toHaveLength(1); + expect(data.reasoning[0]).toContain(original); + const parsed = parseRequest({ model: "gw/some-model", input: first.output }); + expect(parsed.context.messages).toHaveLength(2); + expect(parsed.context.messages[1].content).toBe(data.reasoning[0]); + const second = await result(await handleResponses(compactionRequest(body(stream, first.output)), config, { model: "", provider: "" })); + expect(decodeRetainedCompaction(second.output[0].encrypted_content)?.reasoning).toEqual(data.reasoning); + expect(sent).not.toContain("excluded option A"); + // The raw Responses adapter must lower the envelope before an ordinary turn too. + await (await handleResponses(compactionRequest({ model: "gw/some-model", stream, + input: [...second.output, { type: "message", role: "user", content: "continue" }] }), config, { model: "", provider: "" })).text(); + expect(sent).toContain("excluded option A"); + expect(sent).not.toContain("ocx2:"); + }); + + test("canonical native v2 retains its original protocol and creates no local archive", async () => { + const config = keyProviderConfig({ authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + let sent = ""; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + sent = await new Request(url, init).text(); + expect(archives()).toEqual([]); + return sseResponse([{ type: "response.completed", response: { + status: "completed", output: [{ type: "compaction", encrypted_content: "native-ciphertext" }], + } }]); + }) as typeof fetch; + const response = await handleResponses(compactionRequest(body(true), undefined, { + authorization: "Bearer native-fixture-only", "chatgpt-account-id": "native-fixture-account", + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(sent).toContain("excluded option A"); + expect(sent).toContain("compaction_trigger"); + expect(text).toContain("native-ciphertext"); + expect(text).not.toContain("ocx2:"); + expect(archives()).toEqual([]); + }); + + test("v1 can compact a v2 retained envelope without summarizing its reasoning", async () => { + let sent = ""; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + sent = await new Request(url, init).text(); + return jsonResponse(completedPayload("progress")); + }) as typeof fetch; + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true }; + const first = await result(await handleResponses(compactionRequest(body()), config, { model: "", provider: "" })); + const compact = await handleResponsesCompact(compactionRequest({ model: "gw/some-model", input: first.output }), config, { model: "", provider: "" }); + const next = await compact.json() as { output: unknown[] }; + expect(compact.status).toBe(200); + expect(JSON.stringify(next.output)).toContain("excluded option A"); + expect(sent).not.toContain("excluded option A"); + }); + + test("overflow archives exact text and publishes the path in the single envelope", async () => { + globalThis.fetch = (async () => jsonResponse(completedPayload("progress"))) as typeof fetch; + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + const json = await result(await handleResponses(compactionRequest(body()), config, { model: "", provider: "" })); + const data = decodeRetainedCompaction(json.output[0].encrypted_content)!; + expect(data.reasoning).toEqual([]); + expect(archives()).toHaveLength(1); + const path = join(home, "reasoning-archive", archives()[0]!); + expect(readFileSync(path, "utf-8")).toBe(original); + expect(data.summary).toContain(path); + }); + + test.each([false, true].flatMap(stream => ["failed", "incomplete", "empty", "opaque", "http-error", "cancelled"].map(outcome => [stream, outcome] as const)))("stream=%s %s does not retain a provisional archive", async (stream, outcome) => { + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + const abort = new AbortController(); + globalThis.fetch = (async () => { + expect(archives()).toHaveLength(1); + if (outcome === "cancelled") { abort.abort(); throw abort.signal.reason; } + if (outcome === "http-error") return new Response("bad request", { status: 400 }); + if (outcome === "opaque") { + const payload = { status: "completed", output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }; + return stream ? sseResponse([{ type: "response.completed", response: payload }]) : jsonResponse(payload); + } + if (outcome === "empty") return jsonResponse(completedPayload("")); + return jsonResponse({ ...completedPayload("partial"), status: outcome }); + }) as typeof fetch; + try { + const response = await handleResponses(compactionRequest(body(stream), abort.signal), config, { model: "", provider: "" }); + const text = await response.text(); + if (outcome === "empty") { + expect(text).not.toContain("ocx2:"); + if (response.status === 200) { + if (stream) expect(text).toContain("response.failed"); + else expect(JSON.parse(text).status).toBe("failed"); + } + else { expect(response.status).toBe(502); expect(text).toContain("invalid_compaction_summary"); } + } + if (outcome === "opaque") { expect(text).toContain("native-ciphertext"); expect(text).not.toContain("ocx2:"); } + } catch (error) { if (outcome !== "cancelled") throw error; } + expect(archives()).toEqual([]); + }); + + test.each([undefined, {}, { maxTokens: 1 }, { enabled: false, maxTokens: 1 }])( + "disabled preparation allocates no retention state or archive (%j)", setting => { + const config = keyProviderConfig(); + config.reasoningRetention = setting; + const parsed = parseRequest(body()); + const raw = parsed._rawBody; + const messages = parsed.context.messages; + const budget = createTranslatorBudget(); + expect(prepareCompactionRetention(parsed, config, undefined, budget, new AbortController().signal)).toBeUndefined(); + expect(parsed._rawBody).toBe(raw); + expect(parsed.context.messages).toBe(messages); + expect(parsed._compactionRetention).toBeUndefined(); + expect(archives()).toEqual([]); + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + }); + + test("completed-frame overflow removes the provisional archive", async () => { + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + const parsed = parseRequest(body(true)); + const budget = createTranslatorBudget({ maxTurnBytes: 4096 }); + const retention = prepareCompactionRetention(parsed, config, undefined, budget, new AbortController().signal)!; + expect(archives()).toHaveLength(1); + async function* events() { + yield { type: "text_delta" as const, text: "summary" }; + yield { type: "done" as const }; + } + try { + const stream = bridgeToResponsesSSE(events(), "model", undefined, undefined, undefined, undefined, 2000, { + compaction: true, compactionRetention: parsed._compactionRetention, translatorBudget: budget, + onCompletedResponse() { + budget.chargeRetained(4096 - budget.snapshot().currentBytes, { kind: "retained_collectors" }); + }, + }); + const text = await retention.deliver(new Response(stream)).text(); + expect(text).toContain("response.output_item.done"); + expect(text).toContain("translation_buffer_limit"); + expect(text).toContain("response.failed"); + expect(text).not.toContain('event: response.completed'); + expect(archives()).toEqual([]); + } finally { retention.dispose(); budget.dispose(); } + }); + + test("body cancellation cleans up before a stalled upstream cancel settles", () => { + const config = keyProviderConfig(); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + const parsed = parseRequest(body(true)); + const budget = createTranslatorBudget(); + const retention = prepareCompactionRetention(parsed, config, undefined, budget, new AbortController().signal)!; + expect(archives()).toHaveLength(1); + const response = retention.deliver(new Response(new ReadableStream({ cancel: () => new Promise(() => {}) }))); + void response.body!.cancel(); + expect(archives()).toEqual([]); + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + }); + + test("same-target retries reuse one provisional archive", async () => { + const config = keyProviderConfig({ retryOn429: { attempts: 1, intervalMs: 1, maxIntervalMs: 1 } }); + config.reasoningRetention = { enabled: true, maxTokens: 1 }; + const files: string[][] = []; + globalThis.fetch = (async () => { + files.push(archives()); + return files.length === 1 ? new Response("rate limited", { status: 429 }) : jsonResponse(completedPayload("progress")); + }) as typeof fetch; + await result(await handleResponses(compactionRequest(body()), config, { model: "", provider: "" })); + expect(files).toHaveLength(2); + expect(files[0]).toHaveLength(1); + expect(files[1]).toEqual(files[0]); + expect(archives()).toEqual(files[0]); + }); +}); + +describe("retained compaction encoding", () => { + test("old envelopes and generic history recovery remain readable", () => { + expect(decodeCompactionSummary(encodeCompactionSummary("old summary"))).toBe("old summary"); + expect(decodeCompactionSummary(encodeRetainedCompaction("summary", [original]))).toContain(original); + expect(decodeCompactionSummary("native-ciphertext")).toBeNull(); + }); + test.each(["not-base64", Buffer.from('{"version":3,"summary":"x","reasoning":[]}').toString("base64"), + Buffer.from('{"version":2,"summary":"x","reasoning":[123]}').toString("base64")])("malformed proxy envelopes fail explicitly (%s)", payload => { + expect(() => parseRequest({ model: "gw/some-model", input: [{ type: "compaction", encrypted_content: "ocx2:" + payload }] })).toThrow(); + }); +}); diff --git a/tests/responses/reasoning-retention.test.ts b/tests/responses/reasoning-retention.test.ts new file mode 100644 index 00000000000..dfeb4cb9a3c --- /dev/null +++ b/tests/responses/reasoning-retention.test.ts @@ -0,0 +1,276 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + appendRetentionNotice, + applyReasoningRetention, + collectReasoningTexts, +} from "../../src/responses/reasoning-retention"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { estimateTokens } from "../../src/lib/token-estimate"; + +const reasoningItem = (text: string) => ({ + type: "reasoning", + summary: [{ type: "summary_text", text }], +}); + +describe("collectReasoningTexts", () => { + test("extracts summary text from reasoning items and ignores other items", () => { + const texts = collectReasoningTexts([ + { type: "message", role: "user", content: "hi" }, + reasoningItem("第一段的推理"), + reasoningItem("second stretch"), + ]); + expect(texts).toEqual(["第一段的推理", "second stretch"]); + }); + + test("returns an empty list when nothing carries readable reasoning", () => { + expect(collectReasoningTexts([{ type: "message", role: "user", content: "hi" }])).toEqual([]); + expect(collectReasoningTexts(undefined)).toEqual([]); + }); +}); + +describe("applyReasoningRetention", () => { + let dir = ""; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = ""; + }); + + const setup = () => { + dir = mkdtempSync(join(tmpdir(), "reasoning-retention-")); + return dir; + }; + + test("holds reasoning locally while it fits the configured share", () => { + const archiveDir = setup(); + const input = [reasoningItem("short thinking")]; + const result = applyReasoningRetention(input, { + archiveDir, + contextWindow: 1_000_000, + maxContextPercent: 20, + }); + expect(result.input).toEqual([]); + expect(collectReasoningTexts(result.retainedReasoning)).toEqual(["short thinking"]); + expect(result.archived).toBeUndefined(); + }); + + test("holds reasoning locally under the absolute cap when the context window is unknown", () => { + const archiveDir = setup(); + const input = [reasoningItem("x".repeat(100_000))]; + const result = applyReasoningRetention(input, { archiveDir, maxContextPercent: 20 }); + expect(result.input).toEqual([]); + expect(collectReasoningTexts(result.retainedReasoning)).toEqual(["x".repeat(100_000)]); + expect(result.archived).toBeUndefined(); + }); + + test("keeps no-reasoning input unchanged and strips opaque-only items without decoding them", () => { + const input = [{ type: "message", role: "user", content: "hello" }]; + expect(applyReasoningRetention(input, { archiveDir: setup() }).input).toBe(input); + const result = applyReasoningRetention([...input, { type: "reasoning", encrypted_content: "private-ciphertext" }], { archiveDir: dir }); + expect(result.input).toEqual(input); + expect(result.retainedReasoning).toBeUndefined(); + }); + + test("retains exact envelope text at the inclusive cap without provider signatures", () => { + const text = " exact\n原文 "; + const input = [{ type: "reasoning", summary: [{ type: "summary_text", text: "display-only" }], + encrypted_content: encodeReasoningEnvelope({ txt: text, sig: "provider-signature", krc: "opaque" }) }]; + // The 137-character frame plus 10 Latin and two CJK characters estimates to 39 tokens. + const result = applyReasoningRetention(input, { archiveDir: setup(), maxTokens: 39 }); + expect(collectReasoningTexts(result.retainedReasoning)).toEqual([text]); + expect(JSON.stringify(result.retainedReasoning)).not.toContain("provider-signature"); + expect(result.archived).toBeUndefined(); + expect(applyReasoningRetention(input, { archiveDir: dir, maxTokens: 38 }).archived).toBeDefined(); + }); + + test.each([1_000, undefined])("many short reasoning blocks include each retained frame in the budget (window=%s)", contextWindow => { + const texts = Array(100).fill("x"); + const result = applyReasoningRetention(texts.map(reasoningItem), { + archiveDir: setup(), contextWindow, maxContextPercent: 20, maxTokens: 100, + }); + expect(result.retainedReasoning).toBeUndefined(); + // 100 frames of 138 Latin characters plus 99 separators: ceil(13899 / 4) = 3475. + expect(result.archived?.tokens).toBe(3_475); + expect(result.archived?.cap).toBe(100); + expect(readFileSync(result.archived!.path, "utf-8")).toBe(texts.join("\n\n---\n\n")); + }); + + test.each([true, false])("framed reasoning fits the inclusive cap and is not framed twice on replay (known window=%s)", windowKnown => { + const archiveDir = setup(); + // The protocol frame has 137 Latin characters: three payload characters make 35 tokens. + const limits = windowKnown ? { contextWindow: 175, maxTokens: 100 } : { maxTokens: 35 }; + const result = applyReasoningRetention([reasoningItem("xxx")], { archiveDir, ...limits }); + expect(result.archived).toBeUndefined(); + expect(collectReasoningTexts(result.retainedReasoning)).toEqual(["xxx"]); + expect(estimateTokens(result.retainedReasoning![0]!.content[0]!.text)).toBe(35); + const repeated = applyReasoningRetention(result.retainedReasoning, { archiveDir, ...limits }); + expect(repeated).toEqual(result); + const smallerLimits = windowKnown ? { contextWindow: 170, maxTokens: 100 } : { maxTokens: 34 }; + const over = applyReasoningRetention(result.retainedReasoning, { archiveDir, ...smallerLimits }); + expect(over.archived?.cap).toBe(34); + expect(over.archived?.tokens).toBe(35); + expect(readFileSync(over.archived!.path, "utf-8")).toBe("xxx"); + }); + + test("archive write failure leaves original input intact for a client retry", () => { + const archiveDir = join(setup(), "not-a-directory"); + writeFileSync(archiveDir, "occupied"); + const input = [reasoningItem("original reasoning"), { type: "message", role: "user", content: "question" }]; + const snapshot = JSON.stringify(input); + expect(() => applyReasoningRetention(input, { archiveDir, maxTokens: 1 })).toThrow(); + expect(JSON.stringify(input)).toBe(snapshot); + }); + + test("partial archive write failure removes only the owned file", () => { + const archiveDir = setup(); + const originalWrite = fs.writeFileSync; + const write = spyOn(fs, "writeFileSync").mockImplementation((path, ..._args) => { + originalWrite(path, "partial plaintext"); + throw new Error("fixture ENOSPC"); + }); + try { + expect(() => applyReasoningRetention([reasoningItem("full reasoning")], { archiveDir, maxTokens: 1 })).toThrow("fixture ENOSPC"); + expect(readdirSync(archiveDir)).toEqual([]); + } finally { write.mockRestore(); } + }); + + test("exclusive-open collision preserves the existing archive", () => { + const archiveDir = setup(); + const path = join(archiveDir, "reasoning-1970-01-01T00-00-00-000Z-i.md"); + writeFileSync(path, "existing archive"); + const random = spyOn(Math, "random").mockReturnValue(0.5); + try { + expect(() => applyReasoningRetention([reasoningItem("new reasoning")], { archiveDir, maxTokens: 1, now: 0 })).toThrow(); + expect(readFileSync(path, "utf-8")).toBe("existing archive"); + } finally { random.mockRestore(); } + }); + + test("archives reasoning over the absolute cap even when the context window is unknown", () => { + const archiveDir = setup(); + const input = [reasoningItem("x".repeat(420_000))]; + const result = applyReasoningRetention(input, { archiveDir, maxContextPercent: 20 }); + expect(result.archived).toBeDefined(); + expect(result.archived!.cap).toBe(100_000); + expect(appendRetentionNotice("summary", result.archived)).toContain("context window unknown"); + }); + + test.skipIf(process.platform === "win32")("new archives are private to the user on POSIX", () => { + const archiveDir = join(setup(), "private-archive"); + const result = applyReasoningRetention([reasoningItem("private original reasoning")], { archiveDir, maxTokens: 1 }); + expect(statSync(archiveDir).mode & 0o777).toBe(0o700); + expect(statSync(result.archived!.path).mode & 0o777).toBe(0o600); + }); + + test("archives oversized reasoning to a local file and leaves a path note", () => { + const archiveDir = setup(); + const big = "考虑了很多方案 ".repeat(2000); + const input = [ + { type: "message", role: "user", content: "问题" }, + reasoningItem(big), + { type: "compaction_trigger" }, + ]; + const result = applyReasoningRetention(input, { + archiveDir, + contextWindow: 10_000, + maxContextPercent: 20, + }); + expect(result.archived).toBeDefined(); + expect(result.archived!.tokens).toBeGreaterThan(2000); + const archivedText = readFileSync(result.archived!.path, "utf-8"); + expect(archivedText).toContain(big); + + const nextInput = result.input as Array>; + expect(nextInput.some(item => item.type === "reasoning")).toBe(false); + expect(nextInput.some(item => item.type === "compaction_trigger")).toBe(true); + const note = nextInput.find(item => item.type === "message" && typeof item.content === "string" + && (item.content as string).includes(result.archived!.path)); + expect(note).toBeDefined(); + }); + + test("defaults to a 20 percent share when the config omits it", () => { + const archiveDir = setup(); + const input = [reasoningItem("x".repeat(100_000))]; + const result = applyReasoningRetention(input, { archiveDir, contextWindow: 10_000 }); + expect(result.archived).toBeDefined(); + }); + + test("archives reasoning that fits the percent share but exceeds the absolute token cap", () => { + const archiveDir = setup(); + // A huge window keeps the percent share (20% of 10M = 2M tokens) from binding, so only + // the absolute cap can justify archiving ~117K tokens of reasoning. + const text = "x".repeat(420_000); + expect(estimateTokens(text)).toBeGreaterThan(100_000); + expect(estimateTokens(text)).toBeLessThan(2_000_000); + const input = [reasoningItem(text)]; + const result = applyReasoningRetention(input, { + archiveDir, + contextWindow: 10_000_000, + maxContextPercent: 20, + }); + expect(result.archived).toBeDefined(); + expect(result.archived!.tokens).toBeGreaterThan(100_000); + }); + + test("holds reasoning locally when it fits both the percent share and the absolute cap", () => { + const archiveDir = setup(); + const text = "x".repeat(320_000); + expect(estimateTokens(text)).toBeLessThan(100_000); + const input = [reasoningItem(text)]; + const result = applyReasoningRetention(input, { + archiveDir, + contextWindow: 10_000_000, + maxContextPercent: 20, + }); + expect(result.input).toEqual([]); + expect(collectReasoningTexts(result.retainedReasoning)).toEqual([text]); + expect(result.archived).toBeUndefined(); + }); + + test("an explicit maxTokens overrides the default cap", () => { + const archiveDir = setup(); + const text = "x".repeat(100_000); + expect(estimateTokens(text)).toBeGreaterThan(20_000); + expect(estimateTokens(text)).toBeLessThan(100_000); + const input = [reasoningItem(text)]; + const underDefault = applyReasoningRetention(input, { + archiveDir, + contextWindow: 10_000_000, + maxContextPercent: 20, + }); + expect(underDefault.input).toEqual([]); + expect(collectReasoningTexts(underDefault.retainedReasoning)).toEqual([text]); + const underCustom = applyReasoningRetention(input, { + archiveDir, + contextWindow: 10_000_000, + maxContextPercent: 20, + maxTokens: 20_000, + }); + expect(underCustom.archived).toBeDefined(); + }); +}); + +describe("appendRetentionNotice", () => { + test("leaves the summary alone when nothing was archived", () => { + expect(appendRetentionNotice("summary", undefined)).toBe("summary"); + }); + + test.each([true, false])("appends an English notice with the archive path and numeric limits (known window=%s)", windowKnown => { + const text = appendRetentionNotice("summary", { + path: "/tmp/archive.md", + tokens: 5000, + percent: 20, + cap: 2000, + windowKnown, + }); + expect(text).toStartWith("summary\n\n[reasoning retention notice]"); + expect(text).toContain("/tmp/archive.md"); + expect(text).toContain("5000 tokens"); + expect(text).toContain("2000 tokens"); + expect(text).toContain(windowKnown ? "20% of the context window" : "context window unknown"); + expect(text).toContain("read that file for the full reasoning"); + expect(text).not.toMatch(/[\u4e00-\u9fff]/); + }); +}); diff --git a/tests/responses/responses-compaction-recovery.test.ts b/tests/responses/responses-compaction-recovery.test.ts index 6a20ef424b4..69897e97622 100644 --- a/tests/responses/responses-compaction-recovery.test.ts +++ b/tests/responses/responses-compaction-recovery.test.ts @@ -4,10 +4,12 @@ import { getDefaultConfig } from "../../src/config"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; import { runWithCompactionRecovery } from "../../src/server/responses/compaction-recovery"; import { decodeCompactionSummary } from "../../src/responses/compaction"; +import { decodeRetainedCompaction } from "../../src/responses/retained-compaction"; import { COMPACTION_IMAGE_NOTE } from "../../src/responses/compaction-images"; import * as visionModule from "../../src/vision"; import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { isNonReplayableResponse } from "../../src/lib/upstream-retry"; import { jsonUtf8Bytes } from "../../src/lib/json-byte-size"; import type { AdapterEvent, OcxConfig, OcxParsedRequest } from "../../src/types"; import type { RequestLogContext } from "../../src/server/request-log"; @@ -197,6 +199,28 @@ describe("routed compaction emergency integration", () => { expect(config).toEqual(before); }); + test.each([false, true])("v2 recovery preserves reasoning outside the summary on repeated compaction (stream=%s)", async stream => { + const payload = body(stream); + (payload.input as unknown[]).unshift({ type: "reasoning", summary: [{ type: "summary_text", text: "Exact preserved option reasoning." }] }); + const config = settings(); + const output = async (response: Response) => { + if (!stream) return (await response.json()).output; + const events = (await response.text()).split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + return events.find(event => event.type === "response.completed").response.output; + }; + const first = await output(await handleResponses(request(payload), config, { model: "", provider: "" })); + const data = decodeRetainedCompaction(first[0].encrypted_content)!; + expect(data.reasoning[0]).toContain("Exact preserved option reasoning."); + expect(data.summary).not.toContain("Exact preserved option reasoning."); + expect(data.summary).toContain("ALPHA-729"); + expect(calls.map(call => call.model)).toEqual(["swe-2", "rescue"]); + expect(JSON.stringify(calls.map(call => call.parsed.context.messages))).not.toContain("Exact preserved option reasoning."); + const second = await output(await handleResponses(request({ ...payload, input: [...first, { type: "compaction_trigger" }] }), config, { model: "", provider: "" })); + expect(decodeRetainedCompaction(second[0].encrypted_content)?.reasoning).toEqual(data.reasoning); + expect(calls.map(call => call.model)).toEqual(["swe-2", "rescue", "swe-2", "rescue"]); + expect(JSON.stringify(calls.map(call => call.parsed.context.messages))).not.toContain("Exact preserved option reasoning."); + }); + test("routed v1 returns replacement history retaining original user text once", async () => { const response = await handleResponsesCompact(request(body(false, false), "responses/compact"), settings(), { model: "", provider: "" }); expect(response.status).toBe(200); @@ -343,6 +367,33 @@ describe("routed compaction emergency integration", () => { expect(budget.used).toBe(2); }); + test.each(["error", "eof"])("retained v2 compaction combo stops after a replay-unsafe runTurn %s", async outcome => { + sourceEvents = [{ type: "heartbeat", replayUnsafe: true }, ...(outcome === "error" ? [sourceError] : [])]; + const config = settings(); + config.compactionRecovery = undefined; + config.combos = { [`retained-unsafe-${outcome}`]: { + strategy: "failover", targets: [ + { provider: "source", model: "swe-2" }, + { provider: "emergency", model: "rescue" }, + ], + } }; + const payload: Record = { ...body(true), model: `combo/retained-unsafe-${outcome}` }; + (payload.input as unknown[]).unshift({ type: "reasoning", summary: [ + { type: "summary_text", text: "Preserve the exact reasoning without replaying local side effects." }, + ] }); + const translatorBudget = createTranslatorBudget(); + try { + const response = await handleResponses(request(payload), config, { model: "", provider: "" }, { translatorBudget }); + const text = await response.text(); + expect(calls.map(call => call.model)).toEqual(["swe-2"]); + expect(response.status).toBe(502); + expect(isNonReplayableResponse(response)).toBe(true); + expect(text).toContain(outcome === "error" ? "Source rejected compact fixture" : "Adapter ended before producing a response"); + } finally { + translatorBudget.dispose(); + } + }); + test("emergency transient 5xx retry cannot exceed the shared cap", async () => { const config = settings(); config.providers.emergency = { adapter: "openai-chat", authMode: "key", apiKey: "fixture-only", baseUrl: "https://emergency.example/v1", transientRetryOn5xx: { attempts: 3 } }; diff --git a/tests/responses/responses-core-modules.test.ts b/tests/responses/responses-core-modules.test.ts index 0ce1ae5436c..ed1c3fb5a8f 100644 --- a/tests/responses/responses-core-modules.test.ts +++ b/tests/responses/responses-core-modules.test.ts @@ -77,7 +77,7 @@ describe("Responses core module boundaries", () => { }); test("lease transfer retains both finally owners until response construction settles", () => { - const ingress = readResponsesCoreModule("core.ts"); + const ingress = readResponsesCoreModule("core-pipeline.ts"); const native = readResponsesCoreModule("passthrough-execution.ts"); expect(ingress).toContain("const passthroughResult = await executePassthroughResponse("); expect(ingress).toContain("if (passthroughResult instanceof Response) return passthroughResult;");