diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 22fe4d81cd40..f1f80adb39c9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1438,7 +1438,12 @@ export const layer = Layer.effect( instruction.system().pipe(Effect.orDie), MessageV2.toModelMessagesEffect(msgs, model), ]) - const system = [...env, ...instructions, ...(skills ? [skills] : [])] + // Cache-friendly order: place stable instructions/skills first so byte 0 of + // the assembled system payload is invariant across sessions, days, and users. + // The env block (which contains a per-session datetime, per-project cwd, and + // per-agent model id) goes at the tail so it cannot defeat prefix-cache hits + // on the upstream model. See anomalyco/opencode#20110, anomalyco/opencode#5224. + const system = [...instructions, ...(skills ? [skills] : []), ...env] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 06c71fa7dbdd..abd4b0108a95 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -49,7 +49,7 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context return [ [ - `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, + `\nYou are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, `Here is some useful information about the environment you are running in:`, ``, ` Working directory: ${ctx.directory}`, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 4c4647457814..adbbe911c36d 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,6 +1,7 @@ +import { readFileSync } from "fs" import { NodeFileSystem } from "@effect/platform-node" import { FetchHttpClient } from "effect/unstable/http" -import { expect } from "bun:test" +import { expect, test } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" @@ -2343,3 +2344,44 @@ noLLMServer.instance( }), 30_000, ) + +test("session/prompt: source assembles system array with env at tail for cache stability", () => { + // Enforce that the assembled `system` array in runLoop puts the volatile + // env block (cwd, datetime, platform, model id) at the END so byte 0 of + // the system payload is invariant across sessions, days, and users. + // This enables exact-prefix cache reuse on every provider opencode targets. + // Related: anomalyco/opencode#20110, anomalyco/opencode#5224. + const source = readFileSync( + path.resolve(__dirname, "../../src/session/prompt.ts"), + "utf8", + ) + const match = source.match( + /const system = \[\.\.\.(\w+),\s*\.\.\.\(skills \? \[skills\] : \[\]\),\s*\.\.\.(\w+)\]/, + ) + expect(match).not.toBeNull() + expect(match![1]).toBe("instructions") + expect(match![2]).toBe("env") +}) + +test("session/prompt: env block leads with \\n so the skills/env seam is a blank line", () => { + // The env block string returned by SystemPrompt.environment() must start + // with "\n" so that when request.ts joins the system array with "\n", the + // seam between and the env preamble becomes "\n\n" + // (a blank line). This makes the byte sequence at the boundary canonical + // and stable, enabling downstream prefix-cache hit rate to match what the + // LiteLLM opencode_reorder hook previously achieved server-side. + // + // We assert on the source of system.ts: the template-literal that opens + // the environment block must start with "\n" (a leading newline). + // Related: anomalyco/opencode#20110, anomalyco/opencode#5224. + const source = readFileSync( + path.resolve(__dirname, "../../src/session/system.ts"), + "utf8", + ) + // The environment() function builds the env string via a .join("\n") on an + // array. The first element of that array must be `"\nYou are powered by..." + // (i.e. the literal starts with a newline escape or a backtick-newline). + // We match the template literal opening that starts the env block string. + const match = source.match(/`\\nYou are powered by the model/) + expect(match).not.toBeNull() +})