fix: collapse system prompt array into a single chat message - #47007
fix: collapse system prompt array into a single chat message#47007derrix060 wants to merge 1 commit into
Conversation
prepare() built the outgoing messages array with one system-role chat message per entry in the `system` string array. that array starts as a single joined string, but any plugin registered under experimental.chat.system.transform can push extra strings onto it, which is the documented way for plugins to extend the system prompt. so the moment a plugin pushed onto output.system, opencode sent 2+ separate system messages instead of 1. some backends reject a second system-role message outright, even though it's still ahead of the user turn. concretely, a self-hosted Qwen3 model served via vLLM behind a LiteLLM gateway rejects these requests with "System message must be at the beginning". not a bug in @ai-sdk/openai-compatible, it just serializes whatever messages[] it's handed, the multi-message behavior originates in opencode's own request-preparation code. extracted systemMessages(system: string[]): ModelMessage[] in request.ts, joins every non-empty entry with \n into exactly one system-role message (zero if everything is empty), and prepare() now calls it instead of the old per-entry .map(). re-exported from llm.ts as LLM.systemMessages next to the existing LLM.hasToolCalls, same pattern. added 5 unit tests in llm.test.ts: empty array, array of empty strings, single entry, multiple entries joined into one message (the regression case), and entries mixed with empty strings. verified: bun run typecheck clean, bun test test/session/llm.test.ts test/plugin/trigger.test.ts (37/37 pass), prettier and oxlint clean on the touched files. also tested end-to-end against the real Qwen3/vLLM backend that surfaced this, request succeeds now where it 400'd before. fixes anomalyco#47003 Claude-Session: https://claude.ai/code/session_01XKkeDmhtJ7HwUzfsSMJW4p
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 2 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
|
The following comment was made by an LLM, it may be inaccurate: Potential Duplicates FoundPR #42801 -
This PR is potentially addressing the identical problem already fixed in a previous PR. I recommend reviewing #42801 to understand the prior approach and whether this PR duplicates that work or improves upon it. |
There was a problem hiding this comment.
🟡 Changes recommended
systemMessages currently does not treat whitespace-only entries as empty (contradicting the PR description and intended behavior), and the tests should cover that edge case.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes an LLM request-shaping incompatibility where system: string[] was serialized into multiple role: "system" chat messages after plugins appended to the array, which breaks strict OpenAI-compatible backends that require exactly one leading system message.
Changes:
- Introduces
systemMessages(system: string[]): ModelMessage[]to collapse the system prompt array into a single leading system chat message (or none if empty). - Updates
prepare()to usesystemMessages(...)instead ofsystem.map(...)when buildingmessages. - Adds unit tests for
LLM.systemMessagesand re-exports it frompackages/opencode/src/session/llm.ts.
File summaries
| File | Description |
|---|---|
| packages/opencode/src/session/llm/request.ts | Adds systemMessages helper and switches request preparation to emit a single system message. |
| packages/opencode/src/session/llm.ts | Re-exports systemMessages alongside existing LLM utilities for external use. |
| packages/opencode/test/session/llm.test.ts | Adds unit tests covering the expected collapsing behavior of system prompts into one message. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| export function systemMessages(system: string[]): ModelMessage[] { | ||
| const content = system.filter((x) => x).join("\n") | ||
| return content ? [{ role: "system", content }] : [] | ||
| } |
| test("returns no message when every entry is empty", () => { | ||
| expect(LLM.systemMessages(["", ""])).toEqual([]) | ||
| }) |
|
This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window. Feel free to open a new pull request that follows our guidelines. |
Summary
fixes #47003. opencode was sending multiple system-role chat messages whenever a plugin used
experimental.chat.system.transformto push onto the system prompt array, and some backends reject that outright.Root cause
prepare()inpackages/opencode/src/session/llm/request.tsbuilt the messages array withsystem.map(x => ({role: "system", content: x})), one chat message per entry in thesystemstring array. that array starts as a single joined string (base agent/provider prompt + session system text), but any plugin registered underexperimental.chat.system.transformcanpushextra strings onto it, that's the documented way for plugins to extend the system prompt. so the moment a plugin pushed ontooutput.system, opencode sent 2+ separate system messages instead of 1.some backends reject a second system-role message outright, even when it's still ahead of the user turn. concretely, a self-hosted Qwen3 model served via vLLM behind a LiteLLM gateway rejects these requests with
"System message must be at the beginning". worth being clear this isn't a bug in@ai-sdk/openai-compatible, it just serializes whatevermessages[]it's handed, the multi-message behavior originates in opencode's own request-preparation code.Fix
extracted a pure exported helper
systemMessages(system: string[]): ModelMessage[]inrequest.ts. joins every non-empty entry ofsystemwith\ninto exactly one system-role message (or zero messages if everything is empty), andprepare()now calls it instead of the old per-entry.map(). re-exported it fromllm.tsasLLM.systemMessages, next to the existingLLM.hasToolCalls, same pattern as that one.two small, intentional behavior deltas worth flagging for review, both are improvements not regressions:
systemends up entirely empty/whitespace after joining, zero system messages are sent now instead of one message with empty content{role:"system", content:""}messageTesting
packages/opencode/test/session/llm.test.ts, newdescribe("session.llm.systemMessages", ...)block right after the existingdescribe("session.llm.hasToolCalls", ...)block, same style: empty array, array of empty strings, single entry, multiple entries joined into one message (the regression test, with a comment explaining why), entries mixed with empty stringsbun run typecheckcleanbun test test/session/llm.test.ts test/plugin/trigger.test.ts, 37/37 pass, no regressionsScope / follow-up
found the identical bug pattern in a second, unrelated call site,
Agent.generateinpackages/opencode/src/agent/agent.ts(used when generating a new sub-agent's config from a description) does the same one-message-per-entry construction. deliberately NOT touched in this PR, different feature I didn't reproduce or test end-to-end, keeping this minimal and reviewable. flagging it as a known follow-up for maintainers or a later PR.Ref: #47003
https://claude.ai/code/session_01XKkeDmhtJ7HwUzfsSMJW4p