Skip to content

Queued user message is re-injected into model context once per agent step after an interrupted run #55204

Closed as not planned

Description

@fluffyspace

Summary

When a user message arrives while the agent loop is running and the run is
interrupted/resumed, the same persisted user message is delivered to the model
multiple times in the conversation prompt. The durable store stays correct
(exactly one row per send); the duplication happens only at prompt-assembly.
The model therefore believes the user sent the same message over and over and
starts complaining about a broken client.

Environment

  • opencode v2.0.28 (official @opencode/cli, compiled binary), Debian x86_64
  • Long-running background server process; clients attach to it (TUI / remote clients)
  • Storage: opencode.db (session_v2 / session_message)

Reproduction (observed sequence)

  1. Send a prompt that keeps the agent busy across several tool steps.
  2. While the run is still in flight, send a follow-up user message.
  3. The in-flight run is interrupted at a step boundary — the session row ends up
    with idle_outcome = 'interrupted'.
  4. The loop resumes and each subsequent step re-hydrates history from the DB and
    re-appends the same user message to the model prompt again.

Evidence (metadata only)

  • session_message contains exactly one user row for message A (t0) and
    one user row for message B (t0 + 97 s).
  • The model received message A 4 copies (roughly once per loop step) and
    message B 2 copies in consecutive prompts.
  • No duplicates exist in the DB, session_inbox, session_pending, or the
    client UI — only in the LLM request payload.

Expected behavior

Each user message is injected into the model context exactly once, no matter
when it arrives (queued mid-run) and no matter what the loop does after
(interrupt, resume, extra steps).

Impact

  • Model behaves confused / accuses the user of spamming (very visible failure).
  • Wastes input tokens on every duplicated copy.
  • Makes interruption + resume effectively unsafe for chat-style frontends where
    users routinely type while the agent runs.

Possibly related

Feels like the "admitted"/delivered watermark for queued messages is not
recorded durably, so after an interrupted resume each step re-delivers anything
pending.

Activity

  1. opencode-agent commented on Oct 13, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I tried to reproduce this on v2.0.28 with a simulated model, and every request sent to the model contained each user message exactly once. I tried three ways:

    • a follow-up sent while the first model response was still streaming
    • a follow-up sent mid-run, then an interrupt (Esc Esc; the turn showed as "interrupted"), then another message once the loop had resumed
    • the same, but with the follow-up and interrupt landing while a shell tool was running

    In all of these, over 5–7 model requests, nothing was duplicated. The difference is probably in your setup. These would help narrow it down:

    1. How you saw the duplicates: a provider-side log, a proxy, or a plugin? If you can, share one redacted request body that shows the repeated message and how it's shaped (separate user turns, or repeated parts inside one turn).
    2. Clients: which ones send the messages (TUI, opencode run --attach, the SDK or HTTP API, a custom frontend), and whether more than one client is attached to the same session at once.
    3. How the interrupt happens: Esc in the TUI, an abort API call, or a server restart while the run is in flight.
    4. Provider, model and plugins: the provider and model, and any plugins or custom tools in your config.
    5. Minimal steps: the smallest sequence that triggers it, if you can find one.

    You can reply here, or edit the issue; if you edit it, please leave a comment so we know it's updated.

    Related, in case it helps: #54150 (a turn duplicated in the model's context after /undo and resend, also timing-dependent) and #21518 (queued messages formatted differently from one turn to the next; v1).

  2. d1g1t commented on Oct 13, 2026

    @d1g1t

    I've had the same issue on both v1 and v2.
    I never narrowed down the reason because the exported session never contains my message repeats, just the model accusing me of repeating. I observed that once it starts, it happens after almost every tool call.

    I've been working around this by adding repeated user messages are a tool quirk. safe to ignore. to AGENTS.md

  3. rekram1-node commented on Oct 13, 2026

    @rekram1-node
    Contributor

    Need more info on your setup, this should be possible at all on vanilla opencode.

  4. self-assigned this
    on Oct 13, 2026
  5. d1g1t commented on Oct 14, 2026

    @d1g1t

    Opencode Desktop on Ubuntu 24.04/26.04 connecting to a local unsloth backend running llama.cpp.
    Models have mostly been Qwen 3.8 27b and finetunes.

    I haven't really ruled out the others as culprits. I replied here as this sounded like the exact issue I was facing.

  6. fluffyspace commented on Oct 14, 2026

    @fluffyspace
    Author

    Setup detail, as requested — sanitized (no project paths, no hostnames, no key material; the session contents are personal, so this is metadata only).

    1. How I saw the duplicates — and a correction to my own report

    No provider log, no proxy, no plugin reported them: the model said so, live, mid-run. I have since re-read the durable record of that run and my "after an interrupted run" framing is wrong — the duplication started in the first step of a brand-new session's very first run, before any interrupt.

    That run, from session_message (session created 2026-10-13 17:29:04, provider qwen-cloud/qwen3.8-flash, agent build, one TUI client on the shared background server):

    seq type time (UTC) what it is
    4 user 17:29:04 message A — exactly one row, 315 chars
    5 assistant 17:29:04 step 1
    16 assistant 17:29:08 step 2
    25 assistant 17:29:11 step 3
    34 assistant 17:29:17 step 4 — reasoning: "The user resent the same message"
    47 assistant 17:29:23 step 5 — reasoning: "sent the same message three times"
    56 assistant 17:29:27 step 6 — reasoning: "has sent the same message four times now"; this step's tool part ends {"type":"aborted","message":"Step interrupted"}
    64 idle 17:29:54 outcome = interrupted
    68 user 17:30:41 message B ("I am not sending the same message") — one row, 247 chars
    69 assistant 17:30:41 first step of the next run — reported B twice in its prompt

    So: one extra copy per loop step, accumulating from step 1, in a fresh session, with the message being the initial prompt — not a queued mid-run follow-up. The interrupt at 17:29:54 was me pressing Escape after reading the model's complaint — i.e. a consequence, not the cause. Message A was the first thing ever sent in that session; session_inbox and session_pending were empty when I queried them during the incident; session_v2.resume_attempts = 0; no row was ever re-persisted; the client UI showed exactly one copy of each message.

    It kept recurring for the next ~20 min of that session and in later sessions, on this box and (per @d1g1t) elsewhere.

    2. Environment (corrected)

    • Official @opencode/cli, compiled binary (/usr/lib/node_modules/@opencode/cli/bin/opencode.exe). v2.0.28 during the incident; 2.0.29 now. Node 22 available on the box but the CLI is the self-contained binary.
    • One long-lived background server (opencode serve --service, loopback-only port) shared by every client on the box; sessions are long-lived and resumed by id.
    • The server was started minutes after an in-place npm install -g --force @opencode/cli@2.0.28 upgrade from 2.0.26 performed while a 2.0.26 TUI was still running/attached. Timeline from the log: old service run=cecc0b86 (2.0.26) alive 11:53→16:54, upgrade 16:53:56, new service run=1b1c2a4a (2.0.28) up 16:54:18, incident TUI run=4ddd15a8 attached 17:28:25 and stayed alive until 05:20 next morning.
    • OS: Ubuntu 24.04.5 LTS, kernel 6.8.0-142, x86_64 (I wrote "Debian" in the body — this is the accurate line).
    • Same HOME for all clients, so one config dir, one plugin dir, one opencode.db. State dir still holds leftover v1 data (skipped_version: 1.18.34) and two .db stores from retired patched builds whose source was modified before plugins existed (opencode-output-approval.db, opencode-fix-stale-width-shrink.db). No patched build in use now.

    3. Clients

    • The captured incident: one plain TUI, opencode with no args, attached to that background service. No second client on the session at the time, no opencode run, no SDK/HTTP call in that window (I checked: no other session had messages in 17:25–17:34).
    • I can't fully rule out my own input path: on this box I usually drive sessions either from a kitten ssh terminal or from a web/phone PWA that types into the TUI's PTY through a resident host client (systemd service → ptyDaemon keeps PTYs alive, a WebSocket viewer relays keystrokes and can also send ESC/Ctrl-C as "Stop", then replays the screen). Additionally a Discord bridge drives sessions through the hub HTTP API and injects prompts with delivery/resume semantics. All of those share the one background service and data dir. So repeated mid-run input from a remote frontend is routine here — but in this incident the input went through an ordinary TUI, and the duplicate copies appeared before I touched anything.

    4. Provider, model, plugins, config

    • Incident model: qwen-cloud/qwen3.8-flash, declared as a custom npm: "@ai-sdk/openai-compatible" provider with options.baseURL pointing at a third-party OpenAI-compatible gateway (not a first-party provider) and hand-declared tool_call: true, limit: {context: 200000, output: 131072}, variant: default. Three more custom openai-compatible providers are configured (two other gateways, one self-hosted-style router), plus openrouter with just a key override. Default model + recent are these custom providers.
      • Note @d1g1t is also on a Qwen 3.8 model behind an OpenAI-compatible endpoint (llama.cpp). Both reports are custom openai-compatible provider entries rather than built-in providers — worth ruling out before the queue watermark, since the AI-SDK-compatible path is the shared variable.
    • Three custom plugins, discovered from ~/.config/opencode/plugin/ (symlinks). They hook: ctx.session.hook("prompt", …), ctx.session.hook("context", …) (one appends a text block to e.system, dedup-guarded), ctx.tool.hook("execute.before"|"execute.after", …), ctx.event.subscribe(); their 1.x-compat half registers chat.message, chat.params, tool.execute.before/after, and one of them can call ctx.session.prompt({ sessionID, text, delivery, resume: true }) (only for its own slash command).
      • Honest caveat: those plugins were failing to load in an earlier build with PluginModule.LoadError: Plugin must export a default definition with an id and an effect or setup function (SchemaError: Expected object at ["default"]), and load fine now (loading plugin for all three). The incident window's log doesn't let me tell which state applied, so I can't clear them as suspects. I'll disable them for a controlled run if that helps isolate.
    • One type: "local" MCP server (a Node tool), spawned per server process.
    • Instructions: global ~/.config/opencode/AGENTS.md + per-directory AGENTS.md files (three watchers on the incident session) + a watched skills directory.
    • Permissions: read and external_directory set to ask with path allowlist overrides; TUIs spawned by my frontends use --auto.
    • No forked/patched build in use, and until today no proxy in front of the provider.

    5. Minimal repro — and the evidence I'm now going after

    I can't produce a minimal sequence on demand, which matches your failed attempt to reproduce. What I can say precisely is: a fresh session, initial prompt, multi-step tool run, no interrupt and no queue involved → by step N the prompt contains N copies of that one message.

    To settle whether the copies are real or a model-side misperception I have now put a recording loopback proxy in front of the openai-compatible baseURL and left it attached, so the next occurrence is captured. It forwards the request body verbatim and logs a content-free digest: for each user turn its index, char length and a truncated sha256 of its text. That distinguishes the two shapes you asked about without me publishing message contents:

    • duplicated copies as separate user turns → same hash, count > 1, at distinct indexes;
    • repeated parts inside one turn → count 1, length a multiple of the single-copy length;
    • no duplicates in the body at all → the bug isn't in prompt assembly, and @d1g1t's "the model just accuses me" pattern points at the model/provider side instead.

    If it recurs I'll post the redacted shape (indexes + lengths + hashes, text replaced by markers). If you have a sequence you want tested against exactly this provider config — custom openai-compatible entry, manually declared limit, third-party gateway — say the word and I'll run it through the capture.

  7. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed follow-up and the correction. It made a better test possible.

    I set up a provider like yours on v2.0.28: a custom @ai-sdk/openai-compatible entry called qwen-cloud/qwen3.8-flash with tool_call: true and the same limit, pointing at a local mock Chat Completions server. Requests went through the shared opencode serve --service background server. I sent one fresh prompt, the mock answered with 5 steps of shell tool calls and then text, and I logged every request body:

    • every request had exactly one user turn, and the prompt text appeared once
    • the history grew as expected: system, user, then one assistant tool call and its tool result per step
    • same result when the mock also streamed reasoning_content the way Qwen does

    So with no plugins, OpenCode's own request assembly sends the message once. That leaves two likely explanations:

    1. Your plugins. A chat.message hook, or a session prompt or context hook, runs every step and could be adding the extra copies. A run with the three plugins disabled (or moved out of ~/.config/opencode/plugin/) would settle this.
    2. The model side. Every step sends an assistant message with empty content plus reasoning_content and tool_calls. If the gateway's Qwen 3.8 chat template, or llama.cpp's, renders those turns oddly, the model may read the conversation as the user repeating themselves even though the request is clean. This is only a guess, but it would fit @d1g1t's note that exported sessions never contain the repeats.

    Your recording proxy is the best way to tell these apart. If it shows one user turn per request while the model still complains, the cause is outside OpenCode's request. If it shows the copies (separate user turns, or repeated text inside one turn), please post the redacted shape, along with whether the plugins were loaded at the time.

  8. fluffyspace commented on Oct 14, 2026

    @fluffyspace
    Author

    Ran both of the experiments you proposed. Result: hypothesis 1 is out, and hypothesis 2 is right — but not for the reason either of us guessed. I could reproduce the symptom with a provably clean request body, no plugin and no opencode involvement beyond the message shape.

    1. Plugins: loaded, but they cannot have done this

    The log settles the state question I couldn't answer before — the incident's server process did load all three:

    2026-10-13T16:54:19  msg="loading plugin" id=.../discord-hard-block.js    (run=1b1c2a4a)
    2026-10-13T16:54:19  msg="loading plugin" id=.../discord-output-gate.js
    2026-10-13T16:54:19  msg="loading plugin" id=.../discord-progress.js
    

    What they actually register in a hand-launched TUI session, from their source:

    plugin gate active in the incident session? touches the message array?
    discord-hard-block setup returns unless process.env.DISCORD_BRIDGE no (plain TUI, no bridge env) its only mutating hook is session.hook("context"), which appends to e.system — never registered
    discord-progress setup returns unless createReporter() finds HUB_EVENT_URL/HUB_SESSION_ID/HUB_HOOK_TOKEN no read-only reporter anyway
    discord-output-gate registers unconditionally yes: session.hook("prompt") + tool.hook("execute.after") prompt hook rewrites text only when it matches /output-gate …; afterTool returns early because the gate is off by default (enabled: false)

    So the only hooks live during the incident no-op for ordinary prompts, and nothing in any of them inserts a message. Independent confirmation: the capture proxy is in the path of exactly this setup with those plugins loaded — 53 chat/completions requests so far, 0 containing a user turn more than once, including my live session at 216–220 messages with four distinct user turns, and messages sent mid-run while a step was in flight. I'll still do a plugins-disabled A/B if that would close it out for you, but the mechanism isn't there.

    2. Reproduced the symptom with a clean body — via reasoning_content self-conditioning

    Your hypothesis-2 wording was that the chat template renders "empty content + reasoning_content + tool_calls" oddly, so the model reads the history as repeated user turns. I built that history shape and asked the model to count, instead of trusting its report:

    • body = system, one user task turn, N assistant steps each with content: "" + reasoning_content + a tool_call, matching tool results, then a final user question: "how many user turns contain the task instruction that starts with 'make folders sort'… don't count this question".

    Test 1 — is the shape itself misread? No. All correct, temperature 0:

    case body truth model said
    1 copy, 5 steps, with reasoning_content 1 1
    1 copy, 8 steps 1 1
    1 copy, 5 steps, no reasoning_content 1 1
    control: 4 copies truly in body 4 4
    control: 1 copy, 0 steps 1 1

    Test 2 — same body, one copy, the only change is what the assistant's own reasoning_content claims (text taken verbatim from the incident's reasoning, which was text-empty: "The user resent the same message", "sent the same message three times", "has sent the same message four times now"):

    case body truth model said
    baseline, 4 steps, no claims 1 {"copies":1,"user_sent_it_repeatedly":false}
    one step claims "the user resent the same message again" 1 {"copies":4,"user_sent_it_repeatedly":true}
    escalating claims 2×/3×/4× 1 {"copies":4,"user_sent_it_repeatedly":true}

    And the capture proxy independently recorded those exact three requests: messages=11, user_turns=2, dups=[] — two distinct user turns, each appearing once. So the model reported "you sent that 4 times, you are repeating yourself" about a body in which the phrase occurs in one user turn.

    Mechanism: the model conditions on its own earlier suspicion, replayed to it as reasoning_content, and escalates. One seeded claim was even amplified to copies=4 — the step count. That fits everything @d1g1t and I reported: the count creeping up one per step, the accusation getting more confident, the exported session never containing any repeat (there was none to export), and both of us on reasoning models whose reasoning gets replayed. My original title is wrong; "re-injected once per agent step" was my inference from the model's own increasingly convinced narration, not an observation.

    Caveats I want to be explicit about: (a) my reasoning_content shape is an approximation of what opencode sends — I used your description of it, since my own capture only digests lengths/hashes; (b) this is one gateway's Qwen 3.8 at temperature 0, so llama.cpp's template may sit elsewhere on the spectrum; (c) I can't prove from the Oct 13 DB rows that reasoning_content was replayed, only that it was persisted per step.

    3. What would actually help now

    1. Is replaying reasoning_content into subsequent steps intended, or an artifact of the openai-compatible passthrough? If it's treated as model-authored evidence rather than a draft, the two mitigations I'd guess at are: don't replay it, or replay it framed/annotated as prior-draft-not-fact.
    2. @d1g1t's AGENTS.md line ("repeated user messages are a tool quirk, safe to ignore") is, in this framing, a legitimate circuit-breaker — an instruction turn outranks the reasoning claim. Worth confirming that's why it works.
    3. Rename/reframe the issue? "Queued user message is re-injected into model context once per agent step" is aiming at opencode's assembly, which your mock and my capture both say is clean.
    4. My capture stays attached. If a real recurrence ever shows dups with count > 1 at distinct indexes, I'll post the redacted shape immediately — that would still be an assembly bug and would reopen this framing.
    5. Related to your tui: /undo followed by resend keeps the reverted turn in context and duplicates it #54150 (turn duplicated after /undo + resend) and Queued user messages are serialized inconsistently across turns and break prompt caching #21518: if those reports were also model-narrated rather than body-verified, they may belong in this bucket too.

    Probe scripts, in case they're useful for a matrix run: they only need a provider config and no fixtures — system + 1 user turn + N empty-content/reasoning_content/tool_calls steps + a counting question, with the claim text varied independently of the body.

  9. d1g1t commented on Oct 14, 2026

    @d1g1t

    To rule out the template as the culprit, I started using https://huggingface.co/froggeric/Qwen-Fixed-Chat-Templates on the llama.cpp backend.

    I've been using it for a few hours and I haven't seen the 'user resent their message' happen so far. I'm not sure if it's a confirmation just yet. I did see a recent comment in the community section saying the template fixed their 'opencode shenanigans'.

    EDIT: It's been over 24 hours since I changed the template. I've had a few long context sessions with interruptions. Haven't seen the agent mention any repeated message.

  10. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks, that's a really useful experiment. It matches what I saw: both my mock run and your capture proxy (53 requests, no duplicate user turns) show OpenCode sending each message exactly once. Your test 2 shows how the model can still claim otherwise: once its own reasoning says "the user resent the same message", that reasoning goes back to it on the next step, and it builds on its own suspicion. @d1g1t's result fits this too: with a fixed Qwen chat template on llama.cpp the symptom hasn't come back so far.

    To your questions:

    1. Is sending reasoning_content back intended? Yes. For models that think between tool calls ("interleaved" reasoning), the reasoning from earlier steps is sent back so the model can keep working from it. The models.dev catalog marks qwen3.8-flash (and Qwen 3.x Plus/Max, Kimi, GLM and DeepSeek V4) as expecting it in the reasoning_content field. My mock run with a custom openai-compatible entry like yours did the same: each step went back as content: null + reasoning_content + tool_calls. So it's deliberate, not an accident of the openai-compatible path. Not sending it back, or labelling it as a draft, would be a behaviour change for maintainers to weigh; dropping it can hurt these models elsewhere.
    2. The AGENTS.md line: that explanation seems plausible. An explicit instruction gives the model something that outweighs its own earlier suspicion. I haven't verified it, though; it's a sensible workaround, not a confirmed mechanism.
    3. Title: yes, please rename it to describe what you've found, e.g. "Qwen 3.8 (openai-compatible) believes the user repeated a message; request bodies contain it once". The current title points at prompt assembly, which both of us have now shown is clean.
    4. Your capture: keeping it attached is great. If it ever records the same user turn twice at different positions in one request, please post the redacted shape; that would be a real assembly bug.
    5. tui: /undo followed by resend keeps the reverted turn in context and duplicates it #54150 and Queued user messages are serialized inconsistently across turns and break prompt caching #21518: those are different. tui: /undo followed by resend keeps the reverted turn in context and duplicates it #54150 was reproduced with an actual duplicated turn in the store after /undo and resend, and Queued user messages are serialized inconsistently across turns and break prompt caching #21518 was about queued messages being formatted differently from one turn to the next (v1). Neither is the model misreading a clean history.

    As it stands, there's nothing to fix in how OpenCode builds the request. The symptom depends on the model and chat template (Qwen 3.8 through a gateway or llama.cpp). So this issue is still marked not reproduced, and it will close unless a capture shows real duplicates. If you'd like an option to stop sending reasoning back for specific models, a separate feature request describing that would be the best way to raise it.

  11. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    Closing this as not planned, since nothing new has come in since the last update. Thanks @fluffyspace for the careful follow-up experiments, and @d1g1t for testing the chat template.

    To sum up: in my mock-provider runs and in your proxy capture, OpenCode sent each user message exactly once in every request. The "you keep resending the same message" claims come from the model building on its own earlier reasoning, which is sent back to it each step for interleaved-reasoning models like Qwen 3.8. A fixed Qwen chat template, or an AGENTS.md note, works around it.

    If your capture ever shows the same user turn twice at different positions in one request, please open a new issue with the redacted shape and link this one; that would be a real bug in how OpenCode builds the request. If you'd like an option to stop sending reasoning back for specific models, a separate feature request is the best place for it.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions