{"slug": "how-openai-codex-cli-context-compaction-actually-works-remote-compaction-v2-the", "title": "How OpenAI Codex CLI context compaction actually works: remote compaction v2, the encrypted compaction item, measurements over 1087 real compactions, and what the blob's encryption does and doesn't…", "summary": "OpenAI's Codex CLI implements context compaction via a server-side encrypted blob rather than client-side summarization, according to a developer's analysis of the Codex source code and live captures. The mechanism, labeled 'memento' internally, retains user messages verbatim, re-injects fresh instructions, and replays an opaque 'compaction' item that the server decrypts to resume the conversation. Security testing revealed the encryption is not access control, as a second ChatGPT account could decrypt and read a blob minted by another account, making it a long-lived bearer token for conversation state.", "body_md": "Findings from reading the Codex source (`<codex checkout>`\n\n, commit `fa1d4c40d0e`\n\n, 2026-07-28) and from capturing a real compaction against a live 174k-token conversation on 2026-08-02. Raw captures are not published: they contain private conversation content. Everything below is reproducible with the recipe at the end.\n\nCodex compaction is not a client-side \"summarize the transcript\" prompt. The current default implementation (\"remote compaction v2\") works like this:\n\n- The client sends the\n**normal Responses API request**(same model, same instructions, same tools, full history) with one extra input item appended at the end:`{\"type\": \"compaction_trigger\"}`\n\n. - The server/model returns\n**exactly one output item**:`{\"type\": \"compaction\", \"id\": \"cmp_...\", \"encrypted_content\": \"gAAAAAB...\"}`\n\n. The content is an encrypted blob (Fernet token format) that the client cannot read. - The client rebuilds its history as:\n**real user messages retained verbatim**(newest-first, up to a 64k-token budget) + freshly re-injected initial context (developer instructions, AGENTS.md, environment context) + the opaque`compaction`\n\nitem**last**. - On every subsequent request, the\n`compaction`\n\nitem is replayed as an input item. The server decrypts it and expands whatever state it holds into the model's context.\n\nSo the \"summary\" is produced and consumed entirely server-side, by a model that is explicitly trained for this (\"the model is trained to see the compaction summary as the last item in history after mid-turn compaction\" — comment in `compact.rs`\n\n). The client keeps raw user messages and fresh instructions; only the assistant/tool-call bulk is folded into the encrypted state.\n\nModel-side introspection (2026-08-03, see \"What the decrypted handoff looks like\" below) filled in what the blob decrypts to: a ~1,000-word plaintext **assistant-role message on a dedicated summary channel** — first-person notes written to a successor instance of the same agent, with no framing preamble. The full mechanism is three layers:\n\n- a\n**standing base-prompt instruction**(ships in`models.json`\n\nfor gpt-5.5/5.6: \"When you run out of context, the conversation is automatically summarized for you… Do not restart from scratch; you continue naturally…\") that teaches every session what a compaction summary means before one ever happens; - a\n**trained**(alongside Harmony's analysis/commentary/final) that produces the successor-notes when triggered;`summary`\n\noutput channel **client-side conventions**: verbatim user-message retention, fresh instruction re-injection, blob-always-last ordering, mid-turn continuation.\n\nThis is why it works so much better than client-side summarization à la Pi: user intent is never paraphrased, instructions are never summarized, the summary itself is written in the register the model actually resumes from (notes-to-self, not exposition), and the model was both prompted and trained for the resume.\n\nOpenAI's analytics label for the strategy is `memento`\n\n(`CompactionStrategy::Memento`\n\n).\n\nOne security finding fell out of the probes: the blob's encryption is **not** access control. It was tampered-tested (rejected), thread-tested (accepted anywhere), model-tested (accepted across a `comp_hash`\n\nfamily), age-tested (a 50-day-old blob still decrypts), and finally **account**-tested — a second ChatGPT account decrypted a blob minted by the first and read back assistant-only content verbatim. So a `compaction`\n\nitem is a long-lived bearer token for the conversation state it summarizes. Details and severity in \"Cross-account replay: the key is fleet-wide\".\n\nAll in `codex-rs/core/`\n\n. Selection logic is in `session/turn.rs`\n\n(`run_auto_compact`\n\n) and `tasks/compact.rs`\n\n:\n\n| Implementation | Analytics tag | Transport | Condition |\n|---|---|---|---|\nRemote v2 (`compact_remote_v2.rs` ) |\n`responses_compaction_v2` |\nnormal `/responses` stream (WS or SSE) + `compaction_trigger` item |\nprovider is OpenAI/Azure-responses AND feature `remote_compaction_v2` (stage: Stable, default on) |\nRemote v1 (`compact_remote.rs` ) |\n`responses_compact` |\nunary `POST /responses/compact` |\nprovider is OpenAI/Azure but v2 feature off |\nLocal (`compact.rs` ) |\n`responses` |\nnormal `/responses` with a summarization prompt |\nall other providers (OSS, LM Studio, etc.) |\n\nThere is also a fourth, orthogonal mode gated by the `token_budget`\n\nfeature: no summarization at all — the session just starts a fresh context window (`compact_token_budget.rs`\n\n), optionally after prompting the model to write notes to disk first (`auto_compact_fallback_prompt`\n\n, buffer tokens on top of the limit for the note-taking turn).\n\n`compact_remote_v2.rs`\n\n+ `compact_remote_v2_attempt.rs`\n\n:\n\n- Before the request, oversized tool outputs are rewritten in place if the estimated history exceeds the context window: walking backwards from the end,\n`function_call_output`\n\n/`custom_tool_call_output`\n\n/`tool_search_output`\n\nbodies are replaced with`\"Output exceeded the available model context and was truncated\"`\n\nuntil the estimate fits (`trim_function_call_history_to_fit_context_window`\n\nin`compact_remote.rs`\n\n). - Request = full\n`Prompt`\n\n(base instructions, tool specs,`parallel_tool_calls`\n\n, full history) +`ResponseItem::CompactionTrigger {}`\n\nappended as the last input item. - Response must contain\n**exactly one**`compaction`\n\noutput item, otherwise the attempt is a hard error. Additional output items (e.g. an assistant message) are ignored. - Replacement history construction (\n`build_v2_compacted_history`\n\n):- keep only\n`user`\n\n/`developer`\n\n/`system`\n\nmessages from the old prompt input, then filter through`should_keep_compacted_history_item`\n\n(drops`developer`\n\nmessages and non-user-content`user`\n\nwrappers; keeps real user messages and hook prompts); - truncate retained messages to a\n**64k-token budget, newest first**(`RETAINED_MESSAGE_TOKEN_BUDGET = 64_000`\n\n, mirroring the server-side default of`/responses/compact`\n\n); images count ≥1 token and are kept if budget remains; - append the new\n`compaction`\n\nitem last.\n\n- keep only\n- Then\n`process_compacted_history`\n\nre-injects**fresh** canonical initial context (developer instructions, AGENTS.md, environment context, world state) — before the last real user message for mid-turn compaction, or fully re-injected on the next turn for pre-turn/manual compaction. Stale developer messages from before compaction never survive. - Stream retries are capped at 2 (vs. the normal budget) because compaction requests run long.\n\nFor **mid-turn** auto-compaction the model's context afterwards is, in order:\n\n- Base instructions (separate top-level\n`instructions`\n\nfield of the Responses API — always first, not part of the input array) - All older user messages, verbatim\n- The re-injected fresh setup block: developer messages, then AGENTS.md + environment context (the latter two are user-role messages carrying instruction content)\n- The last real user message\n- The\n`compaction`\n\nitem — always the very last item\n\n```\nBEFORE (mid-task, context full)                AFTER mid-turn compaction\n───────────────────────────────                ─────────────────────────────────\n┌─────────────────────────────┐                ┌─────────────────────────────┐\n│ developer instructions      │                │ user msg 1        (verbatim)│\n│ AGENTS.md / environment     │                │ user msg 2        (verbatim)│\n├─────────────────────────────┤                ├─────────────────────────────┤\n│ user msg 1                  │   fresh   ───▶ │ developer instructions      │\n│   assistant + tool calls    │   context      │ AGENTS.md / environment     │\n│   reasoning ...        (170k│   spliced      ├─────────────────────────────┤\n│ user msg 2                  │   in HERE      │ user msg N (last real one)  │\n│   assistant + tool calls    │                ├─────────────────────────────┤\n│   reasoning ...             │                │ ▓▓ compaction blob ▓▓       │\n│ user msg N                  │                │ (encrypted, ~2k tokens)     │\n│   assistant + tool calls ...│                └─────────────────────────────┘\n└─────────────────────────────┘                              ▲\n                                                MUST be the last item.\n                                                Model resumes the task from here.\n\nWRONG (what the compact.rs:64 comment forbids)\n──────────────────────────────────────────────\n┌─────────────────────────────┐\n│ user msgs ...               │\n│ ▓▓ compaction blob ▓▓       │\n│ developer instructions  ◀── │  anything after the blob = a layout\n└─────────────────────────────┘  the model never saw in training\n```\n\nThe insertion rule (`insert_initial_context_before_last_real_user_or_summary`\n\nin `compact.rs`\n\n) is \"splice initial context immediately before the *last* real user message\"; retained older user messages simply stay above it. The apparent logic: the blob must end the history, and the freshest instructions plus the in-flight task sit right next to it, while older user messages are kept for reference at the top, out of the way. The old assistant/tool/reasoning bulk disappears entirely into the blob.\n\nExample shape from a real compaction (226,661 -> 19,537 tokens). Message text is replaced with placeholders; the roles, order and counts are exactly as observed:\n\n```\n 0  user       <session info message>\n 1  user       <short user message>\n 2  user       <user message>\n 3  user       <user message>\n 4  user       <user message>\n 5  user       <user message>\n 6  user       <user message>\n 7  user       <user message>\n 8  developer  '<permissions instructions>...'\n 9  developer  'You are `/root`, the primary agent...'\n10  developer  '<multi_agent_mode>...'\n11  user       'AGENTS.md instructions + <environment_context>'\n12  user       <last real user message>\n13  compaction (encrypted, 18 KB)\n```\n\nEight real user messages spanning the whole session survive verbatim at the top, then three freshly re-injected developer blocks, then the AGENTS/environment block, then the last user message, then the blob.\n\nThe context exposed to the assistant in this conversation had the following chronological order:\n\n- System-level API behavior\n- Developer tool definitions\n- Developer rules for the Codex agent\n- Retained historical user messages\n- A later developer block with permissions and collaboration rules\n- A user-role block containing\n`AGENTS.md`\n\nand environment context - The last user task before the handoff\n- A plaintext\n`assistant/summary`\n\nhandoff - Assistant commentary, tool calls, tool results, and the final answer produced after that handoff\n- A refreshed user-role\n`AGENTS.md`\n\nand environment block - Refreshed developer rules, including the active skill list and delegation policy\n- The newest user messages\n\nThis is the reconstructed, model-visible conversation order, not the raw Responses API input array. In particular, the plaintext `assistant/summary`\n\nis how the resumed task state was presented in this environment. It should not be confused with the opaque `compaction`\n\nitem used by remote compaction v2 on the wire.\n\nThe effective instruction priority remains **system > developer > user > assistant**, regardless of chronological order. Tool results provide evidence and state; they do not add instructions. Within one priority level, a newer instruction can replace an older one when it says so explicitly, as the refreshed `AGENTS.md`\n\ndid here.\n\nThis conversation compacted again while this note was being updated. After resumption, the model received a new plaintext `assistant/summary`\n\ncontaining the active task, repository state, relevant rules, completed checks, and next actions. That observation describes the context supplied to the model after compaction; it does not reveal how the server stores or transports the underlying compacted state.\n\nThe compacted Codex session above (gpt-5.6-sol) was interrogated directly about the handoff in its own context. Its self-report is corroborated on one key point (below), so the rest carries real weight. Findings:\n\n**Presentation.** The handoff is an **assistant-role message on a dedicated summary channel**. The model reports four assistant channels configured in its context —\n\n`analysis`\n\n(private reasoning), `commentary`\n\n(user-visible progress + tool calls), `final`\n\n(answers), and `summary`\n\n(used exactly once: the compaction handoff). No XML-style wrapper, no tag; the summary designation is message metadata. This matches Harmony-style channels (gpt-oss uses analysis/commentary/final) plus a compaction-specific fourth channel.**No framing preamble.** The handoff does not begin with \"another model produced this summary\" or \"context was compacted.\" It starts mid-thought:\n\nWe must continue current user request: \"<live user request, quoted verbatim>\" referring to .\n\nand ends with a self-directive:\n\nNeed send a commentary update now since compaction happened and instructions say continue naturally; mention that the compaction itself gave another observation and we're recording it carefully.\n\n**Format.** Free-form Markdown-ish operational notes, ~1,000 words (≈1.3k tokens; consistent with the 8–18 KB encrypted blobs). No schema — but recurring section labels observed verbatim, in order: `Current governing user AGENTS replaces all previous AGENTS:`\n\n, `What I told user:`\n\n, `Repo discovery:`\n\n, `Recommended next action:`\n\n, plus nested labels like `Real example from a separate compaction:`\n\nand `Important nuance:`\n\n. Voice is mixed first-person (\"We must continue\", \"What I told user\", terse subjectless directives like \"Use apply_patch\") — it reads as **notes to a successor instance of the same agent**, not an explanation for a human.\n\n**The trigger, as the model sees it.** The handoff quotes the compaction request it received as:\n\nContext compaction triggered. Summarize the current context.\n\nSo the wire item `{\"type\":\"compaction_trigger\"}`\n\nis apparently rendered server-side as that plain instruction. (Second-hand: quoted inside the handoff, not visible as a live message.)\n\n**One handoff, ever.** Despite this session compacting multiple times, only one `summary`\n\n-channel message exists in context. Each compaction folds the previous handoff into the new one.\n\n**User messages verbatim.** Older user messages appear as separate user-role messages with original typos and phrasing preserved — matching the client-side retention behavior exactly.\n\n**The corroborated part — a standing compaction instruction in the base prompt.** The model quoted a developer instruction about compaction handling; the identical text ships client-side in `models-manager/models.json`\n\n(base instructions for gpt-5.5 / gpt-5.6-sol / -luna / -terra), which independently verifies the quote:\n\nWhen you run out of context, the conversation is automatically summarized for you, but you will see all prior user requests. Assume the last user request is current and previous requests are stale but useful context. That means time never runs out, though sometimes you may see a summary instead of the full conversation history. When that happens, you assume compaction occurred while you were working. Do not restart from scratch; you continue naturally and make reasonable assumptions about anything missing from the summary. Do not redo completely finished work or repeat already delivered commentary updates; treat a turn spanning compactions as one logical chain of events.\n\nSo the full mechanism is three-layered: (1) a standing base-prompt instruction teaching the model what compaction means and how to resume; (2) a trained `summary`\n\noutput channel producing successor-notes when triggered; (3) client-side conventions (verbatim user messages, fresh context re-injection, blob-last ordering). Usual caveat: apart from the base-prompt quote, this is model self-report and could contain confabulated details, but its specifics match every independently verified wire-side fact (single item, size, verbatim user retention, ordering).\n\nThe same session was asked to reproduce its `summary`\n\n-channel handoff in full. It did (~900 words; complete text saved at `codex-compaction-capture/verbatim-handoff-2026-08-03.txt`\n\n). Opening and closing lines:\n\n```\nWe must continue current user request: \"<the live user request, quoted verbatim>\"\nreferring to <what it refers to>.\n...\nNeed send a commentary update now since compaction happened and instructions say continue\nnaturally; mention <the specific thing to say>.\n```\n\n(Opening and closing lines, with the task-specific content replaced. The shape is the point: it opens on the live request and closes on an immediate self-directive.)\n\nSection structure as produced (not a fixed schema — these labels emerged from the task):\n\n- Opening line: the live user request, quoted verbatim, with what it refers to\n`Current governing user AGENTS replaces all previous AGENTS:`\n\n— 10 bullets of active rules (conventional commits,`[skip ci]`\n\n, no GitHub connector, use`apply_patch`\n\n, cwd + date, which skills were read)`What I told user:`\n\n— what has already been said to the user, so it is not repeated`Repo discovery:`\n\n— concrete state: repo path, remote URL, branch, exact commit SHA, dirty files to preserve, recent commit subjects, and the exact announcement text already delivered to the user- A prose block on file contents already read, with a nested\n`Real example from a separate compaction:`\n\nand`Important nuance:`\n\n(a caution against overclaiming) `Recommended next action:`\n\n— a numbered 10-step plan, right down to the commit message string, the push target, and the URL format required by AGENTS- Closing line: an immediate self-directive about what to do first on resume\n\nNotable properties for anyone copying the format:\n\n**Environment state is carried, not just narrative.** Git SHA, dirty-file list, remote URL, absolute paths, current date. This is the part a \"summarize the conversation\" prompt reliably loses.**Delivered-output tracking.**\"What I told user\" and the quoted announcement exist so the resumed agent does not repeat commentary — directly serving the base-prompt rule \"do not repeat already delivered commentary updates.\"**An executable plan, not a description.** Over half the handoff is the next-actions list, specified to the exact command and commit string.**Self-cautions survive.** The`Important nuance:`\n\nblock preserves an epistemic caveat the agent had reasoned itself into, which a naive summarizer would drop as non-essential.**No preamble, no sign-off.** It begins and ends mid-work. The register is notes-to-self, not exposition.\n\nStatistics over all **1,087** compaction records in the local sessions directory plus two controlled captures:\n\n| Measurement | Value |\n|---|---|\n| Blob size (encrypted chars) | min 2,252 · median 15,820 · p90 23,672 · max 64,420 |\n| Blob on a near-empty session (controlled) | 1,080 chars (7 input items, 20.7k tokens) |\n| Blob on a 174k-token session (controlled) | 8,140 chars |\n| Compaction turn wall time | 6.0s (tiny session) · 33.7s (174k session) |\n| Context after compaction | 4,552 tokens (tiny) · 3,368 tokens (174k) |\n\nWhat the blob size does **not** track: pre-compaction output tokens (r = 0.10), accumulated reasoning bytes since the last compaction (r = 0.01), reasoning item count (r = −0.15), or total items since the last compaction (r = −0.18). Blob size appears to track *how much live state is worth carrying* (open threads, plan complexity), not raw transcript volume — consistent with a written summary rather than a mechanical encoding of history.\n\nSuccessive compactions do **not** accumulate: median blob-size change between consecutive windows is **−168 chars** (n = 949, 52% negative), and median size by window number stays flat around 16–21k from window 1 through 8. Each compaction rewrites the previous handoff rather than appending to it, so a 12-times-compacted thread carries no more compaction weight than a once-compacted one.\n\nBlob internals visible from outside: Fernet v0x80 token, whose 8-byte timestamp field decodes to the compaction moment (median offset from the rollout record timestamp: **−0.8s**), confirming the blob is minted server-side at request time rather than assembled from cached pieces.\n\nTwo more wire facts from the captures:\n\n- Reasoning items in the request carry their own\n`encrypted_content`\n\n(52 items, 92 KB total in the 174k capture). The compaction reply (8 KB) is far smaller, so it is not a repackaging of retained reasoning state. - The compaction turn's true token usage is\n**not surfaced to app-server clients**(`thread/tokenUsage/updated`\n\nreports zeros with only the recomputed context total). It goes to OpenAI's analytics as`compaction_summary_tokens`\n\n. The turn is billed but effectively invisible in the client's usage view.\n\nKangwook Lee published the original reverse-engineering of this ([X article](https://x.com/Kangwook_Lee/article/2028955292025962534), 2026-03-04), with secondary write-ups by [ShroomDog](https://gu-log.vercel.app/en/posts/en-gp-103-20260304-kangwook-lee-codex-prompt-injection-context-compaction-api/), [Tony Lee](https://tonylee.im/en/blog/codex-compaction-encrypted-summary-session-handover/) and [Justin3go](https://justin3go.com/en/posts/2026/04/09-context-compaction-in-codex-claude-code-and-opencode). Lee's method — prompt-inject the compactor, then replay the blob and ask the model to echo its context — established what no amount of client reading could: the remote path really does run an LLM with its own compaction prompt, plus a **handoff prompt prepended to the summary**, both closely matching the open-source `prompt.md`\n\n/ `summary_prefix.md`\n\n.\n\nThat describes the **v1** path (`POST /responses/compact`\n\n). Two things have changed:\n\n**The default is now v2**, which does not use that endpoint at all (normal Responses request +`compaction_trigger`\n\nitem). OpenAI's own[compaction guide](https://developers.openai.com/api/docs/guides/compaction)documents both modes: the standalone endpoint is \"fully stateless and ZDR-friendly\", while in server-side mode \"no separate`/responses/compact`\n\ncall is required … The response stream includes the encrypted compaction item.\" It also exposes a`compact_threshold`\n\nrequest parameter, so the server-side trigger is a first-class API feature, not only a client policy.**v2 has no handoff prefix.** Tested 2026-08-03 by minting a session whose user message instructed any future summary to quote whatever text precedes it and to answer`NO-PREFIX`\n\nif there is none. After compaction and resume, the model quoted the retained user message — i.e. the item immediately before the compaction item in the wire history — and reported: \"The summary is presented as an assistant message on the`summary`\n\nchannel.\" No`summary_prefix.md`\n\n-style framing appeared. Two independent sessions agree, and the reported ordering matches the wire history exactly.\n\nSo the framing moved out of a prompt and into the model's channel structure: v1 wrapped the summary in a user-role message with an explanatory prefix; v2 delivers it as an assistant-role message on a trained `summary`\n\nchannel with no prefix at all. That is a plausible reason for v2 to exist, and it means secondary articles describing the handoff prompt are describing the older generation.\n\nThe docs are silent on the two properties that turned out to matter most — retention/expiry and account scoping — both of which the probes below settle empirically.\n\nSix controlled probes against doctored **copies** of rollouts, resumed via the experimental `thread/resume`\n\n`path`\n\nparameter so nothing was written into the local sessions directory (source checksums verified unchanged; the five probe rows registered in `state_5.sqlite`\n\nwere removed afterwards). Probe recipe is described below.\n\n| Probe | Change to the copy | Result |\n|---|---|---|\n| Tamper | one ciphertext char flipped | rejected — HTTP 400 `invalid_encrypted_content` |\n| Cross-thread | intact blob, brand-new thread + session ID | accepted, content recovered |\nCross-model, same `comp_hash` |\nresumed under `gpt-5.6-terra` (blob minted by `gpt-5.6-sol` ) |\naccepted directly, content recovered |\nCross-model, different `comp_hash` |\nresumed under `gpt-5.5` (hash 2911 vs 3000) |\nclient re-compacts with `gpt-5.6-sol` first, then proceeds; content survives |\n| Cross-account | resumed under a different ChatGPT account | accepted, content recovered — key is fleet-wide |\n| Age | 50-day-old blob | accepted, no TTL |\n\nRun on 2026-08-03 across two genuinely distinct ChatGPT accounts on the same machine (different emails, different `chatgpt_user_id`\n\n; verified by decoding the `id_token`\n\nclaims in each `CODEX_HOME/auth.json`\n\n).\n\nSetup: account A ran a throwaway session in which the model invented `NUM=583147 VEG=artichoke`\n\nand stated it in an **assistant** message, then compacted. Post-compaction history = the instruction user message + a 1,080-char blob; the values appear nowhere in plaintext. Positive control: account A resumes that history under a fresh thread ID and answers correctly.\n\nResult: account B (a different ChatGPT user, verified by comparing `chatgpt_user_id`\n\nand `email`\n\nclaims), resuming the same two-item history under a fresh root thread ID, answered:\n\n```\nNUM=583147 VEG=artichoke\n```\n\nin 9.6s, with no error. So **the compaction blob is decryptable by any Codex account, not just the one that minted it.** Combined with the longevity probe below (a 50-day-old blob still decrypts), a compaction item is effectively a long-lived bearer token for the conversation state it summarizes.\n\nSeverity, stated honestly: in the normal case this changes little, because a rollout file that contains a blob also contains the full plaintext transcript — an attacker holding the file already has everything. What makes it worth writing down is the framing: OpenAI's own guide calls the item \"opaque and not intended to be human-interpretable\", which reads as \"safe to pass around\". It isn't — it is readable by anyone with any Codex account, indefinitely. That matters when a blob travels **without** its transcript, which is not hypothetical:\n\n- a thread exported or synced after compaction, where pre-compaction records were pruned;\n`thread/resume`\n\nwith a client-supplied`history`\n\n(the documented Codex Cloud path), where only replacement history moves;- a blob pasted into a bug report, support ticket, or issue as \"opaque encrypted data\";\n- machine-to-machine session transfer that carries only current history.\n\nIn those cases the blob looks like ciphertext but is readable by anyone with any Codex account. Worth reporting upstream; binding the blob to the minting account (or at least to an org) would close it. No third-party data was involved in this test — both accounts belong to the same person, and the payload was a randomly invented number and vegetable.\n\nA sixth probe tested **blob longevity**: a compaction blob minted on **2026-06-14** (22,052 chars, 50 days old, produced by whatever model was current then) was extracted into a minimal two-record rollout, given a fresh thread ID, and resumed on 2026-08-03. The server accepted it — the turn completed normally in 9.9s with no re-compaction and no error. So blobs carry no short TTL and survive at least seven weeks of key/model churn, which is what makes resuming genuinely old threads work.\n\n**Tamper rejection** is explicit and specific:\n\n```\ninvalid_request_error / invalid_encrypted_content:\n\"The encrypted content for item cmp_<id> could not be verified.\n Reason: Encrypted content could not be decrypted or parsed.\"\n```\n\n**Blobs are not thread-bound.** The clean version of this test used a session whose secret existed *only* in an assistant message: the model was asked to invent `NUMBER=<n> FRUIT=<f>`\n\n, answered `NUMBER=583194 FRUIT=papaya`\n\n, and was then compacted (retained history = the instruction user message + a 1,720-char blob; the values themselves appear nowhere in it). Resumed under a foreign thread ID, the model answered `NUMBER=583194 FRUIT=papaya`\n\nexactly. The captured outgoing request confirms the values were **absent from the request plaintext** — the only possible source was the blob. So the blob genuinely carries assistant-side content, decrypts under any thread ID, and is not bound per-thread. (The cross-account section above shows it is not bound per-account either.)\n\n**The comp_hash mechanism works as the code claims.** Resuming a sol-minted blob under\n\n`gpt-5.5`\n\nproduced a visible extra request in the trace: a compaction call **to** carrying\n\n`gpt-5.6-sol`\n\n`[message, compaction, compaction_trigger]`\n\n, returning a fresh 1,700-char blob, followed by the real turn to `gpt-5.5`\n\nusing that new blob. Under `gpt-5.6-terra`\n\n(same hash family) no re-compaction happened at all. This also confirms **rewrite-don't-append at the wire level**: the old blob is fed back in as input and a single new blob comes out, which is why blob size stays flat across compactions.\n\n**Pre-turn/manual ordering confirmed empirically.** The resumed request's input array was: `additional_tools`\n\n, developer message, retained user message, **compaction blob**, then the fresh developer/AGENTS/environment block, then the new user message. So outside mid-turn compaction the blob is not last — it sits directly after the retained user messages, with fresh context and the new turn following it.\n\n**Pre-turn and manual** compaction differ: nothing is injected into the replacement history (it ends up as just retained user messages + blob, as in the live capture below), and the fresh initial context is re-injected normally at the start of the next turn instead — so there the order becomes: retained user messages, blob, fresh context, new user message.\n\n`compact_remote.rs`\n\n+ `codex-api/src/endpoint/compact.rs`\n\n. A **unary** (non-streaming) `POST {base_url}/responses/compact`\n\nwith basically the same body as a normal Responses request (`model`\n\n, `input`\n\n, `instructions`\n\n, `tools`\n\n, `parallel_tool_calls`\n\n, `reasoning`\n\n, `service_tier`\n\n, `prompt_cache_key`\n\n, `text`\n\n). Response body is `{\"output\": [ResponseItem, ...]}`\n\n— the server returns the whole compacted transcript, which the client filters and installs. Because the call is unary with no streaming heartbeat, the client allows 4× the normal stream idle timeout for it, and a `x-codex-turn-state`\n\nresponse header is captured for turn-scoped continuity. This is the older path (\"the server-side path remains the reference implementation\" per a code comment) and where the `compaction`\n\nitem + retained-message conventions come from.\n\n`compact.rs`\n\n: a normal turn whose user message is a fixed summarization prompt (`prompts/templates/compact/prompt.md`\n\n):\n\n\"You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. Include: current progress and key decisions … Be concise, structured, and focused on helping the next LLM seamlessly continue the work.\"\n\nNew history = retained user messages (20k-token budget here, not 64k) + a user message of the form `SUMMARY_PREFIX + \"\\n\" + last assistant message`\n\n, where the prefix (`summary_prefix.md`\n\n) tells the model \"another language model started to solve this problem and produced a summary of its thinking process…\". If the compaction request itself overflows the context window, it drops the oldest history item and retries in a loop.\n\n`session/context_window.rs`\n\n+ `session/turn.rs`\n\n:\n\n- The auto-compact limit defaults to\n**90% of the resolved context window**(`auto_compact_token_limit()`\n\nin`protocol/src/openai_models.rs`\n\n), and the resolved window is`effective_context_window_percent`\n\n(95% for the fallback metadata) of the raw window. Config`model_auto_compact_token_limit`\n\ncan only lower it. **Pre-turn**(`run_pre_sampling_compact`\n\n): before sampling, if the limit is already reached — also when switching to a model with a smaller context window (`ModelDownshift`\n\n) or when the model's \"compaction compatibility hash\" changed (`CompHashChanged`\n\n, compaction is then run against the*previous*model, with fallback to the current one).**Mid-turn**: after every sampling step, if the model still needs a follow-up (tool results pending, queued input) and the token limit is reached, compaction runs*inside*the turn and the loop continues. This is why Codex never dies mid-task: the compaction item lands last in history and the model resumes from it.**Manual**`/compact`\n\nruns the same remote path as a standalone turn.- Reasons tracked:\n`user_requested`\n\n,`context_limit`\n\n,`model_downshift`\n\n,`comp_hash_changed`\n\n; phases:`pre_turn`\n\n,`mid_turn`\n\n,`standalone_turn`\n\n. These are sent to the server in the`x-codex-turn-metadata`\n\nclient metadata (`{trigger, reason, implementation, phase, strategy: \"memento\"}`\n\n) along with`x-codex-window-id`\n\n; each compaction advances a window counter with fresh window IDs.\n\nMethod: `CODEX_ROLLOUT_TRACE_ROOT=<dir> codex app-server`\n\n(built-in trace bundle recorder — no proxy needed), then JSON-RPC `initialize`\n\n→ `thread/resume`\n\n→ `thread/compact/start`\n\non thread `<thread-id>`\n\n(a code-review session sitting at 174,871 tokens). Codex CLI 0.146.0, auth = ChatGPT, transport = Responses-over-WebSocket.\n\nRecorded request (trace payload `4.json`\n\n, 876 KB):\n\n`model: \"gpt-5.6-sol\"`\n\n, same 7.6 KB base instructions as normal turns,`parallel_tool_calls: true`\n\n`input`\n\n: 209 items — 76`custom_tool_call`\n\n+ 76`custom_tool_call_output`\n\n+ 52`reasoning`\n\n+ 4`message`\n\n+**1**`compaction_trigger`\n\n(last)\n\nRecorded response (the response payload): exactly one item:\n\n```\n{\"output_items\": [{\"type\": \"compaction\",\n  \"id\": \"cmp_0f4c4daa0cdbdfc1016a6f4183b27c8191a55021e034a750d9\",\n  \"encrypted_content\": \"gAAAAABqbv...\" }]}\n```\n\n`encrypted_content`\n\nis 8,140 chars, `gAAAAAB`\n\nprefix = Fernet token (version 0x80 + timestamp + IV + ciphertext + HMAC) — server-side encrypted, opaque to the client.\n\nInstalled checkpoint (the checkpoint payload): 208-item input history replaced by **2 items** — the original user request message + the compaction item. Token usage on the next turn: **174,871 → 3,368 tokens**. Whole compaction turn: 33.7s.\n\nCorroboration from existing local history: all **1,087** compaction records in the local sessions directory rollouts are v2-shaped (retained user messages + `compaction`\n\nitem; zero local-prompt summaries). A larger example from 2026-08-01: 226,661 → 19,537 tokens, with 13 retained items (8 real user messages, 3 re-injected developer messages, AGENTS.md/environment context, then an 18 KB encrypted compaction item).\n\nPi (`@earendil-works/pi-coding-agent`\n\n0.83.0) is a useful contrast because it does the same job entirely client-side, and its implementation is readable. From `core/compaction/compaction.js`\n\n, `core/compaction/utils.js`\n\nand `core/messages.js`\n\n:\n\n- Trigger:\n`contextTokens > contextWindow - reserveTokens`\n\n, default`reserveTokens`\n\n16384;`keepRecentTokens`\n\ndefault 20000. - Cut point: walk backwards from the newest entry accumulating token estimates until\n`keepRecentTokens`\n\nis reached. Valid cut points are user, assistant, bash-execution and custom messages — never tool results, which must stay with their call. A single turn larger than the budget becomes a \"split turn\", summarized as two summaries that are then merged. - Summary prompt: a dedicated summarizer persona —\n`\"You are a context summarization assistant. … Do NOT continue the conversation. … ONLY output the structured summary.\"`\n\n- Output: a fixed Markdown template —\n`## Goal`\n\n,`## Constraints & Preferences`\n\n,`## Progress`\n\n(Done / In Progress / Blocked),`## Key Decisions`\n\n,`## Next Steps`\n\n,`## Critical Context`\n\n, plus`<read-files>`\n\nand`<modified-files>`\n\nblocks. - Injection: the summary is inserted as a\n**user-role** message prefixed`\"The conversation history before this point was compacted into the following summary:\\n\\n<summary>\\n\"`\n\n. - Repeated compaction: the previous summary is passed back in\n`<previous-summary>`\n\ntags with an update prompt (\"PRESERVE all existing information … UPDATE the Progress section … \"), and the summarized span restarts from the previous compaction's kept boundary. Functionally the same rewrite-don't-append behavior as Codex. - Serialization: messages are flattened to\n`[User]: / [Assistant]: / [Assistant tool calls]: / [Tool result]:`\n\ntext so the model doesn't continue the conversation, with tool results truncated to 2000 characters**inside the summarization prompt only**. - Compaction requests use fresh routing session IDs and disable prompt-cache writes, same reasoning as Codex.\n- Extensions can replace the whole thing:\n`session_before_compact`\n\nreceives the prepared messages and may return its own`summary`\n\nand`firstKeptEntryId`\n\n, with`reason`\n\nin`manual`\n\n/`threshold`\n\n/`overflow`\n\n.\n\n| Dimension | Codex remote v2 | Pi 0.83 |\n|---|---|---|\n| Survives verbatim | every user message, any age, 64k budget | a recency window of all message types, 20k budget |\n| Summary voice | assistant-role, first person, no framing | third-party summarizer, user-role message with an explanatory prefix |\n| Summary shape | free-form notes plus an executable plan | fixed Markdown template |\n| Environment state | git SHA, branch, dirty files, cwd, date | read / modified file lists |\n| Tracks what was already said to the user | yes | no |\n| Standing briefing in the system prompt | yes | no, only a per-message prefix |\n| Repeated compaction | rewrite (old blob in, new blob out) | rewrite (previous summary in, updated summary out) |\n| Mid-turn behavior | compacts between sampling steps, continues | aborts on overflow, compacts, retries the turn |\n| Oversized tool output | rewritten in the live history to reclaim window | truncated only inside the summarization prompt |\n| Summary storage | encrypted, server-side, opaque | plaintext in the session file |\n\nPi's design wins on two axes worth naming: the summary is human-readable and auditable, and it works against any provider. Codex's advantages are the ones listed below.\n\n**The model is trained for it — and pre-briefed for it.** Compaction is a first-class model behavior (`compaction_trigger`\n\nin,`compaction`\n\nitem out), not an instruction-following task bolted on at temperature. Comments in the code state the model is trained to expect the compaction item as the last history item mid-turn (see evidence below). On top of the training, the base prompt of every session carries a standing instruction explaining what a compaction summary is and how to resume from it (\"time never runs out… continue naturally… do not redo completely finished work\"), so the model is never surprised by a summary appearing in place of history. The instruction and the summary's notes-to-successor register are portable to any client; the trained channel is not.**The summary is written in the register the model resumes from.** The decrypted handoff is notes-to-successor: no preamble, no exposition, carrying concrete environment state (git SHA, dirty files, absolute paths), a record of what was already said to the user, and an executable next-actions plan down to the commit string. A generic \"summarize the conversation\" prompt produces narrative prose instead, which is exactly the wrong shape for resuming work. Also portable: the summary format itself is the copyable part, even without provider support.**Server-side encryption keeps the format free to change.** The blob is opaque to clients, so OpenAI can retrain the summary format without breaking anyone. It also blocks tampering with the most trusted item in the context.**User messages survive verbatim**(up to 64k tokens, newest first). The model never loses the actual instructions it was given; only assistant/tool bulk is folded away.**Instructions and environment are re-injected fresh** after compaction rather than summarized, so no drift in developer/AGENTS context.**Mid-turn continuation.** Compaction runs inside the agentic loop between sampling steps, so a long task continues seamlessly instead of ending with \"context full\".**Defense in depth before compaction ever runs**: oversized tool outputs are truncated in place first, which alone often reclaims most of the window without touching semantics the model still needs.**Compactions do not accumulate.** Each one rewrites the previous handoff instead of stacking summaries, so blob size stays flat (median −168 chars per additional compaction) no matter how many times a thread rolls over.\n\nPortable to any client today: the base-prompt briefing (1), the notes-to-successor summary format (2), verbatim user retention (4), fresh instruction re-injection (5), mid-turn compaction (6), pre-truncation of tool outputs (7), and rewrite-don't-append (8). Only the trained output channel and server-side encryption (1, 3) need provider support. In other words, most of the advantage is reproducible client-side — Pi's gap is mostly format and policy, not model access.\n\nObserved (verifiable from client side):\n\n- The compaction request asks for the\n**session's own model**(`gpt-5.6-sol`\n\nin the capture), with the same instructions and tools as normal turns. There is no separate compaction model slug anywhere in the client. - Compaction blobs are\n**model-coupled**: each model carries a \"compaction compatibility hash\" (`comp_hash`\n\n), and when the hash changes or the user switches models, Codex re-compacts with the*previous*model first (`session/turn.rs`\n\n,`maybe_run_previous_model_inline_compact`\n\n). A blob from one model is not assumed valid for another. - The training claim comes from OpenAI's own source comments, not speculation:\n`core/src/compact.rs:64-66`\n\n: \"Mid-turn compaction must use`BeforeLastUserMessage`\n\nbecause**the model is trained to see the compaction summary as the last item in history** after mid-turn compaction.\"`core/src/compact_remote.rs:332`\n\n: \"`assistant`\n\nmessages (**future remote compaction models** may emit them)\" — implying today's compaction is done by the regular model, with dedicated compaction models a possible future direction.- The wire type\n`compaction`\n\nhas serde alias`compaction_summary`\n\n(`protocol/src/models.rs`\n\n), indicating the blob started life as a summary.\n\nInferred (cannot be verified from the client):\n\n-\nWhether the server actually routes the request to the requested model or to something else — the client only expresses a preference.\n\n-\n~~Whether the server validates blob provenance.~~**Resolved 2026-08-03 by direct probe**(see \"Blob validation probes\"): tampering is rejected with`invalid_encrypted_content`\n\n; a valid blob is accepted under any thread ID and across models sharing a`comp_hash`\n\n. -\n**Cross-account replay.****Resolved 2026-08-03: the key is fleet-wide.** A second ChatGPT account decrypted a blob minted by the first and recovered assistant-only content verbatim. See \"Cross-account replay\" below. Earlier attempts failed for reasons unrelated to the API, and the failures are instructive:-\nRun executed under the\n\n*minting*account by mistake. Same-account success proves nothing. -\nRun executed as the second OS user, but the shared work directory was owned by the first user, so the probe could not start and the verdict logic grepped a\n\n**stale log from attempt 1**, reporting a false positive. -\nSecond account's refresh token had expired (\n\n`Your access token could not be refreshed`\n\n); re-authenticating silently reused the browser's signed-in session and landed on the*same*ChatGPT account, detected by fingerprint guard. -\nProbe pointed at a\n\n**sub-agent** rollout (picked by`ls -t`\n\n, whose`session_id != id`\n\n): app-server refused input with \"direct app-server input is not allowed for multi-agent v2 sub-agents\", and that blob did not contain the test secret anyway.\n\nMethod lessons for anyone repeating this: pin identity on\n\n**stable** claims (`sha256(chatgpt_user_id)`\n\nand`sha256(email)`\n\n) —`tokens.account_id`\n\nwas observed to vary and produced two inverted conclusions; write every artifact into a`mktemp -d`\n\nowned by the running user; use a**root** session (`id == session_id`\n\n, no`parent_thread_id`\n\n); verify a**positive control** on the minting account first; and derive the verdict only from the current run's log, reporting`INCONCLUSIVE`\n\notherwise. -\n-\nWhat is inside\n\n`encrypted_content`\n\n. \"It is a summary\" follows from the alias, the output-token accounting (`compaction_summary_tokens`\n\n= the turn's output tokens), and the size (~8 KB encrypted ≈ ~2k tokens); but the Responses API already uses`encrypted_content`\n\nto carry hidden reasoning state, so the blob may hold model-native content beyond prose.**Update 2026-08-03:** model introspection (see \"What the decrypted handoff looks like\") indicates the decrypted form presented to the model is ordinary prose — ~1,000 words of successor-notes on a`summary`\n\nchannel. Whether the blob additionally carries non-prose state the model can't report on remains unknowable from outside.\n\n`codex-rs/core/src/compact.rs`\n\n— local implementation, shared history-rebuild helpers, injection rules`codex-rs/core/src/compact_remote.rs`\n\n,`compact_remote_request.rs`\n\n—`/responses/compact`\n\n(v1), pre-trim, output filtering`codex-rs/core/src/compact_remote_v2.rs`\n\n,`compact_remote_v2_attempt.rs`\n\n— v2, retained-message budget, single-item contract`codex-rs/core/src/compact_token_budget.rs`\n\n— fresh-window mode (token_budget feature)`codex-rs/core/src/session/turn.rs`\n\n— trigger logic (pre-turn / mid-turn), model-downshift and comp-hash triggers`codex-rs/core/src/session/context_window.rs`\n\n— threshold computation`codex-rs/core/src/compact_model_fallback.rs`\n\n— previous-model → current-model retry`codex-rs/codex-api/src/endpoint/compact.rs`\n\n— the compact endpoint client`codex-rs/protocol/src/models.rs`\n\n—`Compaction`\n\n/`CompactionTrigger`\n\n/`ContextCompaction`\n\nwire items`codex-rs/prompts/templates/compact/`\n\n— local summarization prompt + summary prefix`codex-rs/features/src/lib.rs`\n\n—`remote_compaction_v2`\n\n(Stable, default on)\n\nNo prompt injection is needed for any of the wire-level results. The client has a built-in trace recorder: set `CODEX_ROLLOUT_TRACE_ROOT`\n\nto a directory and every compaction writes `trace.jsonl`\n\nplus numbered payload files, containing the exact request (`compaction_request_started`\n\n), the exact response items (`compaction_request_completed`\n\n), and the installed replacement history (`compaction_installed`\n\n). Those three payloads are the whole picture.\n\nDrive it over the app-server JSON-RPC interface (newline-delimited JSON on stdin/stdout, `\"jsonrpc\"`\n\nomitted):\n\n``` php\nCODEX_ROLLOUT_TRACE_ROOT=/some/dir codex app-server\n\n-> {\"id\":1,\"method\":\"initialize\",\"params\":{\"clientInfo\":{\"name\":\"probe\",\"title\":\"probe\",\"version\":\"0.0.1\"},\n                                           \"capabilities\":{\"experimentalApi\":true}}}\n-> {\"id\":2,\"method\":\"thread/resume\",\"params\":{\"threadId\":\"<id>\"}}          # or {\"path\":\"<rollout.jsonl>\"}\n-> {\"id\":3,\"method\":\"thread/compact/start\",\"params\":{\"threadId\":\"<id>\"}}\n-> {\"id\":4,\"method\":\"turn/start\",\"params\":{\"threadId\":\"<id>\",\n                                           \"input\":[{\"type\":\"text\",\"text\":\"...\",\"textElements\":[]}]}}\n```\n\n`experimentalApi: true`\n\nunlocks the `path`\n\nparameter on `thread/resume`\n\n, which is what makes the validation probes safe: point it at a **copy** of a rollout in a scratch directory and nothing in the real sessions directory is touched. Resuming a copy does register a row in `state_5.sqlite`\n\n; `thread/delete`\n\nrefuses it when the rollout lives outside the sessions directory, so remove that row directly afterwards.\n\nFor the probes, doctor the copy before resuming: flip one character of `encrypted_content`\n\nto test tampering, rewrite the thread ID to test thread binding, run under a different `CODEX_HOME`\n\nto test account binding, or pass `-c model=<slug>`\n\nto test model binding. Use a **root** session (`id == session_id`\n\n, no `parent_thread_id`\n\n) — app-server rejects direct input to multi-agent sub-agents with \"direct app-server input is not allowed for multi-agent v2 sub-agents\".\n\nTo test whether the blob carries content the client cannot see, put the secret only in an *assistant* message (ask the model to invent a value and state it), compact, then confirm the value appears nowhere in the recorded request payload before asking for it back.", "url": "https://wpnews.pro/news/how-openai-codex-cli-context-compaction-actually-works-remote-compaction-v2-the", "canonical_source": "https://gist.github.com/osolmaz/a38acf6e522df67530e3ed47c80fdcd5", "published_at": "2026-08-03 16:42:06+00:00", "updated_at": "2026-08-13 14:52:36.804706+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models", "ai-safety"], "entities": ["OpenAI", "Codex CLI", "ChatGPT", "Harmony"], "alternates": {"html": "https://wpnews.pro/news/how-openai-codex-cli-context-compaction-actually-works-remote-compaction-v2-the", "markdown": "https://wpnews.pro/news/how-openai-codex-cli-context-compaction-actually-works-remote-compaction-v2-the.md", "text": "https://wpnews.pro/news/how-openai-codex-cli-context-compaction-actually-works-remote-compaction-v2-the.txt", "jsonld": "https://wpnews.pro/news/how-openai-codex-cli-context-compaction-actually-works-remote-compaction-v2-the.jsonld"}}