cd /news/developer-tools/how-openai-codex-cli-context-compact… · home topics developer-tools article
[ARTICLE · art-95411] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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…

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.

read33 min views2 publishedAug 3, 2026

Findings from reading the Codex source (<codex checkout>

, commit fa1d4c40d0e

, 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.

Codex compaction is not a client-side "summarize the transcript" prompt. The current default implementation ("remote compaction v2") works like this:

  • The client sends the normal Responses API request(same model, same instructions, same tools, full history) with one extra input item appended at the end:{"type": "compaction_trigger"}

. - The server/model returns exactly one output item:{"type": "compaction", "id": "cmp_...", "encrypted_content": "gAAAAAB..."}

. The content is an encrypted blob (Fernet token format) that the client cannot read. - The client rebuilds its history as: 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 opaquecompaction

itemlast. - On every subsequent request, the compaction

item is replayed as an input item. The server decrypts it and expands whatever state it holds into the model's context.

So 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

). The client keeps raw user messages and fresh instructions; only the assistant/tool-call bulk is folded into the encrypted state.

Model-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:

  • a standing base-prompt instruction(ships inmodels.json

for 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 trained(alongside Harmony's analysis/commentary/final) that produces the successor-notes when triggered;summary

output channel client-side conventions: verbatim user-message retention, fresh instruction re-injection, blob-always-last ordering, mid-turn continuation.

This 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.

OpenAI's analytics label for the strategy is memento

(CompactionStrategy::Memento

).

One 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

family), 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

item is a long-lived bearer token for the conversation state it summarizes. Details and severity in "Cross-account replay: the key is fleet-wide".

All in codex-rs/core/

. Selection logic is in session/turn.rs

(run_auto_compact

) and tasks/compact.rs

:

Implementation Analytics tag Transport Condition
Remote v2 (compact_remote_v2.rs )
responses_compaction_v2
normal /responses stream (WS or SSE) + compaction_trigger item
provider is OpenAI/Azure-responses AND feature remote_compaction_v2 (stage: Stable, default on)
Remote v1 (compact_remote.rs )
responses_compact
unary POST /responses/compact
provider is OpenAI/Azure but v2 feature off
Local (compact.rs )
responses
normal /responses with a summarization prompt
all other providers (OSS, LM Studio, etc.)

There is also a fourth, orthogonal mode gated by the token_budget

feature: no summarization at all — the session just starts a fresh context window (compact_token_budget.rs

), optionally after prompting the model to write notes to disk first (auto_compact_fallback_prompt

, buffer tokens on top of the limit for the note-taking turn).

compact_remote_v2.rs

  • compact_remote_v2_attempt.rs

:

  • Before the request, oversized tool outputs are rewritten in place if the estimated history exceeds the context window: walking backwards from the end, function_call_output

/custom_tool_call_output

/tool_search_output

bodies are replaced with"Output exceeded the available model context and was truncated"

until the estimate fits (trim_function_call_history_to_fit_context_window

incompact_remote.rs

). - Request = full Prompt

(base instructions, tool specs,parallel_tool_calls

, full history) +ResponseItem::CompactionTrigger {}

appended as the last input item. - Response must contain exactly onecompaction

output item, otherwise the attempt is a hard error. Additional output items (e.g. an assistant message) are ignored. - Replacement history construction ( build_v2_compacted_history

):- keep only user

/developer

/system

messages from the old prompt input, then filter throughshould_keep_compacted_history_item

(dropsdeveloper

messages and non-user-contentuser

wrappers; keeps real user messages and hook prompts); - truncate retained messages to a 64k-token budget, newest first(RETAINED_MESSAGE_TOKEN_BUDGET = 64_000

, mirroring the server-side default of/responses/compact

); images count ≥1 token and are kept if budget remains; - append the new compaction

item last.

  • keep only
  • Then process_compacted_history

re-injectsfresh 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.

For mid-turn auto-compaction the model's context afterwards is, in order:

  • Base instructions (separate top-level instructions

field of the Responses API — always first, not part of the input array) - All older user messages, verbatim

  • The re-injected fresh setup block: developer messages, then AGENTS.md + environment context (the latter two are user-role messages carrying instruction content)
  • The last real user message
  • The compaction

item — always the very last item

BEFORE (mid-task, context full)                AFTER mid-turn compaction
───────────────────────────────                ─────────────────────────────────
┌─────────────────────────────┐                ┌─────────────────────────────┐
│ developer instructions      │                │ user msg 1        (verbatim)│
│ AGENTS.md / environment     │                │ user msg 2        (verbatim)│
├─────────────────────────────┤                ├─────────────────────────────┤
│ user msg 1                  │   fresh   ───▶ │ developer instructions      │
│   assistant + tool calls    │   context      │ AGENTS.md / environment     │
│   reasoning ...        (170k│   spliced      ├─────────────────────────────┤
│ user msg 2                  │   in HERE      │ user msg N (last real one)  │
│   assistant + tool calls    │                ├─────────────────────────────┤
│   reasoning ...             │                │ ▓▓ compaction blob ▓▓       │
│ user msg N                  │                │ (encrypted, ~2k tokens)     │
│   assistant + tool calls ...│                └─────────────────────────────┘
└─────────────────────────────┘                              ▲
                                                MUST be the last item.
                                                Model resumes the task from here.

WRONG (what the compact.rs:64 comment forbids)
──────────────────────────────────────────────
┌─────────────────────────────┐
│ user msgs ...               │
│ ▓▓ compaction blob ▓▓       │
│ developer instructions  ◀── │  anything after the blob = a layout
└─────────────────────────────┘  the model never saw in training

The insertion rule (insert_initial_context_before_last_real_user_or_summary

in compact.rs

) 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.

Example 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:

 0  user       <session info message>
 1  user       <short user message>
 2  user       <user message>
 3  user       <user message>
 4  user       <user message>
 5  user       <user message>
 6  user       <user message>
 7  user       <user message>
 8  developer  '<permissions instructions>...'
 9  developer  'You are `/root`, the primary agent...'
10  developer  '<multi_agent_mode>...'
11  user       'AGENTS.md instructions + <environment_context>'
12  user       <last real user message>
13  compaction (encrypted, 18 KB)

Eight 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.

The context exposed to the assistant in this conversation had the following chronological order:

  • System-level API behavior
  • Developer tool definitions
  • Developer rules for the Codex agent
  • Retained historical user messages
  • A later developer block with permissions and collaboration rules
  • A user-role block containing AGENTS.md

and environment context - The last user task before the handoff

  • A plaintext assistant/summary

handoff - Assistant commentary, tool calls, tool results, and the final answer produced after that handoff

  • A refreshed user-role AGENTS.md

and environment block - Refreshed developer rules, including the active skill list and delegation policy

  • The newest user messages

This is the reconstructed, model-visible conversation order, not the raw Responses API input array. In particular, the plaintext assistant/summary

is how the resumed task state was presented in this environment. It should not be confused with the opaque compaction

item used by remote compaction v2 on the wire.

The 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

did here.

This conversation compacted again while this note was being updated. After resumption, the model received a new plaintext assistant/summary

containing 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.

The 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:

Presentation. The handoff is an assistant-role message on a dedicated summary channel. The model reports four assistant channels configured in its context —

analysis

(private reasoning), commentary

(user-visible progress + tool calls), final

(answers), and summary

(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:

We must continue current user request: "<live user request, quoted verbatim>" referring to .

and ends with a self-directive:

Need 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.

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:

, What I told user:

, Repo discovery:

, Recommended next action:

, plus nested labels like Real example from a separate compaction:

and Important nuance:

. 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.

The trigger, as the model sees it. The handoff quotes the compaction request it received as:

Context compaction triggered. Summarize the current context.

So the wire item {"type":"compaction_trigger"}

is apparently rendered server-side as that plain instruction. (Second-hand: quoted inside the handoff, not visible as a live message.)

One handoff, ever. Despite this session compacting multiple times, only one summary

-channel message exists in context. Each compaction folds the previous handoff into the new one.

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.

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

(base instructions for gpt-5.5 / gpt-5.6-sol / -luna / -terra), which independently verifies the quote:

When 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.

So 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

output 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).

The same session was asked to reproduce its summary

-channel handoff in full. It did (~900 words; complete text saved at codex-compaction-capture/verbatim-handoff-2026-08-03.txt

). Opening and closing lines:

We must continue current user request: "<the live user request, quoted verbatim>"
referring to <what it refers to>.
...
Need send a commentary update now since compaction happened and instructions say continue
naturally; mention <the specific thing to say>.

(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.)

Section structure as produced (not a fixed schema — these labels emerged from the task):

  • Opening line: the live user request, quoted verbatim, with what it refers to Current governing user AGENTS replaces all previous AGENTS:

— 10 bullets of active rules (conventional commits,[skip ci]

, no GitHub connector, useapply_patch

, cwd + date, which skills were read)What I told user:

— what has already been said to the user, so it is not repeatedRepo discovery:

— 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 Real example from a separate compaction:

andImportant nuance:

(a caution against overclaiming) Recommended next action:

— 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

Notable properties for anyone copying the format:

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. TheImportant nuance:

block 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.

Statistics over all 1,087 compaction records in the local sessions directory plus two controlled captures:

Measurement Value
Blob size (encrypted chars) min 2,252 · median 15,820 · p90 23,672 · max 64,420
Blob on a near-empty session (controlled) 1,080 chars (7 input items, 20.7k tokens)
Blob on a 174k-token session (controlled) 8,140 chars
Compaction turn wall time 6.0s (tiny session) · 33.7s (174k session)
Context after compaction 4,552 tokens (tiny) · 3,368 tokens (174k)

What 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.

Successive 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.

Blob 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.

Two more wire facts from the captures:

  • Reasoning items in the request carry their own encrypted_content

(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 not surfaced to app-server clients(thread/tokenUsage/updated

reports zeros with only the recomputed context total). It goes to OpenAI's analytics ascompaction_summary_tokens

. The turn is billed but effectively invisible in the client's usage view.

Kangwook Lee published the original reverse-engineering of this (X article, 2026-03-04), with secondary write-ups by ShroomDog, Tony Lee and Justin3go. 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

/ summary_prefix.md

.

That describes the v1 path (POST /responses/compact

). Two things have changed:

The default is now v2, which does not use that endpoint at all (normal Responses request +compaction_trigger

item). OpenAI's owncompaction guidedocuments both modes: the standalone endpoint is "fully stateless and ZDR-friendly", while in server-side mode "no separate/responses/compact

call is required … The response stream includes the encrypted compaction item." It also exposes acompact_threshold

request 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 answerNO-PREFIX

if 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 thesummary

channel." Nosummary_prefix.md

-style framing appeared. Two independent sessions agree, and the reported ordering matches the wire history exactly.

So 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

channel 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.

The 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.

Six controlled probes against doctored copies of rollouts, resumed via the experimental thread/resume

path

parameter so nothing was written into the local sessions directory (source checksums verified unchanged; the five probe rows registered in state_5.sqlite

were removed afterwards). Probe recipe is described below.

Probe Change to the copy Result
Tamper one ciphertext char flipped rejected — HTTP 400 invalid_encrypted_content
Cross-thread intact blob, brand-new thread + session ID accepted, content recovered
Cross-model, same comp_hash
resumed under gpt-5.6-terra (blob minted by gpt-5.6-sol )
accepted directly, content recovered
Cross-model, different comp_hash
resumed under gpt-5.5 (hash 2911 vs 3000)
client re-compacts with gpt-5.6-sol first, then proceeds; content survives
Cross-account resumed under a different ChatGPT account accepted, content recovered — key is fleet-wide
Age 50-day-old blob accepted, no TTL

Run on 2026-08-03 across two genuinely distinct ChatGPT accounts on the same machine (different emails, different chatgpt_user_id

; verified by decoding the id_token

claims in each CODEX_HOME/auth.json

).

Setup: account A ran a throwaway session in which the model invented NUM=583147 VEG=artichoke

and 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.

Result: account B (a different ChatGPT user, verified by comparing chatgpt_user_id

and email

claims), resuming the same two-item history under a fresh root thread ID, answered:

NUM=583147 VEG=artichoke

in 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.

Severity, 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:

  • a thread exported or synced after compaction, where pre-compaction records were pruned; thread/resume

with a client-suppliedhistory

(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";

  • machine-to-machine session transfer that carries only current history.

In 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.

A 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.

Tamper rejection is explicit and specific:

invalid_request_error / invalid_encrypted_content:
"The encrypted content for item cmp_<id> could not be verified.
 Reason: Encrypted content could not be decrypted or parsed."

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>

, answered NUMBER=583194 FRUIT=papaya

, 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

exactly. 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.)

The comp_hash mechanism works as the code claims. Resuming a sol-minted blob under

gpt-5.5

produced a visible extra request in the trace: a compaction call to carrying

gpt-5.6-sol

[message, compaction, compaction_trigger]

, returning a fresh 1,700-char blob, followed by the real turn to gpt-5.5

using that new blob. Under gpt-5.6-terra

(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.

Pre-turn/manual ordering confirmed empirically. The resumed request's input array was: additional_tools

, 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.

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.

compact_remote.rs

  • codex-api/src/endpoint/compact.rs

. A unary (non-streaming) POST {base_url}/responses/compact

with basically the same body as a normal Responses request (model

, input

, instructions

, tools

, parallel_tool_calls

, reasoning

, service_tier

, prompt_cache_key

, text

). Response body is {"output": [ResponseItem, ...]}

— 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

response 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

item + retained-message conventions come from.

compact.rs

: a normal turn whose user message is a fixed summarization prompt (prompts/templates/compact/prompt.md

):

"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."

New history = retained user messages (20k-token budget here, not 64k) + a user message of the form SUMMARY_PREFIX + "\n" + last assistant message

, where the prefix (summary_prefix.md

) 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.

session/context_window.rs

  • session/turn.rs

:

  • The auto-compact limit defaults to 90% of the resolved context window(auto_compact_token_limit()

inprotocol/src/openai_models.rs

), and the resolved window iseffective_context_window_percent

(95% for the fallback metadata) of the raw window. Configmodel_auto_compact_token_limit

can only lower it. Pre-turn(run_pre_sampling_compact

): before sampling, if the limit is already reached — also when switching to a model with a smaller context window (ModelDownshift

) or when the model's "compaction compatibility hash" changed (CompHashChanged

, compaction is then run against thepreviousmodel, 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 runsinsidethe 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

runs the same remote path as a standalone turn.- Reasons tracked: user_requested

,context_limit

,model_downshift

,comp_hash_changed

; phases:pre_turn

,mid_turn

,standalone_turn

. These are sent to the server in thex-codex-turn-metadata

client metadata ({trigger, reason, implementation, phase, strategy: "memento"}

) along withx-codex-window-id

; each compaction advances a window counter with fresh window IDs.

Method: CODEX_ROLLOUT_TRACE_ROOT=<dir> codex app-server

(built-in trace bundle recorder — no proxy needed), then JSON-RPC initialize

thread/resume

thread/compact/start

on thread <thread-id>

(a code-review session sitting at 174,871 tokens). Codex CLI 0.146.0, auth = ChatGPT, transport = Responses-over-WebSocket.

Recorded request (trace payload 4.json

, 876 KB):

model: "gpt-5.6-sol"

, same 7.6 KB base instructions as normal turns,parallel_tool_calls: true

input

: 209 items — 76custom_tool_call

  • 76custom_tool_call_output

  • 52reasoning

  • 4message

+1compaction_trigger

(last)

Recorded response (the response payload): exactly one item:

{"output_items": [{"type": "compaction",
  "id": "cmp_0f4c4daa0cdbdfc1016a6f4183b27c8191a55021e034a750d9",
  "encrypted_content": "gAAAAABqbv..." }]}

encrypted_content

is 8,140 chars, gAAAAAB

prefix = Fernet token (version 0x80 + timestamp + IV + ciphertext + HMAC) — server-side encrypted, opaque to the client.

Installed 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.

Corroboration from existing local history: all 1,087 compaction records in the local sessions directory rollouts are v2-shaped (retained user messages + compaction

item; 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).

Pi (@earendil-works/pi-coding-agent

0.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

, core/compaction/utils.js

and core/messages.js

:

  • Trigger: contextTokens > contextWindow - reserveTokens

, defaultreserveTokens

16384;keepRecentTokens

default 20000. - Cut point: walk backwards from the newest entry accumulating token estimates until keepRecentTokens

is 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 — "You are a context summarization assistant. … Do NOT continue the conversation. … ONLY output the structured summary."

  • Output: a fixed Markdown template — ## Goal

,## Constraints & Preferences

,## Progress

(Done / In Progress / Blocked),## Key Decisions

,## Next Steps

,## Critical Context

, plus<read-files>

and<modified-files>

blocks. - Injection: the summary is inserted as a user-role message prefixed"The conversation history before this point was compacted into the following summary:\n\n<summary>\n"

. - Repeated compaction: the previous summary is passed back in <previous-summary>

tags 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 [User]: / [Assistant]: / [Assistant tool calls]: / [Tool result]:

text so the model doesn't continue the conversation, with tool results truncated to 2000 charactersinside the summarization prompt only. - Compaction requests use fresh routing session IDs and disable prompt-cache writes, same reasoning as Codex.

  • Extensions can replace the whole thing: session_before_compact

receives the prepared messages and may return its ownsummary

andfirstKeptEntryId

, withreason

inmanual

/threshold

/overflow

.

Dimension Codex remote v2 Pi 0.83
Survives verbatim every user message, any age, 64k budget a recency window of all message types, 20k budget
Summary voice assistant-role, first person, no framing third-party summarizer, user-role message with an explanatory prefix
Summary shape free-form notes plus an executable plan fixed Markdown template
Environment state git SHA, branch, dirty files, cwd, date read / modified file lists
Tracks what was already said to the user yes no
Standing briefing in the system prompt yes no, only a per-message prefix
Repeated compaction rewrite (old blob in, new blob out) rewrite (previous summary in, updated summary out)
Mid-turn behavior compacts between sampling steps, continues aborts on overflow, compacts, retries the turn
Oversized tool output rewritten in the live history to reclaim window truncated only inside the summarization prompt
Summary storage encrypted, server-side, opaque plaintext in the session file

Pi'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.

The model is trained for it — and pre-briefed for it. Compaction is a first-class model behavior (compaction_trigger

in,compaction

item 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.

Portable 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.

Observed (verifiable from client side):

  • The compaction request asks for the session's own model(gpt-5.6-sol

in 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 model-coupled: each model carries a "compaction compatibility hash" (comp_hash

), and when the hash changes or the user switches models, Codex re-compacts with thepreviousmodel first (session/turn.rs

,maybe_run_previous_model_inline_compact

). A blob from one model is not assumed valid for another. - The training claim comes from OpenAI's own source comments, not speculation: core/src/compact.rs:64-66

: "Mid-turn compaction must useBeforeLastUserMessage

becausethe model is trained to see the compaction summary as the last item in history after mid-turn compaction."core/src/compact_remote.rs:332

: "assistant

messages (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 compaction

has serde aliascompaction_summary

(protocol/src/models.rs

), indicating the blob started life as a summary.

Inferred (cannot be verified from the client):

Whether the server actually routes the request to the requested model or to something else — the client only expresses a preference.

Whether the server validates blob provenance.Resolved 2026-08-03 by direct probe(see "Blob validation probes"): tampering is rejected withinvalid_encrypted_content

; a valid blob is accepted under any thread ID and across models sharing acomp_hash

. - **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:- Run executed under the

mintingaccount by mistake. Same-account success proves nothing. - Run 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

stale log from attempt 1, reporting a false positive. - Second account's refresh token had expired (

Your access token could not be refreshed

); re-authenticating silently reused the browser's signed-in session and landed on thesameChatGPT account, detected by fingerprint guard. - Probe pointed at a

sub-agent rollout (picked byls -t

, whosesession_id != id

): 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.

Method lessons for anyone repeating this: pin identity on

stable claims (sha256(chatgpt_user_id)

andsha256(email)

) —tokens.account_id

was observed to vary and produced two inverted conclusions; write every artifact into amktemp -d

owned by the running user; use aroot session (id == session_id

, noparent_thread_id

); verify apositive control on the minting account first; and derive the verdict only from the current run's log, reportingINCONCLUSIVE

otherwise. - #

What is inside

encrypted_content

. "It is a summary" follows from the alias, the output-token accounting (compaction_summary_tokens

= the turn's output tokens), and the size (~8 KB encrypted ≈ ~2k tokens); but the Responses API already usesencrypted_content

to 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 asummary

channel. Whether the blob additionally carries non-prose state the model can't report on remains unknowable from outside.

codex-rs/core/src/compact.rs

— local implementation, shared history-rebuild helpers, injection rulescodex-rs/core/src/compact_remote.rs

,compact_remote_request.rs

/responses/compact

(v1), pre-trim, output filteringcodex-rs/core/src/compact_remote_v2.rs

,compact_remote_v2_attempt.rs

— v2, retained-message budget, single-item contractcodex-rs/core/src/compact_token_budget.rs

— fresh-window mode (token_budget feature)codex-rs/core/src/session/turn.rs

— trigger logic (pre-turn / mid-turn), model-downshift and comp-hash triggerscodex-rs/core/src/session/context_window.rs

— threshold computationcodex-rs/core/src/compact_model_fallback.rs

— previous-model → current-model retrycodex-rs/codex-api/src/endpoint/compact.rs

— the compact endpoint clientcodex-rs/protocol/src/models.rs

Compaction

/CompactionTrigger

/ContextCompaction

wire itemscodex-rs/prompts/templates/compact/

— local summarization prompt + summary prefixcodex-rs/features/src/lib.rs

remote_compaction_v2

(Stable, default on)

No prompt injection is needed for any of the wire-level results. The client has a built-in trace recorder: set CODEX_ROLLOUT_TRACE_ROOT

to a directory and every compaction writes trace.jsonl

plus numbered payload files, containing the exact request (compaction_request_started

), the exact response items (compaction_request_completed

), and the installed replacement history (compaction_installed

). Those three payloads are the whole picture.

Drive it over the app-server JSON-RPC interface (newline-delimited JSON on stdin/stdout, "jsonrpc"

omitted):

CODEX_ROLLOUT_TRACE_ROOT=/some/dir codex app-server

-> {"id":1,"method":"initialize","params":{"clientInfo":{"name":"probe","title":"probe","version":"0.0.1"},
                                           "capabilities":{"experimentalApi":true}}}
-> {"id":2,"method":"thread/resume","params":{"threadId":"<id>"}}          # or {"path":"<rollout.jsonl>"}
-> {"id":3,"method":"thread/compact/start","params":{"threadId":"<id>"}}
-> {"id":4,"method":"turn/start","params":{"threadId":"<id>",
                                           "input":[{"type":"text","text":"...","textElements":[]}]}}

experimentalApi: true

unlocks the path

parameter on thread/resume

, 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

; thread/delete

refuses it when the rollout lives outside the sessions directory, so remove that row directly afterwards.

For the probes, doctor the copy before resuming: flip one character of encrypted_content

to test tampering, rewrite the thread ID to test thread binding, run under a different CODEX_HOME

to test account binding, or pass -c model=<slug>

to test model binding. Use a root session (id == session_id

, no parent_thread_id

) — app-server rejects direct input to multi-agent sub-agents with "direct app-server input is not allowed for multi-agent v2 sub-agents".

To 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.

── more in #developer-tools 4 stories · sorted by recency
── more on @openai 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-openai-codex-cli…] indexed:0 read:33min 2026-08-03 ·