thread/rollback numTurns uses inconsistent units between core history and app-server replay

Resolved 💬 5 comments Opened May 16, 2026 by WAcry Closed May 17, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

v0.131.0-alpha.16

What subscription do you have?

Pro

Which model were you using?

gpt-5.5-xhigh

What platform is your computer?

Linux 6.8.0-1050-aws x86_64 x86_64

What terminal emulator and version are you using (if applicable)?

_No response_

Codex doctor report

_No response_

What issue are you seeing?

thread/rollback can make the TUI-local transcript and the app-server replayed transcript disagree about which history survived a successful rewind.

The root cause appears to be that the same numTurns value is interpreted in different units:

  • core/protocol treat numTurns as a count of user/instruction-turn rollback boundaries;
  • app-server ThreadHistory replay treats numTurns as a count of raw app-server Turn objects;
  • TUI backtrack computes numTurns from visible user messages.

This only works when every raw app-server Turn contains exactly one rollback boundary. Valid histories can violate that invariant.

  • codex-rs/protocol/src/protocol.rs documents Op::ThreadRollback as “drop the last N user turns from in-memory context”.
  • ThreadRolledBackEvent.num_turns is documented as “Number of user turns that were removed from context”.
  • codex-rs/core/src/context_manager/history.rs::drop_last_n_user_turns drops the last N instruction turns.
  • is_user_turn_boundary treats ordinary non-contextual user messages and structured assistant inter-agent instructions as rollback boundaries.
  • codex-rs/app-server/src/request_processors/thread_processor.rs forwards num_turns unchanged to Op::ThreadRollback { num_turns }.
  • codex-rs/app-server-protocol/src/protocol/thread_history.rs::ThreadHistoryBuilder::handle_thread_rollback clears/truncates self.turns by raw Turn count.

Problematic cases include:

  1. a raw app-server turn with zero visible UserMessage items, such as a goal/autonomous continuation turn or other agent-only/tool-only turn;
  2. a raw app-server turn with multiple UserMessage items, such as mid-turn steering;
  3. a structured assistant inter-agent instruction, which core counts as an instruction-turn boundary but TUI-visible user-message counting does not.

This can make core model history, app-server thread/read includeTurns, persisted rollout reconstruction, resume/fork reconstruction, and TUI backtrack disagree about which conversation prefix survived rollback.

What steps can reproduce the bug?

This can be reproduced with histories where raw app-server Turn objects do not map 1:1 to visible user-message rollback boundaries.

Below are two simplified scenarios. T1, T2, etc. are raw app-server turns. U1, U2, etc. are visible user messages. A1, A2, etc. are assistant messages.

The same class may also affect any feature that creates zero-visible-user raw turns or core-only rollback boundaries, such as standalone !cmd, compaction/review/hook/abort-only turns, and subagent/multi-agent structured inter-agent messages. The two scenarios below are the clearest examples.

---

Scenario 1: multiple steer messages inside one raw turn

Suppose the persisted history has this shape:

T1: U1 -> A1
T2: U2 -> A2
T3: U3
    U4  // steer
    U5  // steer
    A3
T4: U6 -> A6

Now use the TUI backtrack/rewind UI and rewind to U3.

From the TUI's point of view, the visible user messages are:

U1, U2, U3, U4, U5, U6

So rewinding to U3 asks to remove the last four visible user-message boundaries:

num_turns = 4   // U3, U4, U5, U6

The TUI-local transcript is then trimmed to the prefix before U3:

T1: U1 -> A1
T2: U2 -> A2

That is also what core user/instruction-boundary semantics would imply, assuming U3, U4, U5, and U6 are ordinary non-contextual user messages.

However, app-server ThreadHistory replay currently treats the same num_turns = 4 as four raw app-server turns. Since the raw turns are:

T1, T2, T3, T4

replaying the rollback as raw-turn truncation removes all four turns:

app-server replayed transcript: empty

So after TUI rewind:

TUI shows:                  T1, T2
app-server/session replay:  empty

This is a visible data mismatch: the TUI appears to preserve U1/A1 and U2/A2, but thread/read includeTurns, resume, fork, or another client rebuilding from app-server ThreadHistory can see a different transcript.

---

Scenario 2: goal / agent-only raw turn with zero visible user messages

A similar mismatch can happen in the opposite direction when the rollback span contains a raw turn with no visible user message, for example a goal-driven / autonomous / agent-only continuation turn.

Suppose the persisted history has this shape:

T1: U1 -> A1
T2: goal / agent-only output
    // no visible UserMessage in this raw turn
T3: U2 -> A2

Now use the TUI backtrack/rewind UI and rewind to the first visible user message, U1.

From the TUI's point of view, the visible user messages are:

U1, U2

So rewinding to U1 asks to remove the last two visible user-message boundaries:

num_turns = 2   // U1, U2

The TUI-local transcript is then trimmed to the prefix before U1:

TUI-local transcript: empty

Core user/instruction-boundary semantics would also remove both visible user turns, leaving no prior user conversation.

However, app-server ThreadHistory replay sees three raw turns:

T1, T2, T3

and treats num_turns = 2 as “remove the last two raw turns”. It removes T2 and T3, but leaves T1:

app-server replayed transcript:

T1: U1 -> A1

So after TUI rewind:

TUI shows:                  empty
app-server/session replay:  T1: U1 -> A1

---

In both scenarios, rollback can look successful in the TUI because the TUI trims its local transcript by visible user-message position. The persisted/app-server replay can still disagree because it applies the same num_turns value to a different unit: raw app-server Turn objects. The same class of unit mismatch can also become a model-context problem when future requests are built from a mismatched reconstructed history, or when core has rollback boundaries that are not visible user messages.

What is the expected behavior?

thread/rollback should use one consistent rollback boundary across:

  • core in-memory history,
  • persisted rollout reconstruction,
  • app-server ThreadHistoryBuilder / thread/read includeTurns,
  • resume/fork reconstruction,
  • TUI backtrack/local transcript trimming.

Since the protocol and core already document/use numTurns as user/instruction-turn count, the least surprising fix is to keep that semantic and make app-server transcript replay follow it.

Suggested fix:

  • Define numTurns explicitly as “number of rollback boundaries / instruction turns”, not raw app-server Turn objects.
  • Keep app-server replay aligned with core’s rollback-boundary semantics:
  • ordinary non-contextual user messages are rollback boundaries;
  • structured assistant inter-agent instructions are rollback boundaries;
  • contextual/hidden user messages such as goal context are not ordinary visible user-message boundaries.
  • Update ThreadHistoryBuilder::handle_thread_rollback to truncate by rollback boundary position rather than by raw Turn count.
  • If the rollback boundary falls inside a raw app-server Turn, truncate only that turn’s item suffix instead of deleting the whole raw turn.

One maintainable implementation approach would be to record rollback-boundary positions while replaying rollout items into ThreadHistory. A boundary position could point to a raw turn index plus an item index inside that turn. Then ThreadRolledBack { num_turns } can look up the Nth boundary from the end and truncate at that exact position.

Additional information

_No response_

View original on GitHub ↗

5 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #21986

Powered by Codex Action

WAcry · 3 months ago

This issue covers a general and long-standing consistency problem, about a broad inconsistency in app-server rollback units related to steer, command, goal, etc. If the affected session contained non-1:1 history structures (for example goal/autonomous turns, agent-only turns, or mid-turn steering), then this mismatch could plausibly explain #21986. However, I do not have access to the relevant thread, so I cannot confirm that.

etraut-openai contributor · 3 months ago

[Response drafted by Codex]

Thanks for the detailed writeup. I’m not convinced this is a bug as stated.

In particular, I do not think it is inherently problematic for the TUI and the app-server API to use different notions of “turn” for their respective purposes. The TUI is implementing a user-facing rewind interaction over visible transcript entries, while the app-server protocol exposes an API-level representation of thread history. Those abstractions do not need to be identical.

The stronger claim here would need to be narrower and demonstrated concretely: that a single thread/rollback operation leaves core/model history in one state while the app-server’s persisted reconstruction or returned thread.turns represents a different surviving prefix for that same thread. If that happens, that would be worth fixing.

However, the issue currently argues primarily from a semantic comparison across layers and from constructed examples, rather than showing an observed inconsistency in an actual rollback response, thread/read includeTurns, resume, or fork result. I do not think that is enough to establish a bug.

If there is a real defect here, please reduce it to one concrete reproducible case with:

  • the exact sequence of API or UI actions,
  • the rollback request made,
  • the expected surviving history,
  • the actual thread/rollback response and/or thread/read includeTurns result afterward,
  • and, ideally, a small rollout fixture or thread artifact that demonstrates the mismatch.
WAcry · 3 months ago

Thanks — after looking at this more carefully, I agree the issue should be framed more narrowly.

For the built-in TUI, I don't think this is a P0 "live rewind deletes the wrong model history" bug in the common path. The TUI doesn't use app-server Thread.turns.length as the rollback unit. It maintains its own transcript cells, computes rollback depth from visible user-message cells, sends that count to core, and trims local state after rollback succeeds.

So the TUI impact I can actually demonstrate is narrower: app-server replay / thread/read includeTurns / resume / fork may render the wrong surviving transcript. The live rewind itself doesn't appear to over-rewind in a way that causes model-history data loss — it still sends the expected model history in ordinary cases, though user may not see them on the TUI after resume.

My remaining concern is the app-server API contract for non-TUI clients.

thread/rollback is hard to use safely from the outside because the public API exposes Thread.turns, and ThreadRollbackParams.num_turns is documented as "the number of turns to drop from the end of the thread." You'd naturally assume numTurns is measured in the Thread.turns you got back from the server.

But the meaning of num_turns seems to split depending on where you look:

core/model history:                     num_turns = user/instruction rollback boundaries
app-server ThreadHistory replay/projection: num_turns = raw app-server Turn objects

The request path itself forwards num_turns to core, so core interprets it as rollback boundaries. But the ThreadHistory replay/projection counts raw Turn objects. When a raw Turn has zero user/instruction boundaries, or multiple user messages, those two units aren't equivalent. A direct app-server client can't safely figure out the right rollback count from Thread.turns alone — unless it also maintains a TUI-like rollback-boundary/transcript-cell model, which makes the public API hard to use as a source of truth for rewind.

So the practical question: what's the intended contract here?

Should thread/rollback / thread/read includeTurns expose enough for non-TUI clients to do rewind safely from the API alone? Or is the expectation that clients maintain their own transcript-cell model and treat Thread.turns as a display projection?

I'm building a web UI on the app-server API on a headless linux VM and would rather not build a local workaround to maintain a TUI-like rollback-boundary/transcript-cell model on UI side, if the API is expected to grow an explicit rollback boundary or cursor model.

etraut-openai contributor · 3 months ago

The app server api is working as intended (and as documented). We're unlikely to change its semantics because that would be a breaking change for existing clients. If you'd like to see an alternate behavior or a new API, you can file a feature request.