Repository navigation
Description
Summary
opencode-goal-plugin injects its <goal_continuation> block by calling output.system.push(...) inside the experimental.chat.system.transform hook. On any backend that enforces the OpenAI convention of exactly one role: "system" message at index 0, the resulting request is rejected.
The opencode community has already documented that plugins using this hook should merge their content into the existing primary system block rather than push a new array entry (see anomalyco/opencode#23660, anomalyco/opencode#15059). Goal plugin v0.1.8 still uses push.
Where the bug is
src/goal-plugin.js, line 659 (v0.1.8):
"experimental.chat.system.transform": async (input, output) => {
if (!input.sessionID) return
const goal = goalStates.get(input.sessionID)
if (!goal) return
if (goal.stopped) return
if (output.system.some((block) => block.includes("<goal_objective>"))) return
output.system.push( // ← problem
[
buildGoalBlock(goal),
"Keep working until the goal is fully satisfied.",
"When fully satisfied, end the response with `[goal:complete]`.",
"If user input is required, explain the blocker in the line immediately before `[goal:blocked]`.",
buildLimitWarning(goal),
].filter(Boolean).join("\n"),
)
}The result on the wire (captured from opencode's debug log against my Qwen3.5-122B vLLM endpoint):
"messages": [
{ "role": "system", "content": "You are opencode, an interactive CLI tool ..." },
{ "role": "system", "content": "<goal_continuation>\n...\n<goal_objective>\ndraw a ball\n</goal_objective>\n..." },
{ "role": "user", "content": "draw a ball" }
]Two consecutive role: "system" messages. The Qwen3.5 chat template (and several other vLLM-served model templates) explicitly rejects this:
{%- if message.role == "system" -%}
{%- if not loop.first -%}
{{- raise_exception('System message must be at the beginning.') -}}
{%- endif -%}vLLM returns HTTP 400 with the exception text in the response body. opencode propagates this to the user as AI_APICallError.
Steps to reproduce
- Install
opencode-goal-plugin(tested v0.1.8) into opencode (tested v1.15.11). - Configure an OpenAI-compatible provider pointing at a vLLM server hosting any Qwen3.5 model with the upstream chat template (e.g. Qwen3.5-122B-A10B).
- In an opencode session, run
/goal draw a ball(any goal text works). - opencode reports
AI_APICallErrorwith the response body\"System message must be at the beginning.\".
Affected backends
This is not Qwen-specific — every backend that enforces a single-system-message invariant in its chat template is affected:
- Qwen3.5 / Qwen3.6 family on vLLM (see Multiple system prompts break Qwen3.5-* models anomalyco/opencode#15059, #16560, #20785, #20813)
- Several Llama.cpp templates
- Some Mistral models (Ministral 3 support: error "Only user, assistant and tool roles are supported, got system" anomalyco/opencode#5034, #18998)
OpenAI's and Anthropic's hosted APIs silently tolerate multiple system messages, which is why this bug is invisible to users on those providers.
Suggested fix
Merge the goal block into the existing first system entry instead of pushing a new one. Minimal change:
"experimental.chat.system.transform": async (input, output) => {
if (!input.sessionID) return
const goal = goalStates.get(input.sessionID)
if (!goal) return
if (goal.stopped) return
if (output.system.some((block) => block.includes("<goal_objective>"))) return
const goalBlock = [
buildGoalBlock(goal),
"Keep working until the goal is fully satisfied.",
"When fully satisfied, end the response with `[goal:complete]`.",
"If user input is required, explain the blocker in the line immediately before `[goal:blocked]`.",
buildLimitWarning(goal),
].filter(Boolean).join("\n")
if (output.system.length === 0) {
output.system.push(goalBlock)
} else {
output.system[0] = `${output.system[0]}\n\n${goalBlock}`
}
}This:
- Preserves all current goal-plugin content and behavior.
- Produces exactly one
role: "system"message on the wire. - Works on every backend (strict and lenient).
- Matches the documented community guidance in Clarify plugin guidance for experimental.chat.system.transform to avoid extra system messages anomalyco/opencode#23660.
Related
- Multiple system prompts break Qwen3.5-* models anomalyco/opencode#15059 — Multiple system prompts break Qwen3.5- models* (canonical bug, open)
- Clarify plugin guidance for experimental.chat.system.transform to avoid extra system messages anomalyco/opencode#23660 — Clarify plugin guidance for
experimental.chat.system.transform(asks for exactly this guidance, open) - fix(opencode): collapse system prompt fragments into a single system message cyrilmhansen/opencode#2 — upstream coalescer fix merged to fork on 2026-03-26 but not yet landed on
anomalyco/opencodedev. Until upstream lands a coalescer, plugins thatpushwill keep breaking strict-template backends.
Happy to send a PR if helpful.
Environment
- opencode: 1.15.11
- opencode-goal-plugin: 0.1.8
- Provider:
@ai-sdk/openai-compatible-> self-hosted vLLM 0.19.x - Model: Qwen3.5-122B-A10B Hybrid INT4/FP8
- Chat template: enhanced Qwen3.5 Jinja from
allanchan339/vLLM-Qwen3-3.5-3.6-chat-template-fix(sameraise_exceptionguard exists in the upstream Qwen template)
Activity
fank commentedon May 27, 2026
Cross-link: a fresh repro of the same root cause against opencode 1.15.11 with this plugin has been posted upstream at anomalyco/opencode#15059 (this comment). That issue tracks the off-by-one in opencode's plugin-system coalescer.
The two fixes are independent and complementary:
output.system[0]defensively. Unblocks users on strict-template backends immediately, regardless of upstream state.pushper the hook's natural shape.fank commentedon May 27, 2026
Validated the proposed fix locally against the exact reproducer.
A/B test, same command (
opencode run --command goal …), same backend, only the plugin source differs:output.system.push(...)(current v0.1.8){"message":"System message must be at the beginning.","type":"BadRequestError","code":400}output.system[0](proposed fix)writetool, completed step cleanly, auto-continued to next iterationToken usage in the passing case: 9120 input / 283 output — confirming both the base system prompt and the goal continuation block reached the model (just collapsed into a single
role: "system"message instead of two).The patch I tested is the one in the issue body:
No behavior change for users on lenient backends (OpenAI/Anthropic), and unblocks every strict-template backend in one ~6-line change. Happy to send this as a PR if it'd help.
fank commentedon May 27, 2026
PR opened: #2 — applies the fix, adds two regression tests (merge case + empty-array fallback), passes
npm run check(35/35) andnpm run pack:check. Validated against the same Qwen3.5-122B vLLM reproducer described above.fix: merge goal continuation into primary system block
Merge pull request #2 from fank/fix/merge-system-block-instead-of-push
johnson-johns-codes-openez commentedon Aug 14, 2026
Verified fixed in current releases — this can be closed.
The failure mode described here (a second
role: "system"entry pushed viaoutput.system.push(...)) was fixed in commite84ddcc("Harden goal plugin verification and compatibility", 2026-06-04), first shipped in v0.1.11 and still present in the current release (I verified against the installed v0.8.1).Current behavior of
experimental.chat.system.transforminsrc/goal-plugin.js:appendGoalToSystemBlock, which handles plain strings,{ text }objects,{ content: string }, and{ content: array }shapes; only if the block matches none of those does it fall back tounshiftas a separate entry.Result: exactly one
role: "system"message on the wire, so strict-template backends (Qwen3.5 family on vLLM, Llama.cpp templates, etc.) are satisfied.Regression coverage in
test/goal-plugin.test.js:output.system.length === 1, the primary system block is preserved, and the goal block content is merged into it.Suggest closing this issue; the reporter's proposed fix direction is exactly what shipped (plus object/array content-shape hardening).