Skip to content

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

Open

Description

@myhsia

Description

After /undo, I edit the restored prompt and resubmit. The reverted turn disappears from the transcript but is still sent to the model: the next reply references content from the erased turn. The erased prompt/turn also shows up duplicated in the session store (same message text and identical time.created), so the model can effectively see the turn twice.

This looks timing-sensitive: it happens when /undo is used right after sending, while the turn may still be finishing.

Steps to reproduce

  1. In a session, send a message and let the assistant reply.
  2. Press /undo, edit the restored prompt, submit again.
  3. Inspect the session: the previous assistant output is gone from the UI, but the new reply refers to it; the underlying messages still contain the old turn (sometimes two copies with the same time.created).

Expected behavior

Committing the staged rollback should remove the reverted turn from what is sent to the model, and resubmitting should not create duplicate messages.

Actual behavior

Reverted turns remain in the model context, and the erased prompt/turn is left duplicated in the message store.

Environment

  • OpenCode version: 2.0.25
  • OS: macOS (Darwin 27.2.0, arm64)
  • Terminal: VS Code integrated terminal (TERM_PROGRAM=vscode, TERM=xterm-256color)
  • Plugins: none

Activity

  1. opencode-agent commented on Oct 9, 2026

    @opencode-agent
    Contributor

    Thanks for the report. I could reproduce this, but only in one timing window.

    With the simulated model on 2.0.25, I sent a prompt and ran /undo immediately, before the reply had started streaming. Then I cleared the restored prompt and sent a replacement. The prompt came back into the input, but the turn was not reverted: the original prompt and its reply were still stored in the session and included in the next model request. It still happens on the latest v2 (5cfe3b42). If /undo runs once the reply is streaming or finished, the turn is removed correctly.

    In my runs the turn stayed visible in the transcript, and I did not see two copies with the same time.created. If you resend the restored prompt unchanged, it would likely be stored twice. The likely cause is that /undo right after sending cancels the prompt as if it were still waiting to be delivered, even though it has already started running.

    The recording below shows it on 2.0.25, and the script below reproduces it.

    Related: #37751 (a staged revert racing with the running session), #44357 (fast send after /undo), #41428 (duplicate prompt rows in the app store).

    recording.mp4
    Reproduction script (drive.ts)
    // Repro for #54150: /undo right after sending, then edit + resend.
    // MODE=immediate (default): /undo right after submitting. Other modes: streaming, done, justdone.
    // Checks whether the next model request still contains the reverted turn.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const requests: string[] = []
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        project: { git: true, files: { "README.md": "hello\n" } },
        tui: { recording: true, keypressOverlay: true },
      },
      ({ ui, llm, opencode }) =>
        Effect.gen(function* () {
          yield* llm.serve((request, index) => {
            const body = JSON.stringify(request)
            requests.push(body)
            if (index === 0)
              // a slow reply, so /undo lands while the turn is still running
              return Stream.make(
                Llm.text("BRAVO first answer, this is the reply that will be undone. ".repeat(4), {
                  delay: process.env.MODE === "justdone" ? 30 : 60,
                  chunkSize: 8,
                }),
              )
            return Stream.make(Llm.text(`Reply number ${index + 1}`))
          })
    
          const mode = process.env.MODE ?? "immediate"
          yield* ui.submit("ALPHA first prompt")
          if (mode === "streaming") yield* ui.waitFor("BRAVO")
          if (mode === "done") yield* ui.waitFor("Simulated Model ·")
          if (mode === "justdone") yield* Effect.sleep(1150)
          // undo while the assistant turn is still streaming
          yield* ui.submit("/undo")
          yield* Effect.sleep(1500)
          yield* ui.screenshot("after-undo")
          // clear the restored prompt and send a replacement
          yield* ui.press("u", { ctrl: true })
          yield* ui.submit("CHARLIE replacement prompt")
          yield* ui.waitFor("Reply number")
          yield* Effect.sleep(1500)
          yield* ui.screenshot("after-resend")
    
          const last = requests[requests.length - 1] ?? ""
          const report = {
            totalRequests: requests.length,
            lastContainsAlpha: last.includes("ALPHA first prompt"),
            lastContainsBravo: last.includes("BRAVO first answer"),
            lastContainsCharlie: last.includes("CHARLIE replacement prompt"),
            alphaCount: last.split("ALPHA first prompt").length - 1,
          }
          yield* Effect.log(`REPORT ${JSON.stringify(report)}`)
          // dump what the server stores for the session
          const client = opencode as any
          const sessions: any = yield* (client.session.list({}) as Effect.Effect<any>)
          yield* Effect.log(`SESSIONS ${JSON.stringify(sessions).slice(0, 600)}`)
          const list: any[] = sessions?.data ?? sessions?.items ?? sessions
          const id = Array.isArray(list) ? list[0]?.id : undefined
          if (id) {
            const msgs: any = yield* (client.message.list({ sessionID: id }) as Effect.Effect<any>)
            yield* Effect.log(`MESSAGES ${JSON.stringify(msgs)}`)
          }
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. added a commit that references this issue on Oct 21, 2026
    6155122
  3. myhsia commented on Oct 28, 2026

    @myhsia
    Author

    Independent datapoint on the duplication half of this bug, from a released build (2.1.4, cut 2026-10-23).

    While auditing a session store I found a signature that looks exactly like this race:

    • one session contained the same user prompt stored 11 times (plus two more groups of 3x each of a near-identical re-send), all after repeated /undo + resend cycles;
    • each stored copy was a successful re-send, i.e. the turn that /undo was supposed to remove was still there, so the model saw it once per attempt.

    I could not drive the TUI deterministically, so I am not claiming a fresh repro — just that the persisted shape (duplicated admitted turns after undo/resend) matches the analysis above, and that it is observable on 2.1.4.

    Also worth noting for release planning: none of the fixes are in a released build. #55394 and #55403 are still open, #54163 was closed unmerged, and no commit on main references this issue.

    A cheap guard would already help: refuse (or defer) /undo while the turn is admitted/running, and tell the user, rather than silently leaving the turn in context.

  4. added 4 commits that reference this issue on Oct 29, 2026
    5c31b77
    9e082b9
    2245bc5
    9deee26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions