Skip to content

fix(tui): stop losing queued messages and dropping the wrong steer on flush (#398 follow-ups)#411

Merged
andrei-hasna merged 1 commit into
mainfrom
fix/flush-queued-messages-followups
Jul 24, 2026
Merged

fix(tui): stop losing queued messages and dropping the wrong steer on flush (#398 follow-ups)#411
andrei-hasna merged 1 commit into
mainfrom
fix/flush-queued-messages-followups

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What this fixes

PR #398 landed chat.flush_queued_messages (Shift+Enter → merge every queued follow-up
into the running turn). PR #399 was a competing implementation that was closed as
superseded, but its close analysis identified two real bugs in the code #398 actually
merged
(#398's own reviewer independently flagged the first as a lower-severity concern).
Both are on main today. This PR ports only the specific fixes — it does not resurrect
#399.


Bug 1 — data loss on a refused flush (codex-rs/tui/src/chatwidget/input_flow.rs)

flush_queued_messages used to std::mem::take the rejected-steer queues and
pop_front the queued-message queues before submitting, then discard the return
value of resubmit_queued_user_message_with_history_record.

Submission can legitimately be refused:

  • unavailable thread model — input_submission.rs effective_mode.model().trim().is_empty()
    restore_user_message_to_composer(...), returns false
  • blocked image attachments on a non-image model → restore_blocked_image_submission(...),
    returns false
  • a closed op channel → submit_op fails, returns false

On every one of those paths the queues had already been drained, so all queued
follow-ups were silently destroyed
, and the refusal path then pasted the merged text
over whatever the user was still typing
in the composer.

Fix

  • Build the merged message from clones; the queues are only drained after the
    submission is accepted.
  • Snapshot the live composer draft (text, text elements, local images, remote image URLs,
    mention bindings, pending pastes and the caret) before submitting and restore it when the
    submission is refused, so the user's in-progress draft survives instead of being clobbered.
  • An accepted submission can still be re-queued at the front (session not yet
    configured, auth-profile auto-switch, usage-limit self-heal). drain_flushed_entries
    therefore removes only the originally merged entries and skips anything the submission
    itself pushed on top, so the accepted replacement is never eaten.

Proven by (codex-rs/tui/src/chatwidget/tests/review_mode.rs)

  • unavailable_model_flush_preserves_queued_messages_and_composer_draft — full
    capture_thread_input_state() equality before/after, composer text unchanged, the
    "Thread model is unavailable." error is still surfaced.
  • failed_flush_submit_preserves_queued_messages_and_composer_draft — dropped op channel.
  • accepted_flush_preserves_front_requeued_merged_message — the accepted-but-requeued case,
    asserting the merged replacement survives and the originals are gone.

Bug 2 — the wrong pending steer was dropped (codex-rs/tui/src/chatwidget/input_restore.rs)

enqueue_rejected_steer() popped the front pending steer when the server answered
ActiveTurnNotSteerable. After a merged flush the rejected steer is frequently not the
front entry (older pending steers sit ahead of it), so the wrong item was requeued and the
actually-rejected one was dropped.

Fix

enqueue_rejected_steer_matching_items(&[UserInput]) locates the pending steer by the same
compare_key it was recorded with (Self::pending_steer_compare_key_from_items) and removes
that entry. codex-rs/tui/src/app/thread_routing.rs — the path that has the rejected
items in hand — now uses it. The notification path in turn_runtime.rs keeps the
front-pop fallback because AppServerCodexErrorInfo::ActiveTurnNotSteerable { turn_kind }
carries no items.

Proven by

  • rejected_steer_is_matched_by_compare_key_not_queue_position — a merged steer queued
    behind an older pending steer; asserts the older steer stays pending and the merged one
    lands in rejected_steers_queue.
  • rejected_steer_matching_items_reports_no_match — unknown items ⇒ false (caller falls
    back to the error message) and nothing is removed.

Also (same analysis)

  • MAX_USER_INPUT_TEXT_CHARS cap on the merged steer. The merge produced one user input
    with no length check, unlike every other user-input path
    (chat_composer.rs, app-server turn_start). The flush now rejects an oversized merge up
    front with an actionable error and leaves the queues + draft intact.
    Test: oversized_flush_preserves_queued_messages_and_composer_draft.
  • Discoverability hint. The queued-input preview now renders a
    <binding> submit all now line while a turn is running and something is actually
    flushable. The binding comes from the resolved runtime keymap
    (chat_keymap.flush_queued_messages) via set_queued_message_flush_binding, so a remapped
    binding is displayed, and it is re-plumbed from apply_keymap_update so a /keymap remap
    takes effect without a restart.
    Tests: render_queued_message_with_remapped_flush_binding (F12 snapshot),
    flush_hint_is_hidden_when_flush_is_unavailable, plus the two updated chatwidget
    snapshots that now show shift + enter submit all now.

Pre-existing clippy fix (unblocks whole-workspace clippy -D warnings)

codex-rs/core/src/unified_exec/process_manager_tests.rs:151,176 had two
clippy::byte_char_slices errors (untouched by this work, from #327) that fail
whole-workspace cargo clippy -D warnings for everyone. Fixed properly
([b'a', b'b', b'c']*b"abc"), not silenced with -A.


Verification

  • cargo fmt --check — clean (scripts/format.py needs uv, which is absent on the
    testbox, so cargo fmt is invoked directly).
  • cargo clippy -p codex-tui -p codex-core --tests -- -D warnings — clean.
  • cargo nextest run -p codex-tui — the six new regression tests pass; each was confirmed
    to fail against the pre-fix behaviour before the fix was applied.
  • Blacksmith Testbox build (blacksmith-testbox.yml) is the gate for this PR; run linked in
    the thread.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

… flush

Follow-ups to #398 (chat.flush_queued_messages), from the close analysis of
the superseded #399.

Bug 1 (data loss): flush_queued_messages drained the rejected-steer and
queued-message queues *before* submitting and discarded the submit result.
Every refusal path (unavailable thread model, blocked image attachments,
closed op channel) therefore destroyed the queued follow-ups and pasted the
merged text over the user's live composer draft. The merge is now built from
clones, the queues are drained only once the submission is accepted, and the
composer draft is snapshotted/restored around a refused submit. An accepted
submit can still be re-queued at the front (session not yet configured,
auth-profile switch, usage-limit self-heal), so only the originally merged
entries are removed.

Bug 2 (wrong entry dropped): enqueue_rejected_steer() popped the *front*
pending steer, but after a merged flush the rejected steer usually sits
behind older pending steers. Add enqueue_rejected_steer_matching_items(),
which matches on the compare_key the steer was recorded with, and use it from
the thread-routing path that has the rejected items. The notification path
keeps the front-pop fallback because that error carries no items.

Also:
- apply MAX_USER_INPUT_TEXT_CHARS to the merged steer instead of letting an
  oversized merge fail server-side after the queues were drained;
- render a "<binding> submit all now" hint in the queued-input preview,
  driven by the resolved runtime keymap (so a remapped binding shows) and
  re-plumbed from apply_keymap_update;
- fix the two pre-existing clippy::byte_char_slices errors in
  core/src/unified_exec/process_manager_tests.rs that fail whole-workspace
  clippy -D warnings (from #327), rather than silencing them.

Regression tests cover the refused-submit path (queues + draft intact), the
accepted-but-requeued path, a non-front rejected steer, the no-match
fallback, the length cap, and the hint rendering/visibility.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Proof the regression tests actually catch the bugs

The pre-fix behaviour was re-introduced locally (drain-before-submit + discard the submit
result; enqueue_rejected_steer_matching_items degraded to an unconditional front pop) and
the new tests were run against it:

Summary [0.265s] 6 tests run: 1 passed, 5 failed, 3503 skipped
  FAIL codex-tui chatwidget::tests::review_mode::failed_flush_submit_preserves_queued_messages_and_composer_draft
  FAIL codex-tui chatwidget::tests::review_mode::rejected_steer_matching_items_reports_no_match
  FAIL codex-tui chatwidget::tests::review_mode::rejected_steer_is_matched_by_compare_key_not_queue_position
  FAIL codex-tui chatwidget::tests::review_mode::unavailable_model_flush_preserves_queued_messages_and_composer_draft
  FAIL codex-tui chatwidget::tests::review_mode::oversized_flush_preserves_queued_messages_and_composer_draft

The one that passes pre-fix — accepted_flush_preserves_front_requeued_merged_message — is
deliberately a guard test, not a bug reproduction: it pins the accepted-but-front-requeued
case (session not yet configured / auth switch / usage-limit self-heal) so the new
drain_flushed_entries logic cannot start eating the accepted replacement.

With the fix in place, all of them plus the pre-existing flush tests and the hint tests pass:

Summary [0.194s] 18 tests run: 18 passed, 3491 skipped

Local gates on the branch: cargo fmt --all --check clean,
cargo clippy -p codex-tui -p codex-core --tests -- -D warnings clean (including the two
clippy::byte_char_slices errors this PR fixes).

Whole-suite note: cargo nextest run -p codex-tui on this workstation also reports 10
failures in chatwidget::tests::status_surface_previews / chatwidget::tests::terminal_title.
They are untouched by this diff and are purely environmental — every one is
my-project/project vs tmp, i.e. the harness resolving the fake /tmp/project cwd to
its parent. The Blacksmith Testbox run is the authority here.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Blacksmith Testbox gate: PASS

Run 30125143217conclusion=success, headSha=8962c6a7e8224cb2c0dede0247d8094ef117d311 (== branch head).

Command:

cd codex-rs && cargo fmt --all --check && cargo clippy -p codex-tui -p codex-core --tests -- -D warnings && RUST_MIN_STACK=8388608 cargo nextest run -p codex-tui

Result: Summary [19.093s] 3505 tests run: 3505 passed, 4 skipped — every step green, including clippy -D warnings on codex-core (which contains the two pre-existing clippy::byte_char_slices errors this PR fixes rather than silences).

This also confirms the 10 status_surface_previews/terminal_title failures seen on my workstation are purely local-environment noise: they pass on the testbox.

@andrei-hasna
andrei-hasna merged commit ef40a93 into main Jul 24, 2026
28 checks passed
@andrei-hasna
andrei-hasna deleted the fix/flush-queued-messages-followups branch July 24, 2026 21:07
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 24, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant