Skip to content

tui: queued card for a delivered image message lingers above the prompt input #56748

Description

@a228410395

Summary

On Windows (v2 TUI), a user message with an image attachment keeps being rendered as a queued card directly above the prompt input long after it has been delivered and answered. The card does not clear when the turn completes; clicking it has no visible effect; the client's inbox sync (PATCH /api/session/<session>/inbox/<message>) returns 409 Conflict. The card only disappears after restarting the TUI. Observed on both 2.0.29 and 2.0.32. Server-side state is clean (session_inbox empty) — nothing is actually pending.

Environment

  • opencode version: 2.0.29 (first observed); 2.0.32 (still reproduces)
  • OS: Windows_NT 10.0.19045 (win32 x64)
  • Terminal: Windows Terminal (TUI launched from powershell.exe)
  • Shell: PowerShell
  • Install/channel: npm global install of @opencode/cli, channel: latest
  • Active plugins: [email protected]; local plugin files: compaction-offload.js, edit-journal.js, memory-context.js

Reproduction

  1. In the TUI, send a user message with an attached image (renders as [Image 1] … plus a file chip). It is delivered and answered normally.
  2. After the turn completes and the session goes idle, the same message is still shown as a queued card directly above the prompt input.
  3. Clicking the card produces no visible response; the client attempts PATCH /api/session/<session>/inbox/<message> and receives 409 Conflict. The card never clears.
  4. Restarting the TUI clears the card.
  5. Steps 1–2 reproduce on 2.0.32 as well (after updating and restarting).

Only messages with image attachments have been observed to stick; plain text messages do not.

Expected Behavior

Once a queued message is delivered (promoted into the session), its queued card should disappear from above the prompt input. If an inbox sync fails because the server-side row is already gone (409), the client should reconcile and clear its local pending state instead of leaving the card stuck.

Actual Behavior

  • The delivered image message remained displayed as a queued card above the prompt input indefinitely (12+ minutes in the first occurrence, across multiple later turns).
  • Clicking the card had no visible effect.
  • PATCH /api/session/<session>/inbox/<message> returned 409 twice with no visible effect in the client.
  • Still reproduces on 2.0.32: after updating and restarting, another image message left the same card above the prompt input after its turn completed (still present 7 seconds after the session went idle; the user reports it stays until restart).

Additional Context

Observed instances (Windows Terminal, Windows 10.0.19045):

Instance 1 (2.0.29):

  • session ses_eb1c00cd0ffehEx1RlY2u9ZPoe, user message msg_14e47ae22001Q8qN1T36b4JC3V (with PNG image) — sent 2026-10-18T09:11:25.080Z, delivered and answered normally (09:11:44 idle).
  • Card still shown ~12 minutes later; clicking it produced no visible effect.
  • Log (UTC):
2026-10-18T09:23:34.453Z  PATCH /api/session/ses_eb1c00cd0ffehEx1RlY2u9ZPoe/inbox/msg_14e47ae22001Q8qN1T36b4JC3V -> 409
2026-10-18T09:23:35.013Z  PATCH /api/session/ses_eb1c00cd0ffehEx1RlY2u9ZPoe/inbox/msg_14e47ae22001Q8qN1T36b4JC3V -> 409
  • No other 409s in the entire log for this session; session_inbox / session_pending tables empty; the message exists exactly once in session_message.

Instance 2 (2.0.32):

  • Same session, user message msg_14e6cc7000016MmaXKno5jQHVv (with PNG image) — sent 2026-10-18T09:51:56.798Z, answered 09:51:57.810Z → 09:52:21.257Z, idle at 09:52:21.263Z.
  • Card still shown above the prompt input at 09:52:28Z (7s after idle; verified via screenshot).
  • No further PATCH .../inbox/... requests were logged for this occurrence (the card had not been clicked yet).

Code references (v2.0.29): packages/core/src/session/inbox.ts — projectDelivered deletes the inbox row when the message is promoted; later mutations (cancel, delivery change) raise LifecycleConflict, which surfaces as 409 on PATCH .../inbox/:id. It looks like the client can hold a local pending entry whose server row is already gone, and a 409 does not make it clear the entry.

Related issues: #55179 (editing queued messages with attachments), #56115 (inbox cancel API), #44926 (editing queued messages), #16856 (v1: QUEUED badge stuck).

Workaround: restart the TUI. No code fix known; updating 2.0.29 → 2.0.32 did not resolve it.

Activity

  1. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I tried to reproduce this on 2.0.32 (Linux, simulated model, no plugins). I pasted a PNG from the clipboard so it showed up as [Image 1] with a file chip, then sent it:

    • with the session idle;
    • while a turn was running, queued with ctrl+x Enter (the "1 queued · [Image 1] …" card appeared above the prompt);
    • while a turn was running, sent with Enter (steer);
    • with a small image and with a ~2 MB image.

    Each time the queued card cleared once the prompt was delivered, and nothing stayed above the prompt after the session went idle. So far I can't reproduce it.

    These details would help narrow it down:

    1. Plugins: does it still happen with oh-my-openagent and your local plugins (compaction-offload.js, edit-journal.js, memory-context.js) disabled? Plugins that inject or re-send messages could leave the TUI with a stale pending entry.
    2. How the image was attached: pasted from the clipboard with ctrl+v, pasted or dragged as a file path, or another way?
    3. Session state when you sent it: was the session idle, or was a turn (or a subagent or compaction) still running? Did you send it with Enter or queue it?
    4. New session: does it happen in a brand-new session, or only in that long-running one?
    5. Screenshot: if possible, one showing the stuck card.

    Related issues: #54163 (the TUI's pending-prompt view can lag behind the server) and #56735 (inbox cancel returning 204 for prompts that were already delivered).

  2. a228410395 commented on Oct 18, 2026

    @a228410395
    Author

    Screenshots from the reporter (opencode 2.0.29 and 2.0.32, Windows 10, Windows Terminal) - the queued card remains above the prompt input after the turn is fully answered:

    Image Image
  3. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the screenshots, they confirm what the card looks like on your side. They don't yet show what's different between your setup and mine, though: in my runs on 2.0.32 the card always cleared once the image prompt was delivered. I tried the session idle and busy, Enter and ctrl+x Enter, and small and large pasted PNGs.

    These would help most:

    1. Plugins: does it still happen with oh-my-openagent and your local plugins (compaction-offload.js, edit-journal.js, memory-context.js) disabled? This is the biggest difference from my setup.
    2. How you attach the image: clipboard paste (ctrl+v), a pasted or dragged file path, or something else?
    3. Session state when you send it: idle, or was a turn, subagent or compaction still running? Enter or queued?
    4. New session: does it also happen in a brand-new session, or only in the long-running ses_eb1c00cd… one?

    If you can, also export the session (opencode export <sessionID>) and look at the log around the time of the stuck message. Check for session.inbox.* events or errors, and paste anything relevant (with sensitive content removed).

  4. a228410395 commented on Oct 18, 2026

    @a228410395
    Author

    Thanks — here is everything we can pin down from the affected machine, plus a new lead that I think explains why a stable-connection repro can't reproduce it.

    1. Plugins — active in both occurrences ([email protected], plus compaction-offload.js, edit-journal.js, memory-context.js). We have not retried with them disabled yet.

    2. How the image was attached — as a file, with the original filename preserved: files[0] = { mime: "image/png", source: { type: "inline" }, name: "微信图片_20260322152614.png" }, rendered as a 文件/File chip in the composer. It is not the clipboard-image path — pasted clipboard images get name: "clipboard" in the same session (like the screenshots above).

    3. Session state when sent — idle in both cases; this was not the queue path. The previous turn had already ended (idle entries 12.5 s / 48 s earlier), the message was delivered immediately, and the assistant turn started under a second later:

    instance 1: 09:11:12.592 idle -> 09:11:25.080 user+image -> 09:11:25.790 assistant -> 09:11:44.781 idle
    instance 2: 09:51:08.468 idle -> 09:51:56.798 user+image -> 09:51:57.810 assistant -> 09:52:21.263 idle
    

    Plain-text sends in the same session cleared normally; only attachment messages have been observed to stick.

    4. New session? No — both happened in the same session (ses_eb1c00cd0ffeh…, started 09:02:56Z). Have not tried a fresh session yet.

    5. Screenshot — posted above.

    New lead: the client's event stream was down across the send/delivery in both occurrences.

    • instance 1: stream disconnected 09:11:24.232Z, reconnected 09:11:25.849Z — send (09:11:25.080) and delivery fall inside the gap.
    • instance 2: stream disconnected 09:51:55.654Z, reconnected 09:51:57.927Z — same story (send 09:51:56.798, assistant 09:51:57.810).
    • disconnect error: Transport: The socket connection was closed unexpectedly.

    The stream drops and reconnects repeatedly on this machine (~11 times between 09:11 and 10:09, each followed by a reconnect ~1–2 s later):

    09:11:24 / 09:39:48 / 09:51:55 / 09:55:21 / 09:57:12 / 09:58:31 / 09:59:57 / 10:00:56 / 10:07:22 / 10:07:33 / 10:09:26
    

    Hypothesis: the inbox entry was projected away while the client's stream was down, the delivery update was never delivered, and nothing re-syncs the client's pending card after reconnect (only plugin reconciliation runs). That also fits the later PATCH .../inbox/<msg> → 409: the client still holds a local pending entry for a row that no longer exists, and the 409 doesn't clear it. On a stable connection the delivery update always arrives, so the card always clears — which would explain why you couldn't reproduce it.

    This also looks consistent with #54163 (pending-prompt view lagging behind the server).

    Possibly worth handling:

    • on event-stream reconnect, re-sync pending/inbox state for open sessions;
    • treat 409 on an inbox mutation as "already delivered" and drop the local pending entry.

    Happy to provide more logs or run an instrumented build on this machine if it helps.

  5. a228410395 commented on Oct 18, 2026

    @a228410395
    Author

    One more update — we did the log/export dig you asked for, and I think we now have the full mechanism from the client code at v2.0.32.

    Log / export dig

    • No session.inbox.* records in today's log — the only inbox traces in it are the two PATCH .../inbox/<msg> → 409 responses. If you want that trail, a --log-level debug run next time (or trace) would be needed.
    • opencode session export ses_eb1c00cd…: both messages appear as plain delivered user entries, nothing inbox-specific. (Can share the export file if wanted; it's 5 MB of transcript so I left it out.)

    Mechanism — all in packages/client/src/solid/data.ts @ v2.0.32:

    1. prompt() optimistically admits new prompt IDs: outbox.add(id) + a local row. The outbox comment: "Prompts clear on their durable echo, positive pending read, or rollback."
    2. Durable echo session.inbox.enqueued clears it (outbox.delete(event.data.inboxID)); session.inbox.delivered removes the row.
    3. If the event stream is down across both echoes (both of our occurrences — drop windows in the previous comment), neither happens, and pending.sync() keeps the row by design:
    // Keep optimistic rows still awaiting their echo: this fetch may
    // have raced ahead of an in-flight admission the server does not
    // know about yet.
    const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id))
    const merged = inflight.length === 0 ? pending : [...pending, ...inflight]

    A "positive read" only acknowledges rows present in the snapshot (pending.forEach((item) => outbox.delete(item.id))), so a row that was admitted and already delivered while the stream was down is never acknowledged and gets re-merged on every sync — including the reconnect-triggered data.session.pending.sync(id) in packages/tui/src/routes/session/rows.ts.

    1. Nothing else clears it afterwards: no further enqueued/delivered echo will ever arrive for that ID, the click's 409 doesn't reconcile, and removeSession / evictSession also preserve outbox rows. Only a TUI restart resets the store — which matches the observed behavior exactly.

    So: optimistic prompt row + event-stream gap across enqueue/delivery + the outbox protection in pending.sync ⇒ card that never clears.

    Note compact() clears its speculative ID on POST settlement (outbox.delete(id) in .then), while prompt() intentionally doesn't. If clearing outbox membership once the prompt POST resolves is safe (once admission is answered, the race that comment guards against is over), a later sync excluding the row would drop it; or an age/attempt cap on outbox entries. Your call — but forcing the stream down between the prompt POST and session.inbox.enqueued should give a deterministic repro.

  6. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the extra detail. The event-stream drop was the missing piece. I can now reproduce this on 2.0.32, and it still happens on the latest dev (214fb7fdb).

    I routed the TUI through a proxy that can cut its connections, and the attached script does this:

    1. Send a prompt and let the turn finish.
    2. Paste an image and type a prompt.
    3. Drop all of the TUI's connections (including the event stream) and press Enter immediately. The server accepts, delivers and answers the prompt before the stream reconnects.

    After the session goes idle, the delivered prompt stays as a pending entry and never clears, as the recording and screenshots below show. The same happens with a plain-text prompt, so the image isn't the cause; it probably just makes the request slower, which makes it easier to send right as the stream drops.

    Your reading of packages/client/src/solid/data.ts matches what I see. The optimistic row stays in outbox because neither the session.inbox.enqueued nor the session.inbox.delivered event arrives, and pending.sync() keeps adding it back after reconnect. Until it's fixed, restarting the TUI is the workaround. Related: #54163.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive3.ts)
    // Reproduces #56748: the TUI's event stream drops while a prompt is sent and delivered;
    // after reconnect the delivered prompt stays as a pending card above the input forever.
    // Optional: ATTACH=image pastes a PNG from the X clipboard first (needs Xvfb + xclip).
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const image = process.env.ATTACH === "image"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        network: true,
        tui: { recording: true, keypressOverlay: true },
      },
      ({ ui, llm, network }) =>
        Effect.gen(function* () {
          yield* llm.queue(Llm.text("First reply done."))
          yield* ui.submit("hello")
          yield* ui.waitFor("First reply done.")
          yield* Effect.sleep("1 second")
          if (image) {
            yield* ui.press("v", { ctrl: true })
            yield* ui.waitFor("Image 1", { timeout: 10000 })
          }
          yield* ui.type(" second prompt sent while the event stream is down")
          yield* llm.queue(Llm.text("Second reply done."))
          // Drop the TUI's connections (event stream included), then send right away,
          // so the server admits and delivers the prompt before the stream reconnects.
          const killed = yield* network.killConnections()
          yield* Effect.log(`killed ${killed} connections`)
          yield* ui.enter()
          yield* ui.waitFor("Second reply done.", { timeout: 30000 })
          yield* Effect.sleep("8 seconds")
          yield* ui.screenshot("after-idle")
        }),
    )

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

  7. SKar-2007 commented on Oct 18, 2026

    @SKar-2007

    The stream-gap mechanism looks solid — happy to take this.

    Plan: (1) clear the outbox entry once the prompt POST settles — the admission race the outbox merge guards is over once the server has answered, and compact() already does exactly this — so a reconnect-triggered pending.sync() stops re-merging the delivered row; (2) reconcile on 409 for inbox mutations: the row is gone server-side (delivered or cancelled), so drop the local pending entry instead of leaving the card stuck. Will open a PR against dev.

  8. dajiaohuang commented on Oct 22, 2026

    @dajiaohuang

    Thanks for reproducing the event-stream-gap case and validating the fix in #57427. That closed/unmerged PR appears to cover the reported stuck-card path; this issue remains open. If the branch is still current, could you reopen the PR or confirm it has been superseded? I'll avoid duplicating the change.

  9. SKar-2007 commented on Oct 22, 2026

    @SKar-2007

    @dajiaohuang — thanks for nudging, and here's the full picture:

    #57427 wasn't superseded when it closed — its author self-closed it about 8 minutes after opening, roughly a minute before the bot's validation review posted. That review is positive (it reproduced this on base with opencode-drive and confirmed the branch clears the card), but one part of that approach doesn't hold up: clearing the outbox guard when the prompt POST settles fails an existing pinned test — "keeps the row when the response lands before the echo" in packages/tui/test/cli/tui/data.test.tsx. Inbox reads really can lag that response, so settlement can't authorize dropping the row.

    So rather than reopening (their branch, their call), I've opened #58072, which fixes this on positive evidence instead: a fetched message proves delivery — the message is inserted in the same transaction that consumes the inbox row — so the next message read drops the stale card (and the TUI already re-reads messages on reconnect); a 409 on an inbox mutation proves the row already gone, so the click path reconciles too. Both paths are pinned by new regression tests.

    Nothing left to duplicate — reviews on #58072 welcome.

  10. abel1502 commented on Nov 1, 2026

    @abel1502

    I'm having the same issue with a pdf attachment on Linux (WSL).

  11. ronnikols commented on Nov 7, 2026

    @ronnikols

    Same stale queued card on 2.2.6 (Linux), but with plain-text prompts and a clear trigger: an event-stream gap caused by service restarts.

    Environment

    • opencode 2.2.6 (release channel), TUI
    • Linux (CachyOS) x86_64, kitty, zsh
    • plugins: @tarquinen/opencode-dcp, @bybrawe/opencode-loop, opencode-token-tracker, opencode-parser, opencode-ecosystem-manager, plus local keypool-rotator / auto-time / beads / image-guard / rtk

    What happens

    1. While a turn is running, queue a plain-text prompt with ctrl+x Enter. The 1 queued · <text> dock appears above the prompt input.
    2. The server restarts. In one session I see five restarts in eight minutes (14:25:32, 14:26:09, 14:26:44, 14:32:16, 14:33:52 local); the affected sessions get The server restarted while you were working synthetic messages.
    3. After reconnect the dock never clears. Clicking it opens "Queued prompts"; selecting the row issues PATCH /api/session/<id>/inbox/<msg> → 409, a toast appears, the card stays.
    4. GET /api/session/<id>/inbox returns {"data":[]} — there is no server-side row to mutate, so every action on the card fails and it looks permanently "inactive".
    5. Escape does nothing because the session is already idle, and interrupt is a no-op. Only restarting the TUI clears the card.

    Why it looks like the message was never sent

    While the stale pending row still has delivery === "queue", packages/tui/src/routes/session/rows.ts:63-67 filters the matching message id out of the transcript. So the delivered user message (which the server may already have answered) is hidden, leaving only the 1 queued dock — indistinguishable from a prompt that was never sent.

    Root cause in 2.2.6

    pending.sync() intentionally keeps rows that are still in outbox (packages/client/src/solid/data.ts:1619-1620):

    const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id))
    const merged = inflight.length === 0 ? pending : [...pending, ...inflight]

    The only clearing paths are the session.inbox.enqueued / session.inbox.delivered echoes, a positive inbox read, or a rollback. If the event stream is down across both echoes (any service restart or reconnect window), the optimistic row is pinned forever. This is the same mechanism as the image case in the report, but it also affects plain text and is easy to hit when the server restarts often.

    PR #58072's message.sync reconciliation (drop the pending row once the fetched messages contain its id) would fix exactly this; please consider it for the next release.

    Note this is a different failure from #59559: here the session is idle and the server inbox is empty — it is purely stale client state. Workaround for users: restart the TUI (Ctrl+C, reopen; sessions persist). Ctrl+X q does not help for the stale row because the PATCH returns 409.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions