anomalyco / anomalyco/opencode

multiple system messages sent when a plugin uses experimental.chat.system.transform, breaks backends that require exactly one leading system message

Open
#47,003 1 comment 0 reactions 1 assignee View on GitHub

@rekram1-node is already working on this.

Since Sep 3, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

hit this while running opencode 1.18.27 against a self-hosted qwen3 backend (vllm, openai-compatible endpoint, behind a litellm gateway). every single request failed, and it took a bit to track down that it's not a plugin bug, it's how opencode builds the messages array.

environment

  • opencode 1.18.27
  • macOS (darwin)
  • custom openai-compatible provider ("litellm", using @ai-sdk/openai-compatible) pointed at a self-hosted vLLM backend serving Qwen3 (model name internal/default in opencode.json)
  • two plugins registered under experimental.chat.system.transform: the "caveman" plugin (github.com/JuliusBrussee/caveman), and separately any other plugin that also touches output.system

error

litellm.BadRequestError: Hosted_vllmException - {"error":{"message":"System message must be at the beginning.","type":"BadRequestError","param":null,"code":400}}

repro steps

  1. register any plugin under experimental.chat.system.transform that pushes a string onto output.system
  2. point the model at a backend that requires exactly one leading system-role message (qwen3's vllm chat template is the concrete case here, it rejects a second system message even though it's still ahead of the user turn)
  3. send any message
  4. request fails with the error above

with --pure (no plugins) it works fine. so I pointed opencode's baseURL at a local stub http server that just dumps the request body, to see what's actually going out.

  • with a plugin active: 3 messages, system (base prompt), system (230-char plugin-added content), user
  • with --pure: 2 messages, system, user

so it's not the plugin sending something malformed, it's opencode itself emitting two separate system-role messages instead of one.

root cause

traced to packages/opencode/src/session/llm/request.ts, function prepare, roughly lines 56-112 (current main branch). walking through it:

  1. system starts as a single-element array, the base prompt (agent prompt / SystemPrompt.provider, plus input.system, plus input.user.system) already .join("\n")'d into one string
  2. header = system[0] gets saved, then the experimental.chat.system.transform hook fires with { system }, letting plugins push additional strings onto the array
  3. there's a "collapse" safety net: if (system.length > 2 && system[0] === header) { ...collapse everything after index 0 into ONE extra entry... }. this only fires once there are 3+ entries. with exactly one plugin contributing one string (the common case), system.length is 2, so this condition is false and nothing collapses
  4. even when the collapse DOES fire, the result is still 2 array entries (header, collapsedRest), never 1
  5. finally: messages = [...system.map(x => ({ role: "system", content: x })), ...input.messages], this creates one separate chat message PER array entry

so by construction opencode sends 1 system message when no plugin touches output.system, but 2 the moment any plugin does. it never actually joins everything into a single system message, the collapse logic just reduces the count of extra entries, it doesn't merge them into the header.

worth noting this isn't a bug in @ai-sdk/openai-compatible, that package just serializes whatever messages[] it's handed. the multi-system-message behavior originates in opencode's own request prep.

impact

any backend that requires (or whose chat template assumes) exactly one leading system message breaks the instant a user has ANY plugin registered that adds to the system prompt, memory plugins, mode/persona plugins, ambient-context plugins, all pretty common. I ended up patching two separate local plugins individually to defensively merge into a single system entry before realizing the actual bug is upstream in opencode, not something every plugin author should have to work around on their own.

suggested fix

minimal, one line, join instead of map:

const messages = [
  { role: "system", content: system.join("\n") },
  ...input.messages,
]

this also makes the length > 2 collapse block (lines ~74-78) unnecessary, since joining always happens at the point of building messages. could probably remove it in the same change.

happy to open a PR with this if maintainers want it done this way instead of implementing it themselves, just let me know.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.