Skip to content

fix(app): order messages by position, not by ID string - #57581

Open
kitlangton wants to merge 2 commits into
devfrom
message-order
Open

kitlangton wants to merge 2 commits into
devfrom
message-order

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What it does

Fixes a bug where Rewind in a forked session (or one with messages from the iOS app) hid the wrong messages, and committing the revert could drop the whole loaded transcript. The client decided message order by comparing message IDs as strings, but the server orders by seq, and IDs don't sort that way:

  • Forked sessions copy messages as msg_<fork>_<seq>, so msg_x_10 sorts before msg_x_9. Staging a revert at msg_x_10 hid msg_x_9 too.
  • iOS mints mixed-case IDs, and msg_A47D… sorts before every lowercase ID, so a revert there hid all earlier messages.
  • On session.revert.committed, the splice looked for the first ID >= the boundary, cut at index 0 and dropped the loaded history.

The TUI transcript had the same string comparison, and the app's turn list dropped every turn when a revert was staged on an assistant message. Found in code review; each case is reproduced in the new tests.

How it works

Order comes from position in the message list, which is already in server seq order.

  1. New messagesBefore(messages, id) in session-domain.ts returns the messages before the boundary. The staged-revert filters (selectVisibleSessionUserMessages, visibleTimelineMessages) and the new-tail-turn check in interaction.ts use it. A boundary not in the loaded list counts as older than all of it. selectVisibleSessionUserMessages now cuts the full list before picking user messages, so a boundary on an assistant message works.
  2. revert.committed in @opencode/client cuts at the boundary's index. When the boundary isn't loaded, it cuts everything only if older pages exist; with the whole history loaded, a missing boundary means a sync already applied the revert, so nothing is cut. A cut from the first loaded message deletes the paging cursor's anchor, so the client rereads the message list instead of stranding older history.
  3. The TUI's staged-revert filter in rows.ts uses a matching messagesBefore, shared with index.tsx, which already sliced by position. Inbox items can't be ordered against a message ID, so pending items that were cut with the messages are removed, and the inbox is reread from the server to learn which others it kept.

Risk

Client only (desktop app and TUI): what the timeline shows during and after Rewind, and the pending-inbox list after a committed revert. A wrong cut would show too many or too few messages after a rewind. The extra inbox reread happens only when items are pending at commit time. Each predicate is still one linear pass over the list; I didn't run the timeline benchmarks.

Proof

New tests cover forked (msg_fork_9/10/11) and mixed-case (msg_d4f1, msg_A47D) IDs. They also cover the paging cursor (boundary at the first loaded message, or in an unloaded page), a boundary already removed from fully loaded history, and an assistant-message boundary. Against the old code the revert-visibility and committed-revert tests fail, and the new cursor tests fail against the previous head; on the branch they pass.

  • bun test src/session/session-domain.test.ts src/session/timeline/controller-projection.test.ts in packages/app: 20 pass
  • bun test test/solid-data.test.ts in packages/client: 36 pass
  • bun test test/cli/tui/session-rows.test.ts in packages/tui: 28 pass
  • bun typecheck in packages/app, packages/client and packages/tui: clean
  • bun script/lint-changed.ts origin/dev: 0 problems
  • CI on dev: all 17 checks pass

@sayan1999

Copy link
Copy Markdown

the issue is real and very bothering

@dhaern

dhaern commented Nov 9, 2026

Copy link
Copy Markdown

This also fixes #61189 for sessions that use plugins posting synthetic messages with their own IDs. oh-my-opencode-slim uses msg_omos_<uuid> and magic-context uses msg_<24 random hex>. The host accepts any msg_ ID, and as strings these sort after native IDs, so rows.ts hides them during a staged revert and the session.revert.committed handler in data.ts cuts the loaded transcript from the first one onward. That is your third case, but it needs no fork or iOS client: it happens in ordinary TUI sessions with those plugins installed. I see it on 2.2.6, and dev still has the same comparisons. Ordering by position matches what the projector deletes by seq, so I'd like to see this merged.

@MiguelValentin

Copy link
Copy Markdown

Confirming this on the 2.2.7 web UI (Edge on Windows, server on Linux). After Undo on a user message, the assistant reply from the reverted turn stays visible and is grouped into the previous turn. It disappears only when the next prompt commits the revert; a full reload does not fix it.

DB evidence from the affected session (462 messages, staged revert):

  • boundary user: msg_1bd040… — seq 4284
  • its assistant: msg_1bd03f… — seq 4285, created 80 ms later
  • but msg_1bd03f… < msg_1bd040… as strings, so visibleTimelineMessages keeps the assistant and drops its user message

This is a native ID, not a fork/plugin/iOS ID: the suffix inverted the order within the same millisecond. The position-based messagesBefore change here covers it. A desktop 2.2.7 instance with a fresh session does not reproduce it, which matches the ID-ordering dependency.

Happy to provide a sanitized repro or a smaller fixture if useful.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants