Skip to content

fix: collapse system prompt array into a single chat message - #47007

Closed
derrix060 wants to merge 1 commit into
anomalyco:devfrom
derrix060:fix/merge-system-prompt-into-single-message
Closed

fix: collapse system prompt array into a single chat message#47007
derrix060 wants to merge 1 commit into
anomalyco:devfrom
derrix060:fix/merge-system-prompt-into-single-message

Conversation

@derrix060

Copy link
Copy Markdown

Summary

fixes #47003. opencode was sending multiple system-role chat messages whenever a plugin used experimental.chat.system.transform to push onto the system prompt array, and some backends reject that outright.

Root cause

prepare() in packages/opencode/src/session/llm/request.ts built the messages array with system.map(x => ({role: "system", content: x})), one chat message per entry in the system string array. that array starts as a single joined string (base agent/provider prompt + session system text), but any plugin registered under experimental.chat.system.transform can push extra strings onto it, that's 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 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 whatever messages[] 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[] in request.ts. joins every non-empty entry of system with \n into exactly one system-role message (or zero messages if everything is empty), and prepare() now calls it instead of the old per-entry .map(). re-exported it from llm.ts as LLM.systemMessages, next to the existing LLM.hasToolCalls, same pattern as that one.

two small, intentional behavior deltas worth flagging for review, both are improvements not regressions:

  • if system ends up entirely empty/whitespace after joining, zero system messages are sent now instead of one message with empty content
  • an empty string entry pushed by a plugin gets skipped when joining, instead of becoming its own separate empty {role:"system", content:""} message

Testing

  • added 5 unit tests in packages/opencode/test/session/llm.test.ts, new describe("session.llm.systemMessages", ...) block right after the existing describe("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 strings
  • bun run typecheck clean
  • bun test test/session/llm.test.ts test/plugin/trigger.test.ts, 37/37 pass, no regressions
  • prettier and oxlint clean on the changed files (repo-wide oxlint has a bunch of pre-existing warnings and 1 pre-existing error in an unrelated file, not touched here)
  • manually verified end-to-end against the real self-hosted Qwen3/vLLM backend that surfaced this, request succeeds cleanly after the fix, where before it 400'd on every single request

Scope / follow-up

found the identical bug pattern in a second, unrelated call site, Agent.generate in packages/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

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
Copilot AI lite review requested due to automatic review settings September 3, 2026 08:09
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

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.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Potential Duplicates Found

PR #42801 - fix(opencode): coalesce system messages for OpenAI-compatible providers

  • Appears to address the same issue: coalescing/combining multiple system messages into one for compatibility with OpenAI-compatible providers

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 use systemMessages(...) instead of system.map(...) when building messages.
  • Adds unit tests for LLM.systemMessages and re-exports it from packages/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.

Comment on lines +218 to +221
export function systemMessages(system: string[]): ModelMessage[] {
const content = system.filter((x) => x).join("\n")
return content ? [{ role: "system", content }] : []
}
Comment on lines +181 to +183
test("returns no message when every entry is empty", () => {
expect(LLM.systemMessages(["", ""])).toEqual([])
})
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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.

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Sep 3, 2026
@github-actions github-actions Bot closed this Sep 3, 2026
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.

multiple system messages sent when a plugin uses experimental.chat.system.transform, breaks backends that require exactly one leading system message

2 participants