Repository navigation
Description
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
- 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. - After the turn completes and the session goes idle, the same message is still shown as a queued card directly above the prompt input.
- Clicking the card produces no visible response; the client attempts
PATCH /api/session/<session>/inbox/<message>and receives409 Conflict. The card never clears. - Restarting the TUI clears the card.
- 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>returned409twice 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 messagemsg_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_pendingtables empty; the message exists exactly once insession_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
opencode-agent commentedon Oct 18, 2026
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:ctrl+x Enter(the "1 queued · [Image 1] …" card appeared above the prompt);Enter(steer);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:
oh-my-openagentand 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.ctrl+v, pasted or dragged as a file path, or another way?Enteror queue it?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).
a228410395 commentedon Oct 18, 2026
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:
opencode-agent commentedon Oct 18, 2026
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,
Enterandctrl+x Enter, and small and large pasted PNGs.These would help most:
oh-my-openagentand your local plugins (compaction-offload.js,edit-journal.js,memory-context.js) disabled? This is the biggest difference from my setup.ctrl+v), a pasted or dragged file path, or something else?Enteror queued?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 forsession.inbox.*events or errors, and paste anything relevant (with sensitive content removed).a228410395 commentedon Oct 18, 2026
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], pluscompaction-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 getname: "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 (
idleentries 12.5 s / 48 s earlier), the message was delivered immediately, and the assistant turn started under a second later: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.
09:11:24.232Z, reconnected09:11:25.849Z— send (09:11:25.080) and delivery fall inside the gap.09:51:55.654Z, reconnected09:51:57.927Z— same story (send 09:51:56.798, assistant 09:51:57.810).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):
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:
409on 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.
a228410395 commentedon Oct 18, 2026
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
session.inbox.*records in today's log — the only inbox traces in it are the twoPATCH .../inbox/<msg> → 409responses. If you want that trail, a--log-level debugrun 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:prompt()optimistically admits new prompt IDs:outbox.add(id)+ a local row. Theoutboxcomment: "Prompts clear on their durable echo, positive pending read, or rollback."session.inbox.enqueuedclears it (outbox.delete(event.data.inboxID));session.inbox.deliveredremoves the row.pending.sync()keeps the row by design: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-triggereddata.session.pending.sync(id)inpackages/tui/src/routes/session/rows.ts.enqueued/deliveredecho will ever arrive for that ID, the click's409doesn't reconcile, andremoveSession/evictSessionalso preserveoutboxrows. 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), whileprompt()intentionally doesn't. If clearingoutboxmembership 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 andsession.inbox.enqueuedshould give a deterministic repro.opencode-agent commentedon Oct 18, 2026
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:
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.tsmatches what I see. The optimistic row stays inoutboxbecause neither thesession.inbox.enqueuednor thesession.inbox.deliveredevent arrives, andpending.sync()keeps adding it back after reconnect. Until it's fixed, restarting the TUI is the workaround. Related: #54163.recording.mp4
Reproduction script (drive3.ts)
Run it with opencode-drive:
opencode-drive run ./drive3.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.SKar-2007 commentedon Oct 18, 2026
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-triggeredpending.sync()stops re-merging the delivered row; (2) reconcile on409for 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 againstdev.dajiaohuang commentedon Oct 22, 2026
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.
SKar-2007 commentedon Oct 22, 2026
@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
POSTsettles fails an existing pinned test — "keeps the row when the response lands before the echo" inpackages/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
409on 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.
abel1502 commentedon Nov 1, 2026
I'm having the same issue with a pdf attachment on Linux (WSL).
ronnikols commentedon Nov 7, 2026
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
@tarquinen/opencode-dcp,@bybrawe/opencode-loop,opencode-token-tracker,opencode-parser,opencode-ecosystem-manager, plus localkeypool-rotator/auto-time/beads/image-guard/rtkWhat happens
ctrl+x Enter. The1 queued · <text>dock appears above the prompt input.The server restarted while you were workingsynthetic messages.PATCH /api/session/<id>/inbox/<msg>→409, a toast appears, the card stays.GET /api/session/<id>/inboxreturns{"data":[]}— there is no server-side row to mutate, so every action on the card fails and it looks permanently "inactive".Escapedoes 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-67filters the matching message id out of the transcript. So the delivered user message (which the server may already have answered) is hidden, leaving only the1 queueddock — indistinguishable from a prompt that was never sent.Root cause in 2.2.6
pending.sync()intentionally keeps rows that are still inoutbox(packages/client/src/solid/data.ts:1619-1620):The only clearing paths are the
session.inbox.enqueued/session.inbox.deliveredechoes, 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.syncreconciliation (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 qdoes not help for the stale row because the PATCH returns 409.