From 01f4c63f20e88df7be10127cb8ff52fcdc908c43 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 15 Oct 2026 10:33:30 -0700 Subject: [PATCH 1/2] fix(session): keep prompt cache prefixes stable across working directories - Pass the environment section (directory, workspace root, date) to `LLM.stream` separately and append it as a trailing system message after the cache-marked instructions - Remove the instance directory from the `bash` tool `workdir` description and drop the no-op `${directory}` substitution, so serialized tool schemas are identical across directories - Add regression tests for the bash schema, the prompt loop wiring, and Anthropic/Bedrock request assembly Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/session/llm.ts | 7 + packages/opencode/src/session/prompt.ts | 7 +- packages/opencode/src/tool/bash.ts | 9 +- .../test/session/llm-system-cache.test.ts | 162 ++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 49 ++++++ packages/opencode/test/tool/bash.test.ts | 59 ++++++- 6 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/test/session/llm-system-cache.test.ts diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 62804161a3..a43ffce15e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -52,6 +52,9 @@ export namespace LLM { model: Provider.Model agent: Agent.Info system: string[] + // altimate_change start — upstream_fix: keep ambient context outside the reusable system prefix + environment?: string[] + // altimate_change end abort: AbortSignal messages: ModelMessage[] small?: boolean @@ -132,6 +135,10 @@ export namespace LLM { system.push(header, rest.join("\n")) } + // altimate_change start — upstream_fix: preserve a cache breakpoint before directory and date + if (input.environment?.length) system.push(input.environment.join("\n")) + // altimate_change end + const variant = !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {} const base = input.small diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 554f8a1e22..de29c9cb22 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1650,7 +1650,9 @@ export namespace SessionPrompt { const workspaceEngineNotice = workspaceNotice.text() // altimate_change end const system = [ - ...(await SystemPrompt.environment(model)), + // altimate_change start — upstream_fix: environment is a separate trailing system message + // Keep directory and date out of the reusable instructions assembled here. + // altimate_change end // altimate_change start — workspace identity directive. Placed early (not // trailing) per the placement finding in session/system.ts: content near the // front of a section is treated as binding, trailing content as background. @@ -1726,6 +1728,9 @@ export namespace SessionPrompt { abort, sessionID, system, + // altimate_change start — upstream_fix: append ambient context after the cached instructions + environment: await SystemPrompt.environment(model), + // altimate_change end messages: [ ...(await MessageV2.toModelMessages(msgs, model)), ...(isLastStep diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 47e169f068..52bea98df4 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -113,17 +113,20 @@ export const BashTool = Tool.define("bash", async () => { log.info("bash tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) - .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) + // altimate_change start — upstream_fix: keep tool descriptions stable across working directories. + description: DESCRIPTION.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), + // altimate_change end parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), workdir: z .string() + // altimate_change start — upstream_fix: resolve the default directory only during execution. .describe( - `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, + "The working directory to run the command in. Defaults to the session's working directory. Use this instead of 'cd' commands.", ) + // altimate_change end .optional(), description: z .string() diff --git a/packages/opencode/test/session/llm-system-cache.test.ts b/packages/opencode/test/session/llm-system-cache.test.ts new file mode 100644 index 0000000000..8fe9eb869c --- /dev/null +++ b/packages/opencode/test/session/llm-system-cache.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { MockLanguageModelV3 } from "ai/test" +import { jsonSchema, simulateReadableStream, tool } from "ai" +import { Auth } from "../../src/auth" +import { Config } from "../../src/config/config" +import { Plugin } from "../../src/plugin" +import { Instance } from "../../src/project/instance" +import { ProjectID } from "../../src/project/schema" +import { Provider } from "../../src/provider/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { LLM } from "../../src/session/llm" +import { MessageID, SessionID } from "../../src/session/schema" +import { SystemPrompt } from "../../src/session/system" +import { tmpdir } from "../fixture/fixture" + +describe("session system cache prefix", () => { + for (const providerID of ["anthropic", "amazon-bedrock"]) { + for (const pluginInstructions of [false, true]) { + test(`${providerID} keeps a marked prefix across directories with plugin instructions ${pluginInstructions}`, async () => { + const model: Provider.Model = { + id: ModelID.make("claude-test"), + providerID: ProviderID.make(providerID), + api: { + id: "claude-test", + url: "https://example.invalid", + npm: providerID === "anthropic" ? "@ai-sdk/anthropic" : "@ai-sdk/amazon-bedrock", + }, + name: "Test model", + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 4096 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + } + const language = new MockLanguageModelV3({ + provider: providerID, + modelId: model.api.id, + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { + type: "finish" as const, + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }) + const mocks = [ + spyOn(Provider, "getLanguage").mockResolvedValue(language), + spyOn(Provider, "getProvider").mockResolvedValue({ + id: model.providerID, + name: "Test provider", + source: "config", + env: [], + options: {}, + models: {}, + }), + spyOn(Config, "get").mockResolvedValue({}), + spyOn(Auth, "get").mockResolvedValue(undefined), + spyOn(Plugin, "trigger").mockImplementation(async (name, _input, output) => { + if (name === "experimental.chat.system.transform" && pluginInstructions) { + ;(output as { system: string[] }).system.push("Plugin rules.", "Additional plugin rules.") + } + return output + }), + ] + try { + await using first = await tmpdir() + await using second = await tmpdir() + const directories = [first.path, second.path] + for (const [index, directory] of directories.entries()) { + await Instance.restore( + { + directory, + worktree: directory, + project: { + id: ProjectID.global, + worktree: directory, + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }, + async () => { + const sessionID = SessionID.make(`ses_directory_${index}`) + const result = await LLM.stream({ + sessionID, + model, + agent: { name: "test", mode: "primary", options: {}, permission: [] }, + user: { + id: MessageID.make(`msg_directory_${index}`), + sessionID, + role: "user", + agent: "test", + model: { providerID: model.providerID, modelID: model.id }, + time: { created: 0 }, + system: "Custom instructions.", + }, + system: ["Project instructions.", "Skill instructions."], + environment: await SystemPrompt.environment(model), + messages: [{ role: "user", content: "Inspect the project." }], + tools: { + inspect: tool({ description: "Inspect the project", inputSchema: jsonSchema({ type: "object" }) }), + }, + abort: new AbortController().signal, + }) + await result.consumeStream({ + onError: (error) => { + throw error + }, + }) + }, + ) + } + + expect(language.doStreamCalls).toHaveLength(2) + const systems = language.doStreamCalls.map((call) => + call.prompt.filter((message) => message.role === "system"), + ) + expect(systems[0]).toHaveLength(pluginInstructions ? 3 : 2) + expect(JSON.stringify(systems[0].slice(0, -1))).toBe(JSON.stringify(systems[1].slice(0, -1))) + expect(JSON.stringify(language.doStreamCalls[0].tools)).toBe(JSON.stringify(language.doStreamCalls[1].tools)) + for (const [index, system] of systems.entries()) { + const prefix = system.slice(0, -1) + expect(prefix[0].content).toContain("Project instructions.\nSkill instructions.\nCustom instructions.") + expect(prefix[0].providerOptions?.[providerID === "anthropic" ? "anthropic" : "bedrock"]).toEqual( + providerID === "anthropic" + ? { cacheControl: { type: "ephemeral" } } + : { cachePoint: { type: "default" } }, + ) + if (pluginInstructions) expect(prefix[1].content).toBe("Plugin rules.\nAdditional plugin rules.") + expect(JSON.stringify(prefix)).not.toContain(directories[index]) + expect(JSON.stringify(prefix)).not.toContain("Today's date:") + expect(system.at(-1)?.content).toContain(`Working directory: ${directories[index]}`) + expect(system.at(-1)?.content).toContain(`Workspace root folder: ${directories[index]}`) + expect(system.at(-1)?.content).toContain(`Today's date: ${new Date().toDateString()}`) + const conversation = language.doStreamCalls[index].prompt.filter((message) => message.role !== "system") + expect(JSON.stringify(conversation)).not.toContain("") + } + } finally { + for (const mock of mocks) mock.mockRestore() + } + }) + } + } +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 9acfc9c6dc..4cae5cb3ea 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -477,6 +477,55 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { return { prompt, run, sessions, chat } }) +noLLMServer.instance( + "prompt loop separates ambient environment from cached instructions and user content", + () => Effect.gen(function* () { + const { directory } = yield* TestInstance + const captured: Pick[] = [] + const stream = spyOn(LLM, "stream").mockImplementation(async (input) => { + captured.push({ + system: [...input.system], + environment: input.environment && [...input.environment], + messages: structuredClone(input.messages), + }) + const usage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 } + async function* fullStream() { + yield { type: "start" } + yield { type: "start-step" } + yield { type: "text-start", id: "reply" } + yield { type: "text-delta", id: "reply", text: "Done." } + yield { type: "text-end", id: "reply" } + yield { type: "finish-step", finishReason: "stop", usage } + yield { type: "finish", finishReason: "stop", totalUsage: usage } + } + return { fullStream: fullStream() } as unknown as Awaited> + }) + try { + const { prompt, chat } = yield* boot() + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: promptRef, + parts: [{ type: "text", text: "Review invoice totals." }], + }) + expect(result.info).not.toHaveProperty("error") + expect(captured).toHaveLength(1) + const request = captured[0] + const environment = request.environment?.join("\n") ?? "" + expect(environment).toContain(`\n Working directory: ${directory}`) + expect(environment).toContain(` Today's date: ${new Date().toDateString()}`) + expect(request.system.join("\n")).not.toContain("") + expect(request.system.join("\n")).not.toContain(directory) + expect(JSON.stringify(request.messages)).not.toContain("") + expect(JSON.stringify(request.messages)).not.toContain("Today's date:") + } finally { + stream.mockRestore() + } + }), + { config: { ...cfg, snapshot: false, learn: { enabled: false } } }, + 30_000, +) + // altimate_change start - approved lessons are harness-delivered without changing cached prefixes for (const change of ["lower budget", "replacement", "disabled config", "disabled env"] as const) { noLLMServer.instance( diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 982ed0a412..6034d4125b 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -5,7 +5,7 @@ import path from "path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" -import { tmpdir, provideTestInstance } from "../fixture/fixture" +import { tmpdir, provideTestInstance, provideTmpdirInstance } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission/next" import { Truncate } from "../../src/tool/truncation" import { SessionID, MessageID } from "../../src/session/schema" @@ -14,6 +14,10 @@ import { Effect, Layer, ManagedRuntime } from "effect" import { Agent } from "../../src/agent/agent" import { Truncate as TruncateService } from "../../src/tool/truncate" import { InstanceRef } from "../../src/effect/instance-ref" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" + +const it = testEffect(CrossSpawnSpawner.defaultLayer) // The shared tool fixture stubs Truncate with a no-op so most tool tests don't // truncate. Truncation tests need the REAL Truncate service, so build the tool @@ -69,6 +73,59 @@ async function provideInstance(directory: string, fn: () => Promise): Prom } describe("tool.bash", () => { + it.live("keeps tool definitions identical across working directories", () => + Effect.gen(function* () { + const definitions = [] + for (let i = 0; i < 2; i++) { + definitions.push( + yield* provideTmpdirInstance((directory) => + Effect.gen(function* () { + const instance = yield* InstanceRef + const bash = yield* Effect.promise(() => Instance.restore(instance!, () => initTool(BashTool))) + expect(bash.jsonSchema).toBeDefined() + return { directory, description: bash.description, parameters: JSON.stringify(bash.jsonSchema) } + }), + ), + ) + } + expect(definitions[0].description).toBe(definitions[1].description) + expect(definitions[0].parameters).toBe(definitions[1].parameters) + for (const definition of definitions) { + expect(definition.description).not.toContain(definition.directory) + expect(definition.parameters).not.toContain(definition.directory) + } + }), + ) + + test("resolves the default working directory at execution and honors workdir", async () => { + await using first = await tmpdir({ + init: (dir) => Bun.write(path.join(dir, "location.txt"), "first directory"), + }) + await using second = await tmpdir({ + init: (dir) => Bun.write(path.join(dir, "location.txt"), "second directory"), + }) + for (const [directory, expected] of [ + [first.path, "first directory"], + [second.path, "second directory"], + ]) { + await provideInstance(directory, async () => { + const bash = await initTool(BashTool) + const result = await bash.execute({ command: "cat location.txt", description: "Read directory fixture" }, ctx) + expect(result.metadata.exit).toBe(0) + expect(result.output).toBe(expected) + }) + } + await provideInstance(first.path, async () => { + const bash = await initTool(BashTool) + const result = await bash.execute( + { command: "cat location.txt", workdir: second.path, description: "Read directory fixture" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.output).toBe("second directory") + }) + }) + test("basic", async () => { await provideInstance(projectRoot, async () => { const bash = await initTool(BashTool) From 994d11ddd7a694e74e823e43b879e5ce3192a72d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 15 Oct 2026 11:16:15 -0700 Subject: [PATCH 2/2] fix(session): split the environment block only for explicit cache-breakpoint providers - Add `ProviderTransform.usesExplicitCacheBreakpoints` (extracted from `message`) and use it in `LLM.stream` - Anthropic-style providers keep the trailing environment system message after the marked prefix; all other providers keep a single system message with the environment last - Pass the environment block through `experimental.chat.system.transform` again and keep it last after plugin additions - Extend request tests to OpenAI-style and Google-style providers Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/provider/transform.ts | 20 +++++----- packages/opencode/src/session/llm.ts | 17 +++++++-- .../test/session/llm-system-cache.test.ts | 38 ++++++++++++++++--- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cdb200eff6..613e2a306c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -532,26 +532,28 @@ export namespace ProviderTransform { } // altimate_change end - export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { - msgs = unsupportedParts(msgs, model) - msgs = normalizeMessages(msgs, model, options) - if ( + // altimate_change start — upstream_fix: shared with LLM.stream so the environment split only applies where cache breakpoints are explicit + export function usesExplicitCacheBreakpoints(model: Provider.Model) { + return ( (model.providerID === "anthropic" || - // altimate_change start — altimate-specific Anthropic provider IDs model.providerID === "google-vertex-anthropic" || model.providerID === "altimate-backend" || - // altimate_change end model.api.id.includes("anthropic") || model.api.id.includes("claude") || model.id.includes("anthropic") || model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" || - // altimate_change start — Alibaba Anthropic-compatible cache-control namespace model.api.npm === "@ai-sdk/alibaba" - // altimate_change end ) && model.api.npm !== "@ai-sdk/gateway" - ) { + ) + } + // altimate_change end + + export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { + msgs = unsupportedParts(msgs, model) + msgs = normalizeMessages(msgs, model, options) + if (usesExplicitCacheBreakpoints(model)) { msgs = applyCaching(msgs, model) } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a43ffce15e..0e5b5b295e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -122,6 +122,16 @@ export namespace LLM { .join("\n"), ) + // altimate_change start — upstream_fix: keep per-session environment last. Providers with explicit + // cache breakpoints get it as its own trailing message (after the marked prefix); others keep a + // single system message with the environment at the end. Either way plugins still see it. + const environment = input.environment?.length ? input.environment.join("\n") : undefined + const splitEnvironment = environment !== undefined && ProviderTransform.usesExplicitCacheBreakpoints(input.model) + if (environment !== undefined) { + if (splitEnvironment) system.push(environment) + else system[0] = [system[0], environment].filter((x) => x).join("\n") + } + const header = system[0] await Plugin.trigger( "experimental.chat.system.transform", @@ -131,12 +141,11 @@ export namespace LLM { // rejoin to maintain 2-part structure for caching if header unchanged if (system.length > 2 && system[0] === header) { const rest = system.slice(1) + const at = splitEnvironment ? rest.lastIndexOf(environment!) : -1 + if (at >= 0) rest.splice(at, 1) system.length = 0 - system.push(header, rest.join("\n")) + system.push(header, ...(rest.length ? [rest.join("\n")] : []), ...(at >= 0 ? [environment!] : [])) } - - // altimate_change start — upstream_fix: preserve a cache breakpoint before directory and date - if (input.environment?.length) system.push(input.environment.join("\n")) // altimate_change end const variant = diff --git a/packages/opencode/test/session/llm-system-cache.test.ts b/packages/opencode/test/session/llm-system-cache.test.ts index 8fe9eb869c..3ea777808e 100644 --- a/packages/opencode/test/session/llm-system-cache.test.ts +++ b/packages/opencode/test/session/llm-system-cache.test.ts @@ -13,17 +13,25 @@ import { MessageID, SessionID } from "../../src/session/schema" import { SystemPrompt } from "../../src/session/system" import { tmpdir } from "../fixture/fixture" +const NPM: Record = { + anthropic: "@ai-sdk/anthropic", + "amazon-bedrock": "@ai-sdk/amazon-bedrock", + openai: "@ai-sdk/openai", + google: "@ai-sdk/google", +} + describe("session system cache prefix", () => { - for (const providerID of ["anthropic", "amazon-bedrock"]) { + for (const providerID of ["anthropic", "amazon-bedrock", "openai", "google"]) { + const explicit = providerID === "anthropic" || providerID === "amazon-bedrock" for (const pluginInstructions of [false, true]) { test(`${providerID} keeps a marked prefix across directories with plugin instructions ${pluginInstructions}`, async () => { const model: Provider.Model = { - id: ModelID.make("claude-test"), + id: ModelID.make(explicit ? "claude-test" : "plain-test"), providerID: ProviderID.make(providerID), api: { - id: "claude-test", + id: explicit ? "claude-test" : "plain-test", url: "https://example.invalid", - npm: providerID === "anthropic" ? "@ai-sdk/anthropic" : "@ai-sdk/amazon-bedrock", + npm: NPM[providerID], }, name: "Test model", capabilities: { @@ -133,9 +141,29 @@ describe("session system cache prefix", () => { const systems = language.doStreamCalls.map((call) => call.prompt.filter((message) => message.role === "system"), ) + expect(JSON.stringify(language.doStreamCalls[0].tools)).toBe(JSON.stringify(language.doStreamCalls[1].tools)) + if (!explicit) { + for (const [index, system] of systems.entries()) { + expect(system).toHaveLength(pluginInstructions ? 2 : 1) + const text = system.map((message) => message.content).join("\n") + expect(system[0].content).toContain("Project instructions.\nSkill instructions.\nCustom instructions.") + expect(system[0].providerOptions).toBeUndefined() + const stable = text.slice(0, text.indexOf("Working directory:")) + expect(stable).not.toContain(directories[index]) + expect(text).toContain(`Working directory: ${directories[index]}`) + expect(text).toContain(`Today's date: ${new Date().toDateString()}`) + if (pluginInstructions) expect(text).toContain("Plugin rules.") + } + const stripped = systems.map((system, index) => + system.map((message) => String(message.content).split(directories[index]).join("")).join("\n"), + ) + expect(stripped[0].slice(0, stripped[0].indexOf("Working directory:"))).toBe( + stripped[1].slice(0, stripped[1].indexOf("Working directory:")), + ) + return + } expect(systems[0]).toHaveLength(pluginInstructions ? 3 : 2) expect(JSON.stringify(systems[0].slice(0, -1))).toBe(JSON.stringify(systems[1].slice(0, -1))) - expect(JSON.stringify(language.doStreamCalls[0].tools)).toBe(JSON.stringify(language.doStreamCalls[1].tools)) for (const [index, system] of systems.entries()) { const prefix = system.slice(0, -1) expect(prefix[0].content).toContain("Project instructions.\nSkill instructions.\nCustom instructions.")