Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
22 changes: 13 additions & 9 deletions src/goal-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -1059,15 +1059,19 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
if (goal.stopped) return
if (output.system.some((block) => block.includes("<goal_objective>"))) 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}`
}
},
}
}
Expand Down
33 changes: 33 additions & 0 deletions test/goal-plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,39 @@ test("system transform is idempotent", async () => {
assert.match(output.system[0], /<goal_objective>\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], /<goal_objective>\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], /<goal_objective>\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"](
Expand Down