diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f53040..532e9ee 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.9 — 2026-05-18 > This release makes the goal plugin much more reliable for real unattended use. Goals now persist across restarts, recover in a safe paused state, expose better status/history visibility, and use smarter no-progress detection to avoid premature stalls. It also hardens persistence with atomic writes, stricter file permissions, and regression tests around corrupt or missing state. diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 30c66f6..59cc5f8 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -1059,15 +1059,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 066d8c5..97dd765 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -185,6 +185,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"](