Skip to content

[CLI/TUI] thread/resume silently drops the newest turns on heavily compacted threads #38169

Description

@wenqifu

What version of Codex CLI is running?

codex-cli 0.147.0

What subscription do you have?

ChatGPT subscription (exact tier not disclosed).

Which model were you using?

gpt-5.6-sol

What platform is your computer?

Windows 11 Professional, build 10.0.26200, x64.

What terminal emulator and version are you using?

Windows Terminal, PowerShell, no terminal multiplexer.

Codex doctor report

Fresh codex doctor --json, summarized to exclude local paths, user names,
thread identifiers, and private configuration:

  • overallStatus: ok, codexVersion: 0.147.0;
  • auth configured, ChatGPT auth mode, no stored API key;
  • config load ok;
  • app-server mode ephemeral, not running at check time.

What issue are you seeing?

On threads that have been auto-compacted many times, resuming returns fewer
turns than the rollout stores, and the turns that go missing are the newest
ones
. The thread reopens several turns behind where work actually stopped, so
the last thing asked and answered is not visible.

This is a silent, successful resume. There is no error, no warning, and no
transport failure — the response simply contains an earlier slice of the thread.

The rollout on disk is complete. Verified four ways on this machine:

  • 0 of 105 threads in history.jsonl lack a rollout file;
  • 0 of 583 rollout files are truncated or empty;
  • 0 of 105 rollouts have a last user prompt older than the prompt history records;
  • 0 of 178 user threads have a competing second rollout file.

So this is thread reconstruction on read, not data loss on write.

What steps can reproduce the bug?

  1. Use a Codex CLI thread heavily enough that it auto-compacts many times (the
    two affected threads here carry 33 and 26 compacted records).
  2. Close the TUI normally.
  3. Drive the app-server directly and compare stored vs returned prompts:
    • count response_item payloads with role: "user" in the thread's rollout;
    • call thread/resume with only {"threadId": ...} (the parameters the TUI
      sends);
    • count userMessage items across result.thread.turns.
  4. The counts differ, and the returned tail is not the stored tail.

Measured over all 11 resumable threads on this machine, 0.147.0:

thread compacted records rollout size prompts stored prompts returned % returned
A 33 239 MB 41 8 20%
B 26 76 MB 60 19 32%
C 20 68 MB 32 8 25%
D 15 90 MB 48 9 19%
E 10 100 MB 16 15 94%
F 10 28 MB 13 12 92%
G 5 24 MB 21 20 95%
H 5 20 MB 22 20 91%
I 2 23 MB 19 18 95%
J 0 2 MB 3 2 67%
K 0 2 MB 3 2 67%

The result is bimodal with nothing in between: 19–32% versus 91–100%. The
one-or-two-prompt shortfall in the healthy group is the bootstrap instruction
payload, not lost history. A–D are qualitatively different — they return an
early slice and stop.

The separating variable is compaction count, not size:

  • every truncated thread has ≥ 15 compacted records (15, 20, 26, 33);
  • every intact thread has ≤ 10 (0, 0, 2, 5, 5, 10, 10).

Rollout size does not separate them. Thread E is 100 MB and resumes intact;
thread C is 68 MB and returns a quarter of its prompts. Size should not be read
as the trigger.

A standalone reproduction script is attached below. It reads only local files
and the local app-server, prints no transcript content, and hashes each rollout
before and after the call. In every run here the hashes were identical, so
thread/resume did not mutate stored history.

What is the expected behavior?

A default thread/resume should return the thread through its newest completed
turn, or fail loudly if it cannot. If a compacted thread cannot be fully
reconstructed, the response should say so rather than returning an earlier slice
that is indistinguishable from a complete one.

Compaction must not make recent history unreachable to the client that is
resuming.

Additional information

Relationship to #34663

Both concern what resume hands the client, in opposite directions. #34663 asks
the TUI to render less on bootstrap. This asks that whatever is rendered
include the newest turn. A fix for #34663 that pages turns must not be built
on the reconstruction path measured here, or the paged view will page an
already-truncated list.

Note that the paging API #34663 pointed at is still unreachable from a normal
client on 0.147.0: both thread/resume with initialTurnsPage and
thread/turns/list reject with requires experimentalApi capability. So the
TUI does take the default full-history path, and that path is the one that
truncates.

What is not established

Eleven threads on one machine is a small sample, and the boundary is only
bracketed: 10 compactions intact, 15 truncated, nothing observed in between. No
claim is made about the mechanism inside thread reconstruction — only the
input/output behavior was measured. Whether the trigger is the compaction count
itself, total compacted history, or something correlated with both is open.

Reproduction script

"""Compare prompts stored in a Codex rollout against prompts returned by resume.

Read-only. Prints counts and a hash check, never transcript content.
Usage: python resume_truncation_repro.py <thread-id>
"""
import glob, hashlib, json, pathlib, subprocess, sys, threading, time

CODEX = pathlib.Path.home() / ".codex"
TID = sys.argv[1]

path = None
for f in glob.glob(str(CODEX / "sessions" / "**" / "rollout-*.jsonl"), recursive=True):
    with open(f, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            if "session_meta" in line:
                d = json.loads(line)
                if d.get("type") == "session_meta" and d.get("payload", {}).get("id") == TID:
                    path = f
            break
    if path:
        break
if not path:
    raise SystemExit(f"no rollout for {TID}")

stored, compactions = 0, 0
with open(path, encoding="utf-8", errors="replace") as fh:
    for line in fh:
        try:
            d = json.loads(line)
        except json.JSONDecodeError:
            continue
        if d.get("type") == "compacted":
            compactions += 1
        p = d.get("payload") or {}
        if d.get("type") == "response_item" and p.get("role") == "user":
            text = "".join(c.get("text", "") for c in (p.get("content") or [])
                           if isinstance(c, dict)).strip()
            if text and not text.startswith("<"):
                stored += 1

before = hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest()
proc = subprocess.Popen(["codex", "app-server"], stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                        text=True, encoding="utf-8", errors="replace", bufsize=1)
replies = {}

def reader():
    for line in proc.stdout:
        line = line.strip()
        if not line:
            continue
        try:
            m = json.loads(line)
        except json.JSONDecodeError:
            continue
        if "id" in m and ("result" in m or "error" in m):
            replies[m["id"]] = m

threading.Thread(target=reader, daemon=True).start()

def call(rid, method, params, wait=120):
    proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": rid,
                                 "method": method, "params": params}) + "\n")
    proc.stdin.flush()
    for _ in range(wait * 10):
        if rid in replies:
            return replies[rid]
        time.sleep(0.1)
    return None

call(1, "initialize", {"clientInfo": {"name": "repro", "title": "repro", "version": "0"}})
res = call(2, "thread/resume", {"threadId": TID})["result"]
returned = sum(
    1
    for turn in (res.get("thread") or {}).get("turns") or []
    for item in turn.get("items") or []
    if isinstance(item, dict) and item.get("type") == "userMessage"
)
after = hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest()

print(f"compacted records : {compactions}")
print(f"rollout size (MB) : {pathlib.Path(path).stat().st_size / 1048576:.0f}")
print(f"prompts stored    : {stored}")
print(f"prompts returned  : {returned}")
print(f"rollout unchanged : {before == after}")
proc.stdin.close()
proc.terminate()

Suggested acceptance criteria

  1. For a heavily compacted thread, thread/resume returns turns through the
    newest completed turn.
  2. A thread that cannot be fully reconstructed reports that explicitly instead
    of returning a short list silently.
  3. A regression test covers a thread with many compaction records, asserting the
    newest turn is present in the resume response.
  4. Resume continues to leave the rollout byte-identical.

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    TUIIssues related to the terminal user interface: text input, menus and dialogs, and terminal display
    app-serverIssues involving app server protocol or interfaces
    contextIssues related to context management (including compaction)
    windows-osIssues related to Codex on Windows systems
    on Aug 12, 2026
  2. github-actions commented on Aug 12, 2026

    @github-actions
    Contributor
  3. etraut-openai commented on Aug 13, 2026

    @etraut-openai
    Contributor

    (from codex)

    Thanks for the detailed report. I tested this on Windows build 10.0.26200 with Codex CLI 0.147.0 and could not reproduce history loss:

    • 41 real user turns with 33 compactions: all 41 returned, including the newest.
    • 41 real user turns with 100 compactions: all 41 returned, including the newest.
    • 41 real user turns with 33 compactions and a 33 MiB rollout: all 41 returned.
    • The same behavior held for both legacy and paginated history.

    There appears to be a problem with the reproduction script: it counts every response_item with role: "user" as a user turn. Those records represent model input, not necessarily user-visible messages. Internal context can also use the user role, and filtering messages that start with < does not reliably distinguish real user input.

    For legacy history, actual user messages are represented by event_msg records whose payload type is user_message. For paginated history, they are represented by event_msg records whose payload type is item_completed and whose item type is UserMessage. Rollback markers also need to be applied before comparing expected history.

    I was able to reproduce your exact “41 stored, 8 returned” result with eight real user messages and 33 internal user-role records. All eight actual user messages, including the newest, were returned correctly.

    Could you rerun the comparison using the appropriate user-message records for the thread’s history mode, account for any thread_rolled_back events, and compare hashes of the newest actual user message and the newest returned userMessage? That should establish whether a real user turn is missing without revealing transcript content.

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

    CLIIssues related to the Codex CLITUIIssues related to the terminal user interface: text input, menus and dialogs, and terminal displayapp-serverIssues involving app server protocol or interfacesbugSomething isn't workingcontextIssues related to context management (including compaction)windows-osIssues related to Codex on Windows systems

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions