Rollout migration duplicates compatibility user input in paginated transcripts

Open 💬 1 comment Opened Aug 9, 2026 by 92645417d9e5c763259dbebc306e3e

What version of Codex CLI is running?

  • rust-v0.148.0-alpha.1

What subscription do you have?

  • ChatGPT Pro 20x

Which model were you using?

  • gpt-5.6-sol

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

  • Alacritty with Codex running inside tmux

What issue are you seeing?

  • After a legacy rollout is converted to paginated history, one logical user input can appear twice as adjacent user messages in resumed and paginated transcripts.
  • The migrated history contains the original canonical completed user item and a second completed user item synthesized from its deprecated compatibility event.
  • The second item receives a generated item-N identifier, so both records are independently materialized even though their user content is the same.

What steps can reproduce the bug?

  • Use a legacy rollout that contains a canonical ItemCompleted(UserMessage) followed by the equivalent deprecated UserMessage event emitted for rollout compatibility.
  • Run codex migrate-rollouts --apply --thread <THREAD_ID> for that thread.
  • Resume the migrated thread or read its paginated transcript.
  • Observe two adjacent copies of the same user input.

What is the expected behavior?

  • Migration should materialize one user-facing item for one logical user input.
  • Compatibility aliases needed by legacy consumers should not become additional canonical completed items in paginated history.

Additional information

  • Canonical item completion deliberately fans out a deprecated user-message event for raw-event and rollout compatibility consumers.
  • The migration canonicalizer preserves an existing canonical completed item, but also converts every deprecated user-message event into a new completed user item with a synthesized identifier.
  • Rollback planning recognizes paired user representations as one rollback boundary, but the canonical writer still emits both representations as visible items.
  • Filtering generated identifiers in transcript consumers would only hide the malformed projection and could suppress legitimate legacy input; the duplicate should be resolved at the migration boundary.
  • This issue report was written with assistance from Codex CLI.

View original on GitHub ↗

1 Comment

jdcodes1 · 9 days ago

Verified on main (1f41cc5d92): the canonicalizer's EventMsg::UserMessage arm unconditionally synthesizes a new completed item with a generated id (legacy_event::user_message_item(event, &mut || self.next_item_id())) and writes it — with no check for whether the immediately preceding canonical ItemCompleted(UserMessage) already represents the same logical input (https://github.com/openai/codex/blob/1f41cc5d92/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs#L194-L208). Meanwhile rollback_replay.rs#L69 does treat the pair as one boundary — so the migrator knows these records are aliases in one code path and materializes them as two items in the other, exactly as you concluded.

Agreed the fix belongs at the migration boundary, not in transcript consumers. Concretely: have the canonicalizer remember the last canonical completed user item in the current turn (id + content), and when a deprecated UserMessage event arrives whose content matches it, skip synthesis (the compatibility event has served its purpose; paginated history needs only the canonical record). Content-match rather than order-assumption keeps genuinely standalone legacy UserMessage events — old rollouts that predate canonical items — converting exactly as today. This joins the growing migrate-rollouts defect family (#38761 name loss, #38762 subagent boundary, #37673 silent >16 MiB drops), which collectively argue for a verification pass comparing legacy vs migrated item counts before reporting migrated.