diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 3fd0eaeb..4087dc7e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -129,6 +129,59 @@ function normalizeMessages( } }) + // Consolidate leading system messages and lower non-leading system messages to prevent + // Jinja chat template exceptions ("System message must be at the beginning.") on + // OpenAI-compatible, Ollama, vLLM, and local models. + const isAnthropic = + model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic" + + const leadingSystem: string[] = [] + let inLeadingSystem = true + const processed: ModelMessage[] = [] + + for (const msg of msgs) { + if (msg.role === "system") { + const content = typeof msg.content === "string" ? msg.content : "" + if (inLeadingSystem) { + if (isAnthropic) { + processed.push(msg) + } else { + if (content.trim().length > 0) { + leadingSystem.push(content) + } + } + } else { + // Non-leading system message in conversation history: + // Lower to user-compatible block to preserve chronological ordering + // without violating chat template constraints. + processed.push({ + role: "user", + content: `\n${content}\n`, + }) + } + } else { + if (inLeadingSystem) { + inLeadingSystem = false + if (!isAnthropic && leadingSystem.length > 0) { + processed.unshift({ + role: "system", + content: leadingSystem.join("\n\n"), + }) + } + } + processed.push(msg) + } + } + + if (inLeadingSystem && !isAnthropic && leadingSystem.length > 0) { + processed.unshift({ + role: "system", + content: leadingSystem.join("\n\n"), + }) + } + + msgs = processed + // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4ac098b2..e39bb90e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -367,7 +367,7 @@ const live: Layer.Layer< { specificationVersion: "v3" as const, async transformParams(args) { - if (args.type === "stream") { + if (args.type === "stream" || args.type === "generate") { // @ts-expect-error args.params.prompt = ProviderTransform.message( args.params.prompt, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 1d88b08c..2e85db35 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4740,3 +4740,111 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) }) }) + +describe("ProviderTransform.message - system message consolidation and ordering", () => { + const openaiCompatibleModel = { + id: "ollama/qwen3.8-27b", + providerID: "ollama", + api: { + id: "qwen3.8-27b", + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { input: { text: true, image: false } }, + } as any + + const anthropicModel = { + id: "anthropic/claude-sonnet-4-20250514", + providerID: "anthropic", + api: { + id: "claude-sonnet-4-20250514", + npm: "@ai-sdk/anthropic", + }, + capabilities: { input: { text: true, image: true } }, + } as any + + test("consolidates multiple leading system messages into a single system message for OpenAI-compatible providers", () => { + const msgs = [ + { role: "system", content: "You are an AI pentesting agent." }, + { role: "system", content: "\nPhase: recon\n" }, + { role: "user", content: "Scan 10.10.10.5" }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiCompatibleModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].role).toBe("system") + expect(result[0].content).toBe( + "You are an AI pentesting agent.\n\n\nPhase: recon\n", + ) + expect(result[1].role).toBe("user") + expect(result[1].content).toBe("Scan 10.10.10.5") + }) + + test("lowers non-leading system messages into user-compatible blocks", () => { + const msgs = [ + { role: "system", content: "Base system prompt." }, + { role: "user", content: "First question" }, + { role: "assistant", content: "First answer" }, + { role: "system", content: "Compaction summary or chronological update" }, + { role: "user", content: "Second question" }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiCompatibleModel, {}) + + expect(result).toHaveLength(5) + expect(result[0].role).toBe("system") + expect(result[0].content).toBe("Base system prompt.") + expect(result[1].role).toBe("user") + expect(result[2].role).toBe("assistant") + expect(result[3].role).toBe("user") + expect(result[3].content).toBe( + "\nCompaction summary or chronological update\n", + ) + expect(result[4].role).toBe("user") + expect(result[4].content).toBe("Second question") + }) + + test("preserves distinct leading system blocks for Anthropic prompt caching", () => { + const msgs = [ + { role: "system", content: "Static system prompt" }, + { role: "system", content: "Volatile engagement context" }, + { role: "user", content: "Hello" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(3) + expect(result[0].role).toBe("system") + expect(result[0].content).toBe("Static system prompt") + expect(result[1].role).toBe("system") + expect(result[1].content).toBe("Volatile engagement context") + expect(result[2].role).toBe("user") + }) + + test("handles single leading system message without duplication", () => { + const msgs = [ + { role: "system", content: "Sole system message" }, + { role: "user", content: "Hello" }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiCompatibleModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].role).toBe("system") + expect(result[0].content).toBe("Sole system message") + expect(result[1].role).toBe("user") + }) + + test("handles message sequence without any system message", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi" }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiCompatibleModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].role).toBe("user") + expect(result[1].role).toBe("assistant") + }) +})