Skip to content

fix(responses): make reasoning replay restart-safe and observable (#950) - #1126

Draft
ZachDreamZ wants to merge 4 commits into
lidge-jun:devfrom
ZachDreamZ:fix/950-reasoning-replay-robustness
Draft

fix(responses): make reasoning replay restart-safe and observable (#950)#1126
ZachDreamZ wants to merge 4 commits into
lidge-jun:devfrom
ZachDreamZ:fix/950-reasoning-replay-robustness

Conversation

@ZachDreamZ

@ZachDreamZ ZachDreamZ commented Aug 6, 2026

Copy link
Copy Markdown

What

Continues the #950 fix (PR #971) on the Codex / /v1/responses path: the reasoning replay cache is currently in-memory only, so a proxy restart mid-round still loses recovery for a DeepSeek thinking-mode tool round, and there is no privacy-safe signal when a bare tool-call continuation is about to be serialized.

Changes

  • Opt-in disk spill for the replay cache: OPENCODEX_REASONING_REPLAY_PERSIST=1 (optional OPENCODEX_REASONING_REPLAY_FILE override, default <config dir>/reasoning-replay-cache.json, mirroring getConfigDir() resolution). Bounded by the same entry/byte/TTL caps as memory, written atomically (tmp + rename) with best-effort 0600 perms, rehydrated at boot. Default stays memory-only — the privacy stance of fix(responses): keep DeepSeek reasoning_content on tool-call continuations (#950) #971 is unchanged unless explicitly opted in.
  • Privacy-safe diagnostics ([Bug] OpenCode Go DeepSeek V4 Flash intermittently drops reasoning_content on tool-call continuation #950 checklist): getReasoningReplayStats() exposes counters and bounds only (entries, bytes, hits, misses, bare-serialization counts per model, persistence state) — never reasoning text.
  • Invariant counter: recordBareToolCallSerialization(model) counts the exact 400 shape (assistant message with tool_calls, preserved model, no reasoning_content re-attached) in both the compacted-history path and the orphan-repair path, with a throttled console.warn containing counters only.
  • Regression tests (tests/reasoning-replay-robustness.test.ts): restart round-trip, TTL filter on reload, corrupt-file tolerance, entry-cap on reload, stats never contain reasoning text, persistence off by default, and the wire-level bare-serialization counter.

Verification

  • bun test on the reasoning suites: 45 pass / 0 fail.
  • bun run typecheck clean; bun run privacy:scan passes.
  • Local proxy patched with the same change and live-verified against opencode-free/deepseek-v4-flash-free (tool-call continuation replay 200, no 400s).

Notes

  • No reasoning text is ever logged or exposed through stats; the spill file is opt-in and bounded.
  • Test-only seams keep the memory/privacy defaults intact.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing.

  • I pushed my PR to the latest dev commit.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when preserving reasoning across tool-call continuations.
    • Added recovery for missing or expired cached reasoning data.
  • Diagnostics

    • Added privacy-safe cache statistics and throttled warnings without exposing reasoning content.
  • Performance & Reliability

    • Added optional persistent caching with expiration, bounded storage, atomic writes, and recovery from corrupted cache data.

…dge-jun#950)

DeepSeek thinking mode requires the assistant's original reasoning_content on
every tool-call continuation. The lidge-jun#971 replay cache is in-memory only, so a
proxy restart mid-round still loses recovery, and there is no privacy-safe
way to see when a bare tool-call continuation is about to be serialized.

- Opt-in disk spill (OPENCODEX_REASONING_REPLAY_PERSIST=1, optional
  OPENCODEX_REASONING_REPLAY_FILE override): bounded, TTL'd, atomically
  written with best-effort 0600 perms; rehydrated at boot. Default stays
  memory-only.
- Privacy-safe diagnostics: getReasoningReplayStats() exposes counters and
  bounds only; recordBareToolCallSerialization() counts the exact 400 shape
  per model; openai-chat logs a throttled counter line (never reasoning text)
  when a bare tool-call continuation is serialized for a
  preserveReasoningContentModels provider.
- Regression tests: restart round-trip, TTL filter on reload, corrupt-file
  tolerance, entry-cap on reload, stats privacy, and the wire-level bare
  serialization counter.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deterministic PR hygiene checks passed.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

⏳ DRAFT

  • review readiness checklist open (0/4 boxes ticked).

What to do

  • Tick all four boxes in the PR description once you're done (currently 0/4).

Review readiness checklist

  • ⬜ All CI tests are green on my local testing.
  • ⬜ I pushed my PR to the latest dev commit.
  • ⬜ I resolved all correct Codex and CodeRabbit findings.
  • ⬜ My PR is ready for review.

0/4 boxes ticked.

This PR stays in draft until every box above is ticked.

@github-actions
github-actions Bot marked this pull request as draft August 6, 2026 11:30
@github-actions github-actions Bot added the bug Something isn't working label Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 383d07d7-4461-404a-8fd7-174b65142b42

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The reasoning replay cache now supports optional persistent storage, bounded and TTL-filtered rehydration, privacy-safe diagnostics, and serialization counters. OpenAI Chat continuation paths report missing cached reasoning through throttled warnings. Robustness tests cover persistence and diagnostics.

Changes

Reasoning replay robustness

Layer / File(s) Summary
Cache persistence and statistics
src/responses/reasoning-replay-cache.ts:17-300
The cache adds opt-in debounced persistence, atomic writes, startup rehydration, TTL filtering, a 64-entry limit, hit/miss counters, serialization counters, diagnostics, and test controls.
Tool-call continuation diagnostics
src/adapters/openai-chat.ts:11-443
Assistant tool-call serialization and orphan tool-result reconstruction record missing reasoning and emit throttled warnings with model identifiers and aggregate counters.
Persistence and privacy validation
tests/reasoning-replay-robustness.test.ts:1-179
Tests cover simulated restarts, expiration, corrupt files, entry limits, disabled persistence, privacy-safe statistics, and bare tool-call serialization counters.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OpenAIChatAdapter
  participant ReasoningReplayCache
  participant WarningLogger
  OpenAIChatAdapter->>ReasoningReplayCache: Record bare tool-call serialization
  ReasoningReplayCache-->>OpenAIChatAdapter: Return aggregate cache counters
  OpenAIChatAdapter->>WarningLogger: Emit throttled privacy-safe warning
Loading

Possibly related PRs

Suggested reviewers: wibias, ingwannu, lidge-jun

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: restart-safe persistence and observability for reasoning replay in the responses path.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/responses/reasoning-replay-cache.ts`:
- Around line 78-86: Replace the duplicated path logic in defaultPersistPath
with a dependency-free shared config-directory helper, then update both
defaultPersistPath and config.getConfigDir() to call that helper. Preserve the
existing OPENCODEX_HOME and home-directory resolution behavior while ensuring
both locations use the same shared routing path.
- Around line 153-157: The reasoning replay cache must enforce TTL in both
memory and persisted spill data. Update the expiry and startup-validation paths
around the cache eviction logic and spill-loading code to reject future-dated
timestamps, mark the spill dirty when entries expire or fail validation, and
rewrite the spill after loading so removed records are not retained on disk. Add
regressions covering spill contents after expiry and loading a record with a
future finite entry timestamp.
- Around line 202-205: Update the spill-file write flow around tmpPath,
writeFileSync, and renameSync to use a unique temporary filename and create it
with mode 0o600. Apply restrictive permissions at creation time, then rename the
secured temporary file into persistPath; do not rely on the post-rename
chmodSync for protection.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b81d6fa3-6021-4561-8014-a37cd07222ac

📥 Commits

Reviewing files that changed from the base of the PR and between 8ed03e7 and cb683ab.

📒 Files selected for processing (3)
  • src/adapters/openai-chat.ts
  • src/responses/reasoning-replay-cache.ts
  • tests/reasoning-replay-robustness.test.ts

Comment thread src/responses/reasoning-replay-cache.ts Outdated
Comment on lines +78 to +86
/** Mirror config.getConfigDir() resolution (OPENCODEX_HOME or ~/.opencodex) without importing config. */
function defaultPersistPath(): string {
const raw = process.env.OPENCODEX_HOME?.trim();
let base: string;
if (!raw) base = join(homedir(), ".opencodex");
else if (raw === "~") base = homedir();
else if (raw.startsWith("~/") || raw.startsWith("~\\")) base = join(homedir(), raw.slice(2));
else base = raw;
return join(base, "reasoning-replay-cache.json");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared configuration directory resolver.

Line 78 duplicates config.getConfigDir() behavior instead of using the shared configuration layer. If that resolver changes, config and the replay spill can resolve OPENCODEX_HOME to different directories.

Extract a dependency-free config-directory helper and use it from both locations. As per path instructions, changes must not bypass shared routing/config layers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/responses/reasoning-replay-cache.ts` around lines 78 - 86, Replace the
duplicated path logic in defaultPersistPath with a dependency-free shared
config-directory helper, then update both defaultPersistPath and
config.getConfigDir() to call that helper. Preserve the existing OPENCODEX_HOME
and home-directory resolution behavior while ensuring both locations use the
same shared routing path.

Source: Path instructions

Comment on lines 153 to 157
if (now() - entry.at >= TTL_MS) {
entries.delete(key);
totalBytes -= entry.bytes;
misses += 1;
return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce TTL for persisted records at rest.

Line 153 removes an expired entry only from memory. It does not schedule a spill rewrite. Lines 229-233 also skip expired records during startup without rewriting the file. The reasoning text can therefore remain on disk indefinitely.

Line 231 also accepts a future finite timestamp. now() - entryAt is negative, so that record can bypass expiry until the system clock reaches that future value.

Mark the spill dirty when records expire or fail validation. Rewrite it after loading. Reject timestamps where entryAt > now(). Add regressions that inspect the spill after expiry and load a future-dated record. Based on PR objectives, persisted entries must remain TTL-bounded.

Also applies to: 229-233

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/responses/reasoning-replay-cache.ts` around lines 153 - 157, The
reasoning replay cache must enforce TTL in both memory and persisted spill data.
Update the expiry and startup-validation paths around the cache eviction logic
and spill-loading code to reject future-dated timestamps, mark the spill dirty
when entries expire or fail validation, and rewrite the spill after loading so
removed records are not retained on disk. Add regressions covering spill
contents after expiry and loading a record with a future finite entry timestamp.

Comment thread src/responses/reasoning-replay-cache.ts Outdated
Comment on lines +202 to +205
const tmpPath = `${persistPath}.tmp`;
writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
renameSync(tmpPath, persistPath);
try { chmodSync(persistPath, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'reasoning-replay-cache\.ts$' . || true

echo "== relevant file excerpt =="
file="$(fd 'reasoning-replay-cache\.ts$' . | head -n 1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '1,260p' "$file" | cat -n
fi

echo "== search related fs.writeFile/writeFileSync/renameSync usage =="
rg -n "writeSync|writeFileSync|renameSync|chmodSync|uniqueFile|tmp|temporary|persist" src || true

echo "== package/runtime/package manager hints =="
for f in package.json bun.lockb bun.lock package-lock.json pnpm-lock.yaml yarn.lock deno.json; do
  [ -e "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }
done

Repository: lidge-jun/opencodex

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -u

target="src/responses/reasoning-replay-cache.ts"

echo "== file exists =="
if [ -f "$target" ]; then
  echo yes
  echo "== line count =="
  wc -l "$target"
  echo "== excerpt lines 180-220 =="
  sed -n '180,220p' "$target" | nl -ba -v180
else
  echo no
  echo "== tracked files named similar =="
  git ls-files | rg 'reasoning-replay-cache\.ts$|reasoning.*replay.*cache' || true
fi

echo "== focused fs calls in target =="
rg -n "writeFileSync|renameSync|chmodSync|persistPath|tmpPath|create|crypto|randomUUID" "$target" || true

echo "== package/runtime hints =="
for f in package.json bun.lockb bun.lock; do
  [ -e "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }
done

Repository: lidge-jun/opencodex

Length of output: 16243


🌐 Web query:

Bun 1.3 Buffer writeFileSync mode option string encoding third argument

💡 Result:

In Bun, there is no native Bun.writeFileSync function. For operations that require synchronous file writing or Node.js compatibility, Bun provides an implementation of node:fs.writeFileSync [1][2]. When using node:fs.writeFileSync in Bun, the third argument follows standard Node.js fs.writeFileSync behavior [1][3]: 1. Mode: The mode option (e.g., 0o666) is provided within an options object as the third argument. It affects the file permissions only when the file is newly created [1][3]. 2. Encoding: If the third argument is provided as a string, it is interpreted as the encoding (e.g., 'utf8', 'ascii') [4][5][3]. 3. Options Object: Alternatively, you can provide an object as the third argument to specify both mode and encoding (along with other flags) [1][5][3]. If you are using Bun's native, optimized file I/O API (Bun.write), it is asynchronous and does not use a writeFileSync style signature. Instead, Bun.write() accepts a destination and data, and returns a promise [2][6]. Example using node:fs: import { writeFileSync } from 'node:fs'; // Using string as third argument for encoding writeFileSync('file.txt', 'data', 'utf8'); // Using object for mode and encoding writeFileSync('file.txt', 'data', { encoding: 'utf8', mode: 0o644 }); Example using native Bun.write (asynchronous): await Bun.write('file.txt', 'data');

Citations:


Set restrictive permissions before publishing the spill file.

In src/responses/reasoning-replay-cache.ts:202-205, writeFileSync creates ${persistPath}.tmp without an explicit mode, so a local 022 umask can make it readable. renameSync then publishes that file before chmodSync(persistPath, 0o600) runs. Use a unique temp filename with mode: 0o600 and then rename it into place.

Proposed fix
-    const tmpPath = `${persistPath}.tmp`;
-    writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
-    renameSync(tmpPath, persistPath);
-    try { chmodSync(persistPath, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
+    const tmpPath = `${persistPath}.${crypto.randomUUID()}.tmp`;
+    writeFileSync(tmpPath, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 });
+    try { chmodSync(tmpPath, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
+    renameSync(tmpPath, persistPath);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const tmpPath = `${persistPath}.tmp`;
writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
renameSync(tmpPath, persistPath);
try { chmodSync(persistPath, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
const tmpPath = `${persistPath}.${crypto.randomUUID()}.tmp`;
writeFileSync(tmpPath, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 });
try { chmodSync(tmpPath, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
renameSync(tmpPath, persistPath);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/responses/reasoning-replay-cache.ts` around lines 202 - 205, Update the
spill-file write flow around tmpPath, writeFileSync, and renameSync to use a
unique temporary filename and create it with mode 0o600. Apply restrictive
permissions at creation time, then rename the secured temporary file into
persistPath; do not rely on the post-rename chmodSync for protection.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb683abd3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +231 to +233
const entryAt = typeof entry.at === "number" && Number.isFinite(entry.at) ? entry.at : at;
if (at - entryAt >= TTL_MS) continue;
rememberReasoningAt(entry.callId, entry.text, typeof entry.scope === "string" ? entry.scope : undefined, entryAt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Purge expired reasoning from the spill file

When persistence is enabled, entries that have passed the 60-minute TTL are only skipped while loading; the JSON spill file is left untouched. A normal sequence of recording reasoning, flushing, and then restarting after the TTL leaves the raw reasoning text on disk indefinitely until some unrelated successful write happens, so the opt-in disk spill is not actually TTL-bounded. Rewrite or unlink the spill file when expired entries are dropped, or schedule a purge at the expiry time.

Useful? React with 👍 / 👎.

Comment on lines +208 to +210
} catch (err) {
persistLastError = err instanceof Error ? err.message : String(err);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clean up failed persistence temp files

If persistence is enabled and a failure happens after the temp file is written but before the rename completes (for example, another proxy sharing the same OPENCODEX_HOME races on the fixed .tmp path or the target is locked), this catch only records lastError and leaves reasoning-replay-cache.json.tmp containing raw reasoning text. That temp file is never TTL-swept and is written before the final chmod, so use the existing atomic-write pattern with a unique 0600 temp file and truncate/unlink cleanup on errors.

Useful? React with 👍 / 👎.

Comment on lines +168 to +171
persistTimer = setTimeout(() => {
persistTimer = undefined;
writePersisted();
}, PERSIST_DEBOUNCE_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Flush the replay cache before process restarts

With persistence enabled, rememberReasoningForCall() only schedules a debounced write, and production code never calls flushReasoningReplayCache() (only the tests do). If the proxy is restarted or terminated in the 750 ms window after the model emits the tool call, the reasoning is still only in memory and the next process cannot replay it, so the restart-safe path still loses exactly the mid-round continuation this option is meant to protect; write synchronously here or hook the existing restart/shutdown paths to flush first.

Useful? React with 👍 / 👎.

Comment thread src/responses/reasoning-replay-cache.ts Outdated
mkdirSync(dirname(persistPath), { recursive: true });
} catch { /* best-effort */ }
const tmpPath = `${persistPath}.tmp`;
writeFileSync(tmpPath, JSON.stringify(payload), "utf8");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Register the spill file for config cleanup

When the default spill path is under OPENCODEX_HOME, this direct write creates reasoning-replay-cache.json without going through recordOwnedConfigPath, and that filename is not in the initial uninstall manifest. Users who enable persistence can therefore leave a raw-reasoning cache behind after ocx uninstall (or turn cleanup into a partial/refused removal if this file created the config dir first). Register this path with the config ownership manifest before writing, like the other config-dir state files.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant