anomalyco / anomalyco/opencode
multiple system messages sent when a plugin uses experimental.chat.system.transform, breaks backends that require exactly one leading system message
@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 nameinternal/defaultin opencode.json) - two plugins registered under
experimental.chat.system.transform: the "caveman" plugin (github.com/JuliusBrussee/caveman), and separately any other plugin that also touchesoutput.system
error
litellm.BadRequestError: Hosted_vllmException - {"error":{"message":"System message must be at the beginning.","type":"BadRequestError","param":null,"code":400}}
repro steps
- register any plugin under
experimental.chat.system.transformthat pushes a string ontooutput.system - 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)
- send any message
- 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:
systemstarts as a single-element array, the base prompt (agent prompt /SystemPrompt.provider, plusinput.system, plusinput.user.system) already.join("\n")'d into one stringheader = system[0]gets saved, then theexperimental.chat.system.transformhook fires with{ system }, letting plugins push additional strings onto the array- 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.lengthis 2, so this condition is false and nothing collapses - even when the collapse DOES fire, the result is still 2 array entries (header, collapsedRest), never 1
- 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.