Skip to content

Fix OpenCode plugin Jinja template corruption with Qwen3.6 - #1114

Open
atomicflag wants to merge 1 commit into
backnotprop:mainfrom
atomicflag:fix-system-transform-granular
Open

Fix OpenCode plugin Jinja template corruption with Qwen3.6#1114
atomicflag wants to merge 1 commit into
backnotprop:mainfrom
atomicflag:fix-system-transform-granular

Conversation

@atomicflag

Copy link
Copy Markdown

Issue

The OpenCode plugin's system.transform hook was corrupting OpenCode's Jinja template when injecting planning prompts. The error appeared as:

raise_exception('System message must be at the beginning.')

This occurred when using Qwen3.6 models with their chat template.

Root Cause

The plugin was pushing planning prompts and improvement context as separate elements in the output.system array:

output.system.push(getPlanningPrompt());
output.system.push(improveContext);

OpenCode maps each element of output.system to a separate system message. The Qwen3.6 Jinja template only allows one system message at position 0:

{%- for message in messages %}
    {%- if message.role == "system" %}
        {%- if not loop.first %}
            {{- raise_exception('System message must be at the beginning.') }}
        {%- endif %}
    {%- endif %}
{%- endfor %}

When the plugin pushed multiple elements, OpenCode created multiple system messages, causing the template to fail at position > 0.

Jinja template reference: https://huggingface.co/Qwen/Qwen3.6-35B-A3B-FP8/raw/main/chat_template.jinja

Solution

Instead of pushing new elements to output.system, the plugin now appends content to the first element (output.system[0]):

output.system[0] += "\n\n" + getPlanningPrompt();

This preserves the multi-message output.system array structure while keeping all injected content within the original system message (element 0). The Jinja template sees only one system message at position 0, containing all the plugin's additions.

Testing

  • Verified the fix works with Qwen3.6 models
  • All existing tests pass (10/10 in plan-mode.test.ts)
  • No breaking changes to other agents or models

…lement

The plugin previously pushes planning prompts and improvement contexts as
separate elements in the output.system array. This change appends them to
output.system[0] with newline separators instead. This keeps all system
instructions within a single message block to prevent potential parsing or
formatting issues when the agent processes the context.
@backnotprop

Copy link
Copy Markdown
Owner

Review (at 9d1f57fc)

Thanks for this, and welcome. The diagnosis is genuinely good work, and we verified it empirically against the real OpenCode binary rather than taking it on faith: LLMRequestPrep.prepare maps one output.system element to one {role:"system"} message exactly as you said, the array arrives pre-joined as a single element so every plugin push unavoidably creates a second system message, and OpenCode's own post-hook coalescer can never save the plan path (the strip mutates system[0], so its l[0] === a guard fails) and collapses to two messages at best elsewhere. Reproduction confirms main produces 2 to 3 system messages on all three paths and your shape produces 1, which is the only shape Qwen3.6's template accepts. You also covered all three push sites, including the improve-context path, and the PR still merges cleanly onto current main with no rebase needed.

Verdict: needs changes, small ones, all in the assignment mechanism rather than the design.

1. system[0] += can inject the literal string undefined. stripConflictingPlanModeRules ends in .filter(Boolean) and can return an empty array; then the push leaves output.system empty and output.system[0] += evaluates undefined + "\n\n" + ..., silently corrupting the prompt. Measured: the resulting first message begins undefined\n\n## Planning.... There is no type guard against this (the plugin has no tsconfig and sits outside the repo typecheck).

2. Index-assignment breaks when another plugin pushed first. Plugin hooks run sequentially on the same output object, so if any plugin ordered before this one pushed an entry, system[0] += splices the planning prompt into the middle of the base prompt and still leaves two elements: the Qwen fix silently fails and the prompt is reordered. push at least preserved order.

Both fix with the same collapse-with-order shape, about six lines total across the same hunks:

// plan path
const parts = [...stripConflictingPlanModeRules(output.system), getPlanningPrompt()];
if (improveContext) parts.push(improveContext);
output.system.length = 0;
output.system.push(parts.filter(Boolean).join("\n\n"));

// generic path
const merged = [...output.system, planSubmissionReminder].filter(Boolean).join("\n\n");
output.system.length = 0;
output.system.push(merged);

3. One ask alongside the fix: extract the merge into an exported pure helper (for example composeSystemPrompt(system: string[], additions: string[]): string[] in plan-mode.ts) and add a small table test to the existing plan-mode.test.ts: always one element out, the empty-strip case, the multi-element order case, and content order. The transform body is inline in the plugin factory today, which is why nothing covers these lines.

For the record on blast radius, judged acceptable: the change is unconditional across models, content is byte-identical with only the partitioning changed, and the one cache nuance (losing OpenCode's base-prompt/additions split on the generic path) is a fair trade since the appended text is constant within a session.

CI never ran on this fork PR; approving the workflows now. Happy to re-review as soon as the rewrite is pushed.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants