diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c9a62c8645e0..6395d8cc4bac 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -99,28 +99,20 @@ export namespace LLM { // TODO: move this to a proper hook const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth" - const system: string[] = [] - system.push( - [ - // use agent prompt otherwise provider prompt - ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), - // any custom prompt passed into this call - ...input.system, - // any custom prompt from last user message - ...(input.user.system ? [input.user.system] : []), - ] - .filter((x) => x) - .join("\n"), - ) + const header = [...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model))] + .filter((x) => x) + .join("\n") + const tail = [...input.system, ...(input.user.system ? [input.user.system] : [])].filter((x) => x).join("\n") + const system = [header, tail].filter((x) => x) - const header = system[0] await Plugin.trigger( "experimental.chat.system.transform", { sessionID: input.sessionID, model: input.model }, { system }, ) - // rejoin to maintain 2-part structure for caching if header unchanged - if (system.length > 2 && system[0] === header) { + // Keep a stable header separate from the mutable tail so provider prompt caching + // can reuse the static prefix even when turn-level instructions change. + if (header && system.length > 2 && system[0] === header) { const rest = system.slice(1) system.length = 0 system.push(header, rest.join("\n")) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 1fa2e61eb241..209334e34e91 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -383,6 +383,93 @@ describe("session.llm.stream", () => { }) }) + test("keeps the system header separate from turn instructions", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "vivgrid" + const modelID = "gemini-3.1-pro-preview" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-system-split") + const agent = { + name: "test", + mode: "primary", + options: {}, + prompt: "Header prompt", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("user-system-split"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, + system: "Turn specific", + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["Context one", "Context two"], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + for await (const _ of stream.fullStream) { + } + + const body = (await request).body + const msgs = body.messages as Array<{ role: string; content: string }> + const system = msgs.filter((msg) => msg.role === "system") + + expect(system).toHaveLength(2) + expect(system[0]?.content).toBe("Header prompt") + expect(system[1]?.content).toBe("Context one\nContext two\nTurn specific") + }, + }) + }) + test("raw stream abort signal cancels provider response body promptly", async () => { const server = state.server if (!server) throw new Error("Server not initialized")