thread/rollback numTurns uses inconsistent units between core history and app-server replay
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
numTurnsas a count of user/instruction-turn rollback boundaries; - app-server
ThreadHistoryreplay treatsnumTurnsas a count of raw app-serverTurnobjects; - TUI backtrack computes
numTurnsfrom 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.rsdocumentsOp::ThreadRollbackas “drop the last N user turns from in-memory context”.ThreadRolledBackEvent.num_turnsis documented as “Number of user turns that were removed from context”.codex-rs/core/src/context_manager/history.rs::drop_last_n_user_turnsdrops the last N instruction turns.is_user_turn_boundarytreats ordinary non-contextual user messages and structured assistant inter-agent instructions as rollback boundaries.codex-rs/app-server/src/request_processors/thread_processor.rsforwardsnum_turnsunchanged toOp::ThreadRollback { num_turns }.codex-rs/app-server-protocol/src/protocol/thread_history.rs::ThreadHistoryBuilder::handle_thread_rollbackclears/truncatesself.turnsby rawTurncount.
Problematic cases include:
- a raw app-server turn with zero visible
UserMessageitems, such as a goal/autonomous continuation turn or other agent-only/tool-only turn; - a raw app-server turn with multiple
UserMessageitems, such as mid-turn steering; - 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
numTurnsexplicitly as “number of rollback boundaries / instruction turns”, not raw app-serverTurnobjects. - 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_rollbackto truncate by rollback boundary position rather than by rawTurncount. - 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_
5 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
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.
[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/rollbackoperation leaves core/model history in one state while the app-server’s persisted reconstruction or returnedthread.turnsrepresents 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:
thread/rollbackresponse and/orthread/read includeTurnsresult afterward,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.lengthas 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/rollbackis hard to use safely from the outside because the public API exposesThread.turns, andThreadRollbackParams.num_turnsis documented as "the number of turns to drop from the end of the thread." You'd naturally assumenumTurnsis measured in theThread.turnsyou got back from the server.But the meaning of
num_turnsseems to split depending on where you look:The request path itself forwards
num_turnsto core, so core interprets it as rollback boundaries. But the ThreadHistory replay/projection counts raw Turn objects. When a rawTurnhas 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 fromThread.turnsalone — 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 includeTurnsexpose 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 treatThread.turnsas 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.
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.