From 3b7e583f78c9aa4bd7ec9407c125f85a81426739 Mon Sep 17 00:00:00 2001 From: fank Date: Wed, 27 May 2026 18:57:09 +0200 Subject: [PATCH] fix: merge goal continuation into primary system block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `experimental.chat.system.transform` hook previously pushed the goal continuation as a separate `role: "system"` entry. On backends that enforce the OpenAI convention of exactly one system message at index 0 (Qwen on vLLM, several Llama.cpp / Mistral templates), this produced an HTTP 400 "System message must be at the beginning." error. Merge the goal block into the existing primary system entry instead. When the system array is empty, fall back to pushing — preserving current behavior for callers that rely on goal-plugin owning the entire system prompt. Behavior on lenient backends (OpenAI, Anthropic) is unchanged: identical goal content reaches the model, just collapsed into one system message. Validated locally against opencode 1.15.11 + Qwen3.5-122B-A10B on vLLM: the exact reproducer from issue #1 (`/goal draw a ball`) goes from HTTP 400 to a successful tool-calling step with no other changes. Tests: - Existing "system transform is idempotent" still passes. - New "merges into existing system block instead of adding a second one" pins the bug fix. - New "pushes a new block when system array is empty" pins the fallback. Closes #1. --- CHANGELOG.md | 4 ++++ src/goal-plugin.js | 22 +++++++++++++--------- test/goal-plugin.test.js | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efa2264..06dd08c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Fix `experimental.chat.system.transform` to merge the goal continuation block into the primary system entry instead of pushing a separate one. Prevents `"System message must be at the beginning."` errors on strict-template backends (Qwen on vLLM, several Llama.cpp/Mistral templates). See issue #1. + ## 0.1.8 — 2026-05-18 - Harden `--max-minutes` fallback arithmetic when mixed with millisecond duration overrides. diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 4885d9c..29fdd38 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -656,15 +656,19 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => { if (goal.stopped) return if (output.system.some((block) => block.includes(""))) return - output.system.push( - [ - 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"), - ) + 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}` + } }, } } diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 06083aa..ed9539d 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -150,6 +150,39 @@ test("system transform is idempotent", async () => { assert.match(output.system[0], /\nship it\n<\/goal_objective>/) }) +test("system transform merges into existing system block instead of adding a second one", async () => { + const { hooks } = await createHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + + const basePrompt = "You are opencode, a coding assistant." + const output = { system: [basePrompt] } + await hooks["experimental.chat.system.transform"]({ sessionID: "session-1" }, output) + + // Strict-template backends (e.g. Qwen on vLLM) reject any request with more + // than one role:"system" message. The goal block must be merged into the + // existing primary system entry, not pushed as a second array entry. + assert.equal(output.system.length, 1) + assert.ok(output.system[0].startsWith(basePrompt)) + assert.match(output.system[0], /\nship it\n<\/goal_objective>/) +}) + +test("system transform pushes a new block when system array is empty", async () => { + const { hooks } = await createHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + + const output = { system: [] } + await hooks["experimental.chat.system.transform"]({ sessionID: "session-1" }, output) + + assert.equal(output.system.length, 1) + assert.match(output.system[0], /\nship it\n<\/goal_objective>/) +}) + test("session.status idle auto-continues once", async () => { const { calls, hooks } = await createHooks({ options: { minDelayMs: 1 } }) await hooks["command.execute.before"](