Skip to content

Desktop compaction repeatedly embeds full image base64 in compacted checkpoints #23257

Open

Description

@SimpleZion

What version of the Codex App are you using?

Codex Desktop on Windows:

  • App package: OpenAI.Codex
  • Version: 26.513.4821.0
  • Package: OpenAI.Codex_26.513.4821.0_x64__2p2nqsd0c76g0

One affected rollout also recorded cli_version: 0.119.0-alpha.28.

What platform is your computer?

Windows x64.

What issue are you seeing?

Codex Desktop context compaction appears to repeatedly persist full inline image payloads inside compacted checkpoints, especially under payload.replacement_history.

This makes long image-heavy threads keep growing even after automatic compaction. It also keeps the "background information window" / context usage high after compaction because the new compacted checkpoint can still contain the previous screenshots as base64 data.

This looks related to #18629 and #22603, but this report is focused on the compaction layer: old compacted checkpoints and the latest compacted.payload.replacement_history repeatedly contain full image payloads instead of deduplicated references, lightweight placeholders, or a single latest compacted state.

Local evidence

I inspected local saved session JSONL files under the Codex home directory. I am intentionally omitting raw session contents, local paths, screenshots, cookies, thread text, and user data.

Affected thread A

Before repair:

  • Rollout JSONL size: 904.9 MB
  • JSONL records: 40,378
  • compacted records: 128
  • Total bytes in compacted records: 770.9 MB
  • Lines containing data:image: 315
  • Total bytes in lines containing data:image: 822.5 MB
  • Total data:image references: 1,787
  • Latest compacted record size: 7.66 MB
  • Latest compacted image refs: 15
  • Latest compacted.payload.replacement_history length: 185
  • Latest compacted visible string chars before image removal: about 7,954,167
  • Latest compacted contained 15 image parts, plus a copied developer/plugin instruction block and an encrypted compaction blob.

After a local schema-aware cleanup that kept the latest compacted checkpoint but removed inline image payloads and old duplicate compacted checkpoints:

  • compacted records: 1
  • data:image refs: 0
  • Latest compacted size: about 316 KB
  • Latest replacement_history length remained 185
  • Latest visible string chars after image removal: about 264,828

This is the important signal: the useful compacted text was much smaller than the full checkpoint because prior screenshots had been embedded into replacement_history.

Affected thread B

Another long thread repeatedly regenerated large compacted checkpoints:

  • One snapshot: rollout size 430 MB
  • compacted records: 14
  • Total bytes in compacted records: 117.6 MB
  • Latest compacted record: 9.02 MB
  • Latest compacted image refs: 24
  • The Codex UI showed the background information window still around 54% / 139k tokens after compaction.
  • Local token-count records around the same time showed a post-compaction context total around 138,789 / 258,400.

After removing old compacted records and image payloads from the latest compacted checkpoint, the same thread became much smaller and the latest compacted record was under 100 KB. However, if the thread continued running, Codex could generate new compacted records that reintroduced the same image payloads.

Why this is a product-level problem

Users can manually edit JSONL as a workaround, but it is risky. During local recovery, replacing large base64-looking strings too broadly can damage fields such as encrypted_content, which then causes resume/reconnect failures like:

{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_encrypted_content",
    "message": "The encrypted content [...] could not be verified. Reason: Encrypted content could not be decrypted or parsed."
  },
  "status": 400
}

So the durable fix should not require users to manually mutate saved session JSONL. Codex should compact or repair these sessions in a schema-aware way.

Steps to reproduce

  1. Use Codex Desktop in a long-running thread.
  2. Use image-producing workflows repeatedly, such as screenshots, browser/computer-use images, image attachments, or other tool outputs that produce input_image / data:image/...;base64,... payloads.
  3. Let Codex automatically compact the context several times.
  4. Inspect the saved rollout JSONL.
  5. Observe many compacted records and a latest compacted.payload.replacement_history that still contains full image payloads.
  6. Continue the same thread; observe that automatic compaction can create more large compacted records rather than converging to a lightweight latest summary.

Expected behavior

Context compaction should not repeatedly embed full image bytes in compacted.payload.replacement_history.

Instead, Codex should:

  • store image blobs out-of-line and keep only lightweight references in replayable history
  • deduplicate image payloads across records and compacted checkpoints
  • avoid retaining old compacted checkpoints once a newer compacted checkpoint supersedes them, or keep them out of the replay/hydration path
  • replace old image-heavy tool outputs with schema-valid lightweight placeholders during compaction
  • keep encrypted_content opaque or remove whole reasoning records in a schema-aware way during any repair path
  • provide a built-in "repair / strip old media payloads" action for already affected sessions

Actual behavior

compacted checkpoints can contain full inline image base64 payloads. Old compacted checkpoints accumulate, and the latest compacted checkpoint can still contain many prior screenshots in replacement_history. The thread remains slow/heavy after compaction and can continue to re-grow as new compacted checkpoints are written.

Related issues

Privacy note

I am not attaching the raw rollout JSONL because it contains private prompts, local paths, screenshots, and authentication material. The report above includes only sanitized structural measurements and field/type counts.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    contextIssues related to context management (including compaction)
    sessionIssues involving session (thread) management, resuming, forking, naming, archiving
    on May 18, 2026
  2. banteg commented on Jun 22, 2026

    @banteg

    Additional local evidence from a much larger affected rollout. This sample is useful because it separates the disk-growth mechanism from the raw private transcript contents.

    Sanitized chart/data:

    11GB rollout compaction growth

    Rollout-level measurements

    One local rollout JSONL from 2026-04-24 grew to about 11.36 GiB by the last compaction point.

    • JSONL lines: 104,082
    • compacted records: 287
    • Bytes in compacted records: 11,465,609,653 bytes, about 10.68 GiB
    • Largest single compacted record: 54,639,204 bytes, about 52.1 MiB
    • Cumulative total rollout size at compaction 287: about 11.36 GiB
    • Cumulative compacted-record contribution at compaction 287: about 10.68 GiB

    So in this sample, compacted checkpoints alone account for nearly all of the rollout file size.

    The records are not opaque

    The large compacted records are normal structured JSON:

    • top-level type: "compacted"
    • payload.message
    • payload.replacement_history
    • replacement_history is an array of response items

    The largest compacted record contained exactly one replacement_history key in the whole JSON record, so this was not recursive self-embedding of prior replacement_history blobs.

    What caused the amplification

    The amplification came from repeatedly snapshotting inline image payloads that remained inside replacement_history.

    Across all 287 compacted records in this rollout:

    • Records with image parts: 286 / 287
    • Total copied input_image parts across compacted snapshots: 9,173
    • Total JSON bytes for those copied image parts: 11,441,321,095 bytes, about 10.66 GiB
    • Total bytes in the image_url strings alone: 11,440,834,926 bytes, about 10.66 GiB
    • Unique image parts after hashing each input_image JSON object: 43
    • Unique image payload footprint: 53,765,897 bytes, about 51.3 MiB
    • Text parts in the same compacted histories: 53,929, but only about 13.9 MiB total
    • compaction items inside histories: about 6.0 MiB total

    That means about 51 MiB of unique image payloads were repeatedly copied into compacted checkpoints until they contributed about 10.66 GiB on disk, roughly a ~213x amplification in this rollout.

    The largest compacted record showed the same pattern locally:

    • record size: 54,639,204 bytes
    • replacement_history items: 155
    • input_image parts in that record: 44
    • JSON bytes for image parts in that one record: 54,536,192 bytes

    The first inspected image part had keys type, image_url, and detail; the image_url field alone was about 1.29 MB.

    Interpretation

    This looks like append-only snapshot amplification:

    1. Compaction replaces the live context with a new history.
    2. The rollout persists the compacted item with a full replacement_history snapshot.
    3. Inline image image_url payloads remain in that replacement history.
    4. Every subsequent compaction writes another full snapshot containing the same large image payloads.
    5. Old compacted records remain in the append-only rollout, so disk use grows by the sum of all snapshots rather than converging to the latest compacted state.

    This is not merely a long-session transcript being large. The same 43 unique image payloads were copied thousands of times across compacted records.

    The current code path appears to match this behavior: compaction writes CompactedItem { replacement_history: Some(items.clone()), ... }, and the rollout recorder serializes each rollout item as a new JSONL line. Compression would reduce disk footprint, but it would not address the replay/hydration cost or the repeated serialized image payloads unless the image bytes are deduplicated, moved out-of-line, or replaced with schema-valid lightweight references/placeholders before persistence.

  3. xmt0114 commented on Jun 30, 2026

    @xmt0114

    I can confirm a severe instance of this issue on Codex Desktop for Windows, with a long-running UI/debugging thread that accumulated many screenshots and then entered a repeated auto-compaction loop.

    Environment:

    • OpenAI Codex Desktop: 26.623.5546.0
    • app-server CLI: codex-cli 0.142.3
    • Windows
    • Long-running thread with repeated screenshots and many automatic compactions

    Structural evidence from the affected rollout JSONL before mitigation:

    • rollout size: ~2.05 GB
    • JSONL lines: 3215
    • compacted records: 52
    • total compacted bytes: ~2041.747 MB
    • latest compacted record: ~39.669 MB
    • latest payload.replacement_history items: 1678
    • latest replacement history retained input_image parts: 145
    • image JSON inside latest replacement history: ~39.216 MB
    • image_url chars in latest replacement history: 41,113,066

    Observed behavior:

    • The UI repeatedly auto-compacts after only a little work.
    • After compaction, context remains very high because the retained replacement history still contains the same inline images.
    • Every subsequent compaction writes another full copy of those retained inline base64 image payloads, so the session file grows quickly.

    Manual mitigation that restored usability for this thread:

    • Backed up the rollout.
    • Kept only the latest 10 compacted checkpoints plus suffix history.
    • Parsed JSONL schema-aware.
    • Modified only JSON input_image content items by replacing them with short input_text placeholders.
    • Preserved message order, text content, compacted records, thread metadata, and encrypted content.
    • Result: active rollout shrank from ~2051 MB to ~5.97 MB; compacted records kept: 10; input_image count: 0; JSON parse errors: 0.

    Expected behavior / possible product fixes:

    • Compaction should account for retained image payload byte size / image token cost, not only text token budget.
    • Old images in replacement history should be summarized, externalized, deduplicated, truncated by age/count/byte budget, or replaced with explicit lightweight placeholders.
    • Compacted checkpoints should not repeatedly persist the same full inline image payloads.
    • Codex Desktop should expose a safe built-in repair action such as "strip historical images from compacted history" for affected sessions.
  4. wgu9 commented on Jun 30, 2026

    @wgu9

    Additional current-main source trace that seems to match the reported amplification:

    • CompactedItem is schema-backed as replacement_history: Option<Vec<ResponseItem>> in codex-rs/protocol/src/protocol.rs.
    • Both remote compaction paths install a full cloned replacement history: codex-rs/core/src/compact_remote.rs builds CompactedItem { replacement_history: Some(new_history.clone()), ... }, and codex-rs/core/src/compact_remote_v2.rs does the same for v2.
    • The final persistence boundary is CodexThread::replace_compacted_history in codex-rs/core/src/session/mod.rs: after item-id assignment it overwrites the compacted item with replacement_history: Some(items.clone()) and then appends RolloutItem::Compacted(compacted_item) to the rollout.

    So the durable record is intentionally a full structured snapshot of the installed post-compaction history. If any retained ResponseItem still contains inline input_image.image_url data, every later compaction writes another full copy into a new append-only JSONL line. That matches the measurements above: the bloat is not recursive replacement_history nesting; it is repeated full snapshots of retained inline image payloads.

    Suggested fix boundary:

    • Keep the live model-context semantics separate from the persisted rollout snapshot shape.
    • Before persisting CompactedItem.replacement_history, sanitize or externalize large image payloads inside the retained ResponseItems. Options: content-addressed image blobs plus lightweight refs, explicit historical-image placeholders, or a byte/count/age retention budget for image parts.
    • Apply the same boundary in both compact_remote and compact_remote_v2, ideally at or just before replace_compacted_history, so local/remote/v2 behavior cannot diverge.

    Regression shape:

    • Build a synthetic thread with a few input_image data URLs.
    • Trigger two compactions.
    • Assert the second rollout's compacted record does not contain repeated full data URLs from the first retained image set, and that resume/reconstruction still produces a schema-valid history.
  5. ignatremizov commented on Jul 19, 2026

    @ignatremizov

    Additional reproduction plus a proposed smallest-stage implementation spec. This is the same thread analyzed in #33493, viewed specifically at the rollout persistence boundary.

    Additional storage evidence

    A sanitized structural scan of one long-lived image-heavy rollout produced:

    Metric Observed value
    Total rollout bytes 2,873,784,216
    compacted records 54
    Bytes in compacted records 2,731,786,825
    Share of rollout bytes in compacted records approximately 95.1%
    Latest compacted replacement-history items 211
    Latest retained images 142
    Latest retained image/data-URL characters 111,563,732
    Latest retained text characters 56,733

    The latest checkpoint was therefore approximately 111.7 MB of serialized replacement history, almost entirely inline image URLs. The rollout is not recursively embedding prior replacement_history objects. It is append-only snapshot amplification: each compaction persists another full snapshot containing the same surviving media.

    All 142 images in the latest checkpoint were already surrounded by local-image text wrappers containing their source paths. The raw data URLs can therefore be removed while retaining cheap, model-callable references for view_image.

    No raw rollout is attached because it contains private prompts, paths, tool output, and images. These measurements were computed from record types and structural field lengths without printing content.

    Current upstream persistence path

    Local compaction v2 constructs CompactedItem with a clone of the newly installed history:

    let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await;
    let (new_history, world_state_baseline) = process_compacted_history(
    sess.as_ref(),
    compaction_turn_context.as_ref(),
    compacted_history,
    &initial_context_injection,
    )
    .await;
    let reference_context_item = match initial_context_injection {
    InitialContextInjection::DoNotInject => None,
    InitialContextInjection::BeforeLastUserMessage(_) => {
    Some(compaction_turn_context.to_turn_context_item())
    }
    };
    let compacted_item = CompactedItem {
    message: String::new(),
    replacement_history: Some(new_history.clone()),
    window_number: Some(new_window_number),
    first_window_id: Some(new_window_ids.first_window_id.to_string()),
    previous_window_id: new_window_ids.previous_window_id.map(|id| id.to_string()),
    window_id: Some(new_window_ids.window_id.to_string()),
    };
    compaction_trace.record_installed(&CompactionCheckpointTracePayload {
    input_history: &trace_input_history,
    replacement_history: &new_history,
    });
    sess.replace_compacted_history(
    compaction_turn_context.as_ref(),
    new_history,
    reference_context_item,
    world_state_baseline,
    compacted_item,
    )
    .await;

    replace_compacted_history then deliberately overwrites replacement_history with another clone of the exact post-ID-assignment live history and appends it as a RolloutItem::Compacted record:

    pub(crate) async fn replace_compacted_history(
    &self,
    turn_context: &TurnContext,
    items: Vec<ResponseItem>,
    reference_context_item: Option<TurnContextItem>,
    world_state_baseline: Option<Arc<WorldState>>,
    compacted_item: CompactedItem,
    ) {
    let items = if turn_context.item_ids_enabled() {
    Self::assign_missing_response_item_ids(Cow::Owned(items)).into_owned()
    } else {
    items
    };
    let compacted_item = CompactedItem {
    replacement_history: Some(items.clone()),
    ..compacted_item
    };
    // Compaction starts a new history window, so its WorldState baseline must be full.
    let mut world_state_item = None;
    {
    let mut state = self.state.lock().await;
    state.replace_history(items, reference_context_item.clone());
    if let Some(world_state) = world_state_baseline {
    let snapshot = world_state.snapshot();
    world_state_item = Some(WorldStateItem::full(snapshot.clone().into_value()));
    state.history.set_world_state_baseline(snapshot);
    }
    }
    self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
    .await;

    The persisted schema stores that snapshot directly as Option<Vec<ResponseItem>>:

    #[derive(Serialize, Clone, Debug, PartialEq, JsonSchema, TS)]
    pub struct CompactedItem {
    pub message: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub replacement_history: Option<Vec<ResponseItem>>,
    /// Monotonic position of this context window within the thread.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_number: Option<u64>,
    /// UUIDv7 identity of the first context window in this thread's window chain.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub first_window_id: Option<String>,
    /// UUIDv7 identity of the context window immediately before this one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previous_window_id: Option<String>,
    /// UUIDv7 identity of this context window.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_id: Option<String>,

    This means a persistence-only filter applied after history installation would make replay/resume diverge from the live context. The safer fix boundary is earlier: sanitize/bound the replacement history before it becomes both live state and the persisted checkpoint.

    Historic resume/fork reconstruction currently restores the latest surviving replacement history exactly:

    pub(super) async fn reconstruct_history_from_rollout(
    &self,
    turn_context: &TurnContext,
    rollout_items: &[RolloutItem],
    ) -> RolloutReconstruction {
    // Replay metadata should already match the shape of the future lazy reverse loader, even
    // while history materialization still uses an eager bridge. Scan newest-to-oldest,
    // stopping once a surviving replacement-history checkpoint and the required resume metadata
    // are both known; then replay only the buffered surviving tail forward to preserve exact
    // history semantics.
    let has_legacy_compaction_without_window_number =
    rollout_items.iter().any(|item| {
    matches!(item, RolloutItem::Compacted(compacted) if compacted.window_number.is_none())
    });
    let initial_window = if has_legacy_compaction_without_window_number {
    None
    } else {
    rollout_items.iter().find_map(|item| match item {
    RolloutItem::SessionMeta(session_meta) => session_meta
    .meta
    .context_window
    .as_ref()
    .and_then(reconstructed_window_from_session_context_window),
    _ => None,
    })
    };
    let mut base_replacement_history: Option<&[ResponseItem]> = None;
    let mut previous_turn_settings = None;
    let mut reference_context_item = TurnReferenceContextItem::NeverSet;
    let mut world_state_replay = Vec::new();
    let mut window = None;
    // Rollback is "drop the newest N user turns". While scanning in reverse, that becomes
    // "skip the next N user-turn segments we finalize".
    let mut pending_rollback_turns = 0usize;
    // Borrowed suffix of rollout items newer than the newest surviving replacement-history
    // checkpoint. If no such checkpoint exists, this remains the full rollout.
    let mut rollout_suffix = rollout_items;
    // Reverse replay accumulates rollout items into the newest in-progress turn segment until
    // we hit its matching `TurnStarted`, at which point the segment can be finalized.
    let mut active_segment: Option<ActiveReplaySegment<'_>> = None;
    for (index, item) in rollout_items.iter().enumerate().rev() {
    match item {
    RolloutItem::Compacted(compacted) => {
    let active_segment =
    active_segment.get_or_insert_with(ActiveReplaySegment::default);
    active_segment.world_state_replay.push(item);
    if active_segment.window.is_none()
    && let Some(window_number) = compacted.window_number
    {
    active_segment.window = Some(ReconstructedWindow {
    number: window_number,
    first_id: compacted.first_window_id.as_deref().and_then(parse_uuid_v7),
    previous_id: compacted
    .previous_window_id
    .as_deref()
    .and_then(parse_uuid_v7),
    id: compacted.window_id.as_deref().and_then(parse_uuid_v7),
    });
    }
    // Looking backward, compaction clears any older baseline unless a newer
    // `TurnContextItem` in this same segment has already re-established it.
    if matches!(
    active_segment.reference_context_item,
    TurnReferenceContextItem::NeverSet
    ) {
    active_segment.reference_context_item = TurnReferenceContextItem::Cleared;
    }
    if active_segment.base_replacement_history.is_none()
    && let Some(replacement_history) = &compacted.replacement_history
    {
    active_segment.base_replacement_history = Some(replacement_history);
    rollout_suffix = &rollout_items[index + 1..];

    let mut history = ContextManager::new();
    let mut saw_legacy_compaction_without_replacement_history = false;
    if let Some(base_replacement_history) = base_replacement_history {
    history.replace(base_replacement_history.to_vec());
    }
    // Materialize exact history semantics from the replay-derived suffix. The eventual lazy
    // design should keep this same replay shape, but drive it from a resumable reverse source
    // instead of an eagerly loaded `&[RolloutItem]`.
    for item in rollout_suffix {
    match item {
    RolloutItem::ResponseItem(response_item) => {
    history.record_items(
    std::iter::once(response_item),
    turn_context.model_info.truncation_policy.into(),
    );
    }
    RolloutItem::InterAgentCommunication(communication) => {
    let response_item = communication.to_model_input_item();
    history.record_items(
    std::iter::once(&response_item),
    turn_context.model_info.truncation_policy.into(),
    );
    }
    RolloutItem::InterAgentCommunicationMetadata { .. } => {}
    RolloutItem::Compacted(compacted) => {
    if let Some(replacement_history) = &compacted.replacement_history {
    // This should actually never happen, because the reverse loop above (to build rollout_suffix)
    // should stop before any compaction that has Some replacement_history
    history.replace(replacement_history.clone());

    The shared installation path prepares media in memory but intentionally leaves the recorded rollout unchanged. That means an existing 2.87 GB rollout remains large and repeats the same repair work on every reconstruction:

    session_configuration.thread_source.as_ref(),
    Some(ThreadSource::Subagent)
    ),
    )
    };
    let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history);
    {
    let mut state = self.state.lock().await;
    state.set_next_turn_is_first(!has_prior_user_turns);
    }
    match conversation_history {
    InitialHistory::New | InitialHistory::Cleared => {
    // Defer initial context insertion until the first real turn starts so
    // turn/start overrides can be merged before we write model-visible context.
    self.set_previous_turn_settings(/*previous_turn_settings*/ None)
    .await;
    }
    InitialHistory::Resumed(resumed_history) => {
    let turn_context = self.new_default_turn().await;
    let rollout_items = resumed_history.history;
    let previous_turn_settings = self
    .apply_rollout_reconstruction(&turn_context, &rollout_items)
    .await;
    // If resuming, warn when the last recorded model differs from the current one.
    let curr: &str = turn_context.model_info.slug.as_str();
    if let Some(prev) = previous_turn_settings
    .as_ref()
    .map(|settings| settings.model.as_str())
    .filter(|model| *model != curr)
    {
    warn!("resuming session with different model: previous={prev}, current={curr}");
    self.send_event(
    &turn_context,
    EventMsg::Warning(WarningEvent {
    message: format!(
    "This session was recorded with model `{prev}` but is resuming with `{curr}`. \
    Consider switching back to `{prev}` as it may affect Codex performance."
    ),
    }),
    )
    .await;
    }
    // Seed usage info from the recorded rollout so UIs can show token counts
    // immediately on resume/fork.
    if let Some(info) = Self::last_token_info_from_rollout(&rollout_items) {
    let mut state = self.state.lock().await;
    state.set_token_info(Some(info));
    }
    // Defer seeding the session's initial context until the first turn starts so
    // turn/start overrides can be merged before we write to the rollout.
    if !is_subagent {
    let _ = self.flush_rollout().await;
    }
    }
    InitialHistory::Forked(mut rollout_items) => {
    let turn_context = self.new_default_turn().await;
    if turn_context.item_ids_enabled() {
    Self::assign_missing_rollout_response_item_ids(&mut rollout_items);
    }
    self.apply_rollout_reconstruction(&turn_context, &rollout_items)
    .await;
    // Seed usage info from the recorded rollout so UIs can show token counts
    // immediately on resume/fork.
    if let Some(info) = Self::last_token_info_from_rollout(&rollout_items) {
    let mut state = self.state.lock().await;
    state.set_token_info(Some(info));
    }
    // Paginated subagents persist inherited model context while creating the live
    // thread so the copied prefix is not observed as child-owned metadata.
    if !rollout_items.is_empty() && !is_paginated_subagent {
    self.persist_rollout_items(&rollout_items).await;
    }
    // Forked threads should remain file-backed immediately after startup.
    self.ensure_rollout_materialized().await;
    // Flush after seeding history and any persisted rollout copy.
    if !is_subagent {
    let _ = self.flush_rollout().await;
    }
    }

    async fn apply_rollout_reconstruction(
    &self,
    turn_context: &TurnContext,
    rollout_items: &[RolloutItem],
    ) -> Option<PreviousTurnSettings> {
    let rollout_reconstruction::RolloutReconstruction {
    mut history,
    previous_turn_settings,
    reference_context_item,
    world_state_baseline,
    window_number,
    first_window_id,
    previous_window_id,
    window_id,
    } = self
    .reconstruct_history_from_rollout(turn_context, rollout_items)
    .await;
    // Keep the recorded rollout unchanged. Prepare its reconstructed history before
    // installing it, so legacy media is processed once for this resume or fork and
    // will be processed again if the rollout is reconstructed in a future session.
    // This meets media preparation requirements without modifying persisted rollouts.
    prepare_image_response_items(&mut history);
    prepare_audio_response_items(&mut history);
    {
    let mut state = self.state.lock().await;
    state.replace_history(history, reference_context_item);

    The zero-cost media retention that feeds this persistence path is documented in #33493. Current upstream counts InputImage and InputAudio as zero retained-message tokens and preserves them in partial messages:

    fn truncate_retained_messages_for_remote_compaction(
    items: Vec<ResponseItem>,
    max_tokens: usize,
    ) -> Vec<ResponseItem> {
    let mut remaining = max_tokens;
    let mut truncated_reversed = Vec::with_capacity(items.len());
    for item in items.into_iter().rev() {
    if remaining == 0 {
    continue;
    }
    let token_count = message_text_token_count(&item).max(1);
    if token_count <= remaining {
    truncated_reversed.push(item);
    remaining = remaining.saturating_sub(token_count);
    } else if let Some(truncated_item) =
    truncate_message_text_to_token_budget(item, /*max_tokens*/ remaining)
    {
    truncated_reversed.push(truncated_item);
    remaining = 0;
    }
    }
    truncated_reversed.reverse();
    truncated_reversed
    }
    fn message_text_token_count(item: &ResponseItem) -> usize {
    let ResponseItem::Message { content, .. } = item else {
    return 0;
    };
    content
    .iter()
    .map(|item| match item {
    ContentItem::InputText { text } | ContentItem::OutputText { text } => {
    approx_token_count(text)
    }
    ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => 0,
    })
    .sum()
    }
    fn truncate_message_text_to_token_budget(
    item: ResponseItem,
    max_tokens: usize,
    ) -> Option<ResponseItem> {
    let ResponseItem::Message {
    id,
    role,
    content,
    phase,
    internal_chat_message_metadata_passthrough: metadata,
    } = item
    else {
    return Some(item);
    };
    let mut remaining = max_tokens;
    let mut truncated_content = Vec::with_capacity(content.len());
    for mut content_item in content {
    match &mut content_item {
    ContentItem::InputText { text } | ContentItem::OutputText { text } => {
    if remaining == 0 {
    continue;
    }
    let token_count = approx_token_count(text);
    if token_count <= remaining {
    remaining = remaining.saturating_sub(token_count);
    } else {
    *text = truncate_text(text, TruncationPolicy::Tokens(remaining));
    remaining = 0;
    }
    if !text.is_empty() {
    truncated_content.push(content_item);
    }
    }
    ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => {
    truncated_content.push(content_item);

    I posted the detailed current-source trace and live-context implementation spec here:

    #33493 (comment)

    Proposed implementation / persistence spec

    The smallest coherent stage should keep live and persisted replacement semantics identical:

    1. Before build_v2_compacted_history applies its retained-message budget, remove every retained raw InputImage and InputAudio payload while preserving a reopenable reference when one exists.
      • For canonical local-image triplets, retain the existing <image ... path="..."> wrapper and replace only the raw image item with bounded text instructing the model to use view_image if needed.
      • For pathless media, retain an explicit unavailable marker until a durable media-resource contract exists.
    2. Charge the reference/marker text against the existing retained-message token budget.
    3. Install that media-free history as the active post-compaction context.
    4. Let replace_compacted_history persist the same installed items unchanged. No second rollout-only representation is needed for the first fix.
    5. Keep the original user/response rollout records untouched so transcript rendering and audit history still retain the original event once. The change only prevents raw media from being recopied into every later checkpoint.
    6. For already-poisoned threads, sanitize media at or before the most recent compaction boundary in the cloned history used to construct the next compaction request. Preserve any adjacent source-path wrappers. Media added after that boundary remains visible to the current compactor; after the compactor summarizes it, the newly installed checkpoint becomes media-free but reference-preserving.
    7. Record omitted image/audio counts and bytes in compaction analytics so the size reduction and recovery path are observable.

    A content-addressed media store remains useful for pathless pasted/data-URL images and transcript hydration, but it is not required to stop this reproduction because every observed image already has a source path. A later store should be owned by the image's execution environment and expose a stable reference that view_image can resolve; it must not turn an app-server host path into an assumed remote-executor path.

    Historic rollout migration / vacuum spec

    Existing rollouts need recovery before the next compaction:

    1. At the shared resume/fork reconstruction boundary, sanitize only the base replacement history selected from the latest surviving checkpoint. Leave the newer suffix untouched because it has not yet been summarized.
    2. Make the transformation deterministic and idempotent, preserving source-path wrappers while removing raw media.
    3. On resume, append one versioned repaired checkpoint with the same context-window number/IDs before accepting the first user/model turn.
    4. On fork, persist sanitized inherited context only in the child. Never rewrite or vacuum the parent as a side effect of forking.
    5. A migration/version marker must make later resumes no-ops rather than append equivalent repaired checkpoints.
    6. Keep a defensive pre-compaction repair for already-running sessions that did not pass through cold reconstruction.

    The active-context repair and physical file reclamation should remain separate:

    1. Do not rewrite a multi-gigabyte JSONL synchronously on the resume path.
    2. After successful reconstruction and repaired-checkpoint persistence, schedule a vacuum under an exclusive rollout-writer lock.
    3. Stream to a same-directory temporary file. Strip raw media only from superseded compacted.replacement_history snapshots; preserve original response/image events and the latest repaired checkpoint.
    4. Preserve all unrelated fields, including opaque encrypted content, byte-for-byte.
    5. Parse/validate every output record, fsync the temporary file and directory, retain a recoverable backup, and atomically replace the original. Failure before replacement must leave the original selected and usable.
    6. Record bytes before/after, records rewritten, media bytes removed, duration, and failure status.
    7. Complete the reverse/lazy checkpoint reader so future resumes can stop at the latest repaired checkpoint without eagerly deserializing superseded multi-gigabyte snapshots. Until that lands, the vacuum is required to recover current resume latency as well as disk space.

    Acceptance criteria

    • After successful compaction, CompactedItem.replacement_history contains no inline image/audio payloads.
    • Two consecutive compactions over the same image-heavy transcript do not copy prior-window data URLs into the second checkpoint.
    • Compacted-record byte size is bounded by retained text, bounded references/markers, canonical context, and the compaction item—not historical image bytes.
    • Resume, fork, rollback, and rollout-trace reconstruction produce the same sanitized history that was active immediately after compaction.
    • Original image-bearing response records remain available once for transcript/audit rendering.
    • Retained local-image references remain usable by a later view_image call in both local and remote-executor configurations.
    • Pathless or unavailable media degrades explicitly without embedding the original bytes.
    • Cold resume repairs a legacy image-heavy checkpoint before the first model request and does not remove unsummarized suffix images.
    • A second resume does not append a second repaired checkpoint.
    • Fork repair affects only the child rollout and does not modify the parent.
    • A regression test performs at least two compactions with large synthetic data URLs and asserts that checkpoint size does not grow with the discarded image payload size.
    • A recovery test starts from an existing image-bearing compacted replacement history and verifies that the next compaction request succeeds and installs a clean checkpoint.
    • A vacuum test verifies substantial byte reclamation while preserving reconstructed context, original transcript image events, and encrypted fields.
    • An interrupted/failed vacuum leaves the original rollout valid and selected.
    • Encrypted compaction/reasoning content remains opaque and byte-identical; the sanitizer only touches typed InputImage / InputAudio content items.

    This should stop the 95%-of-rollout amplification demonstrated above while also fixing the post-compaction context death spiral, without changing the append-only rollout model or introducing replay divergence.

  6. shlee1615 commented on Jul 26, 2026

    @shlee1615

    Additional Windows Desktop evidence (2026-07-26)

    I reproduced the same on-disk amplification pattern on Windows with image-heavy / Computer Use sessions.

    Environment

    • Product: Codex Desktop on Windows x64
    • App package version: OpenAI.Codex 26.721.4979.0
    • Session location: %USERPROFILE%\.codex\sessions

    Aggregate impact

    • Entire Codex home: 97.234 GB
    • sessions directory: 93.97 GB across 1,794 JSONL files
    • 197 session files larger than 100 MB, totaling 91.55 GB
    • During the July 18–24 growth burst, 161 large session files accounted for 83.53 GB
    • July session growth alone was about 91.64 GB

    Detailed sample

    One representative rollout was 1.234 GB. A streaming structural scan (without printing or uploading any session content) found:

    • 2,331 inline image occurrences
    • Only 22 unique images by SHA-256
    • 2,309 duplicate occurrences
    • Inline Base64 image strings occupied 1.120 GB / 90.7% of the file
    • 2,309 image values were under payload.replacement_history[].content[].image_url
    • Only 20 image values were direct payload.content[].image_url values
    • Record mix for image-bearing lines was dominated by type=compacted

    Representative largest-file samples from July 18–24 showed inline-image shares between 74.4% and 90.7% of each JSONL file. The repeated payloads were predominantly PNG data URLs, consistent with Computer Use screenshots being copied forward through compaction.

    Observed behavior

    A small number of real screenshots becomes thousands of Base64 occurrences as new compacted records persist replacement_history. This creates rapid write amplification across many sessions, not merely one unusually long transcript.

    Expected behavior / requested fix

    • Externalize image blobs and persist content-addressed references.
    • Deduplicate identical screenshots across turns and compacted checkpoints.
    • Do not copy full historical image bytes into replacement_history.
    • Add per-record and per-session media-byte limits plus a repair/cleanup path for existing oversized rollouts.
    • Warn users when session storage or a single rollout exceeds a safe threshold.

    Privacy note

    No raw rollout, screenshots, prompts, local project paths, credentials, or logs are attached because session JSONL files contain private content. The measurements above were generated with streaming scans that emitted only counts, byte totals, MIME categories, and hashes.

  7. 10 remaining items

  8. Kanturna commented on Jul 30, 2026

    @Kanturna

    I can confirm the same failure mode on a current Windows Codex Desktop build. I am also the author of the Reddit report linked below.

    Environment

    • OpenAI Codex Desktop: 26.721.11231.0
    • Bundled CLI: codex-cli 0.146.0-alpha.3.1
    • Windows x64
    • Long-running image/Computer Use tasks with repeated compaction and full-history subagent forks

    Observed structural impact

    • My worst affected rollouts exceeded 2 GB.
    • Approximately 90–98% of their size consisted of inline image data.
    • Repeated input_image payloads were retained inside compacted.payload.replacement_history.
    • Matching images were multiplied further across full-history forks.
    • The Codex app-server reached nearly 9 GB of private memory.

    I also observed a strong before/after runtime correlation:

    • With League of Legends closed, affected Codex work averaged roughly 10 Mbps outbound and peaked near 38 Mbps.
    • Gateway latency remained around 1 ms, while public latency spiked as high as approximately 195 ms.
    • After a schema-aware offline repair that removed only duplicated images from compacted replacement history—while preserving the original images, text, task IDs, and verified backups—the affected tasks became 61–93% smaller.
    • During subsequent real Codex work, outbound traffic averaged approximately 0.1–0.2 Mbps, while public latency remained around 9 ms.

    I want to keep the attribution precise: image retention and duplication inside compacted history were structurally confirmed. The before/after network behavior strongly supports a connection, but adapter-level measurements alone do not prove that every transferred byte or latency spike was caused by Codex.

    I documented the symptoms, diagnostic prompt, safety constraints, and recovery approach here:

    https://www.reddit.com/r/codex/comments/1vaw26v/codex_desktop_duplicated_inline_screenshots/

    I am not uploading raw rollout files because they contain private screenshots, prompts, project paths, and other local data. I can provide sanitized structural counts, shortened hashes, and before/after measurements if useful.

    Feedback ID: 019fb31e-ced3-78c1-9b41-38dc4f8c430c

  9. J0EY0 commented on Jul 31, 2026

    @J0EY0

    Additional macOS Desktop evidence: one continuously used thread reached 6.11 GiB

    I observed the same append-only compaction amplification in a single long-running Codex Desktop thread containing many pasted screenshots.

    The measurements below were produced with a streaming structural scan that recorded only JSONL record types, counts, and serialized byte lengths. No prompts, screenshots, local paths, tool outputs, or raw JSONL records were printed or uploaded.

    Environment

    • Affected session metadata:
      • cli_version: 0.142.0
      • originator: Codex Desktop
    • Current installation at inspection time:
      • Codex Desktop: 26.727.40816 (build 6067)
      • Bundled CLI: codex-cli 0.146.0-alpha.9.2
    • macOS: 15.7.4 (24G517)
    • Architecture: Apple Silicon (arm64)
    • Usage pattern: one continuously reused main Desktop thread over several weeks, with many pasted screenshots

    Sanitized structural measurements

    Metric Observed value
    Rollout JSONL size 6,559,046,373 bytes (6.11 GiB)
    Total JSONL records 60,233
    compacted records 327
    Bytes occupied by compacted records 6,308,733,046 bytes (96.18%)
    Records larger than 10 MiB 314
    Largest compacted record 24,825,686 bytes (23.68 MiB)
    Latest replacement_history 494 items / 24,812,465 bytes
    Retained input_image parts 135
    Image URL format 135 × data:image/png;base64
    Inline image URL bytes in latest snapshot 24,531,498 bytes (23.39 MiB)
    Input text bytes in latest snapshot 132,259 bytes (about 129 KiB)
    Byte-identical duplicate large records 0

    Observed behavior

    1. Approximately 96% of the rollout file consists of appended compacted records.
    2. The latest compaction snapshot is almost entirely inline PNG image data.
    3. Historical screenshots remain embedded as Base64 data URLs inside later replacement_history snapshots.
    4. Each compaction appends another evolving full snapshot instead of referencing previously stored media.
    5. The records are not byte-identical duplicates, but they retain substantial overlapping historical image data.
    6. A single long turn can trigger multiple compactions and append more than 100 MiB.
    7. This occurred in the main thread and did not require subagent fan-out.

    This appears to be append-only snapshot amplification rather than recursively nested replacement_history objects.

    Expected behavior

    • Store image/media payloads once and reference them by attachment ID or content hash.
    • Bound the number or total bytes of images retained after compaction.
    • Avoid indefinitely retaining superseded full image-heavy compaction snapshots.
    • Provide garbage collection, size limits, or a warning before a thread reaches multi-gigabyte size.

    Privacy note

    I am intentionally not attaching the rollout file because it contains private prompts, screenshots, local paths, and tool output. I can provide additional redacted structural measurements if maintainers need a specific counter or distribution.

  10. waml commented on Jul 31, 2026

    @waml

    this is very worrying almost 52gigs filled because of this bug it should be fixed ASAP

  11. Kanturna commented on Jul 31, 2026

    @Kanturna

    Local source-level prevention proof of concept

    I followed up on my previous report by implementing and testing a local source-level proof of concept against the exact bundled CLI version I was running: codex-cli 0.146.0-alpha.3.1.

    This is not an upstream fix, and I am not distributing a custom executable.

    Implementation boundary

    The important part of the implementation is that this is not a persistence-only filter:

    1. The compaction request can still process the current full media.
    2. After the compaction response has processed it, inline media is removed from the resulting replacement history.
    3. That sanitized history then becomes both:
      • the active post-compaction context
      • the persisted CompactedItem.replacement_history

    This keeps live and resumed semantics aligned.

    The sanitizer:

    • removes only typed inline image/audio payloads
    • preserves ordinary text and non-inline references
    • explicitly leaves EncryptedContent untouched
    • inserts a small text placeholder for removed historical media
    • covers both remote compaction implementations
    • adds a defensive check at replace_compacted_history
    • sanitizes selected legacy replacement history during resume
    • handles rollback-aware fork inheritance while preserving the unsummarized live suffix
    • does not modify the parent rollout as a side effect of forking

    Original top-level image events remain in the append-only transcript. The change only prevents them from being copied into every later compacted checkpoint.

    Validation completed

    The local patch passed:

    • compact-media unit tests
    • feature-registry tests
    • rollback-specific reconstruction tests
    • the relevant broader compaction test suite
    • an image-capable v2 compaction integration test
    • nested/subagent compaction coverage
    • fork-history coverage
    • release-mode check and build

    Live result

    After restarting Codex with the patched binary and feature enabled, I produced a fresh Computer Use screenshot and then ran /compact.

    The resulting rollout showed:

    • the new compacted checkpoint occurred after the screenshot
    • 104 replacement-history items
    • 0 inline image/audio payloads in the latest replacement history
    • 0 inline media items in the active suffix after compaction
    • 0 JSON parse errors

    The original historical media records still exist earlier in the append-only journal, but they are no longer part of the active replacement history and were not copied into the new checkpoint.

    One precision note: that specific live screenshot arrived as a custom_tool_call_output, so the live check proves the desired post-compaction context result. The direct retained input_image sanitization path was exercised separately by the image-capable integration test.

    Known limitations

    This proof of concept does not yet provide:

    • automatic vacuuming of old multi-gigabyte rollout files
    • a content-addressed external media store
    • byte-based retention budgets or UI warnings
    • automatic physical reclamation of original top-level image records
    • guaranteed compatibility with later Codex versions

    For pathless media, the original pixels are intentionally unavailable to the model after compaction.

    Does this implementation boundary align with the intended upstream solution? If useful, I can provide the focused source patch and test details, or open a PR if explicitly invited under the repository’s contribution policy.

  12. akimovdmitry commented on Aug 13, 2026

    @akimovdmitry

    Additional Windows data point — the same issue is still present in a much newer build and has reached an extreme total size.

    Environment:

    • Codex Desktop for Windows: 26.803.10989.0
    • Windows 11 x64
    • Session storage: %USERPROFILE%.codex\sessions

    Measured impact:

    • sessions directory: 322.772 GiB
    • rollout files: 642
    • largest rollout: 46.586 GiB
    • files created during the last 48 hours: 132
    • combined size of those new files: 15.261 GiB

    Exact streaming analysis of one newly created 393.13 MiB rollout:

    • compacted records: 62
    • bytes in compacted records: 345.98 MiB (~88% of the file)
    • largest compacted record: 13.23 MiB
    • data:image references: 285 across 74 JSONL records
    • response_item/message: 27.86 MiB
    • custom tool outputs: 4.58 MiB

    So this is not normal text/tool logging. Repeated compacted checkpoints carrying inline image data dominate the persistent writes. The machine becomes noticeably laggy during Codex use, and storage keeps growing by roughly 7–8 GiB/day in the recent sample.

    I am not attaching the rollout files because they contain private prompts, tool output, local paths, and images. The measurements above were collected with a streaming parser.

    This looks like the same root cause described here, but the 322.8 GiB aggregate footprint shows that the missing retention/deduplication limit can affect the entire Codex profile, not only one thread. A built-in repair/cleanup action and hard per-thread/profile limits would be important.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appbugSomething isn't workingcontextIssues related to context management (including compaction)performancesessionIssues involving session (thread) management, resuming, forking, naming, archiving

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions