Skip to content

fix(tui): undo prompts the server already accepted - #54163

Closed
nexxeln wants to merge 2 commits into
devfrom
undo-prompt-race
Closed

nexxeln wants to merge 2 commits into
devfrom
undo-prompt-race

Conversation

@nexxeln

@nexxeln nexxeln commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

/undo right after sending put the prompt back in the composer but left the turn in the session, so the next request still carried it. The TUI chose its branch from a pending view that lags the server. It then treated the 204 from session.inbox.cancel as proof that the prompt was withdrawn. That cancel is an idempotent no-op when the prompt was already delivered, and it could also reach the server before the prompt's own POST, which waits for switchModel.

Fixes #54150.

What changed

  • data.session.undo runs in the per-session send chain, after the prompt's admission settles. While the store still shows the prompt as pending, it cancels and then reads the message back. A missing message means the prompt was withdrawn. Otherwise it interrupts, waits, and stages the revert, as /undo did before.
  • data.session.input.withdraw returns "withdrawn" or "delivered". Queued prompt undo uses it and warns instead of restoring a prompt that was already sent.
  • /undo and queued prompt undo call these methods instead of session.inbox.cancel.

Scope

  • session.inbox.cancel stays the idempotent 204 from the API audit. Server, protocol, and generated clients do not change.
  • App undo, app queued undo, and the mini footer undo still trust the cancel. They need the same change in follow-ups.

Tradeoffs

  • Restoring the 409 on cancel would reverse the audit decision and would still lose the race where the cancel arrives before the prompt POST.
  • Undo does not drop an unsent prompt locally. It waits for the admission and then reverts the turn. With 300 ms of added latency, the composer comes back about 4 seconds after /undo. A local drop would make that instant, but it needs extra state in the data layer.

Blast Radius

The change touches the TUI undo commands and adds two methods to the shared Solid data layer. Undo of an already delivered turn sends the same three requests as before. Undo of a pending prompt adds one message read after the cancel.

Verification

  • packages/tui/test/session-undo.test.tsx drives the real /undo against a fake server. It covers the delivered race, the overtaking cancel, a rejected admission, and queued undo. All four cases fail on v2 and pass here.
  • A Drive probe with a simulated model ran in a fresh session, a warm session, and a session with 300 ms of added latency. On v2, the fresh and latency runs kept the undone exchange in the next model request and in message.list. Here all three runs exclude it.
  • bun typecheck in packages/client and packages/tui, and bun run check at the root.

Added by the opencode bot (AI-generated).

Related: #41428

/undo restored a prompt whenever session.inbox.cancel returned 204. That
cancel is a no-op when the prompt was already delivered, and it could reach
the server before the prompt's own POST. Undo now runs in the per-session
send chain, cancels, and reads the message back. When the message exists, it
interrupts, waits, and stages the revert.

Fixes #54150

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix works. With the bot's #54150 script and the simulated model, /undo right after sending left ALPHA and its reply in the next model request and in message.list on v2. With this PR both are gone and only the replacement prompt is stored. Undo while streaming and after the reply finished still works. typecheck passes in packages/tui and packages/client, the new TUI tests pass here (they fail on the base), and the nearby client data tests pass.

The logic looks sound: on the server, delivering a prompt removes it from the inbox and creates its message in a single step, so cancel followed by message.get reliably shows whether the prompt was delivered.

Public API: unchanged. The only additions are data.session.undo and data.session.input.withdraw, internal methods in the shared Solid data layer. Server, protocol and generated clients are untouched.

One request about the test, see the inline comment.

Related: #41428

Before

After


type WorldMode = "overtake" | "delivered" | "rejected" | "queued"

function createWorld(mode: WorldMode, sessionID: string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is very large for what it checks: a 350-line fake server tested through the TUI, with a polling waitUntil/Bun.sleep loop and manual barriers. The behaviour that changed lives in createData (session.undo / session.input.withdraw). Could the request-order checks (cancel → message.get → withdrawn, or → interrupt/wait/revert.stage) move into a short test next to packages/client/test/solid-inbox-race.test.ts? At most one small TUI case would then cover the /undo wiring. That would be easier to follow and less timing-sensitive.

@thdxr
thdxr changed the base branch from v2 to dev October 10, 2026 19:39
YidaWeng added a commit to YidaWeng/opencode that referenced this pull request Oct 22, 2026
Move cancel-then-probe undo into data.session.undo / input.withdraw so
cancel cannot overtake the prompt POST, matching the approach in anomalyco#54163.
Wire TUI /undo, message Revert, queued undo, and app revert through it.
@nexxeln nexxeln closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tui: /undo followed by resend keeps the reverted turn in context and duplicates it

1 participant