Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,26 +532,28 @@ export namespace ProviderTransform {
}
// altimate_change end

export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
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<string, unknown>) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
if (usesExplicitCacheBreakpoints(model)) {
msgs = applyCaching(msgs, model)
}

Expand Down
18 changes: 17 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,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")
Comment thread
anandgupta42 marked this conversation as resolved.
}

const header = system[0]
await Plugin.trigger(
"experimental.chat.system.transform",
Expand All @@ -128,9 +141,12 @@ 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 end

const variant =
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Comment thread
anandgupta42 marked this conversation as resolved.
Comment thread
anandgupta42 marked this conversation as resolved.
// altimate_change end
messages: [
...(await MessageV2.toModelMessages(msgs, model)),
...(isLastStep
Expand Down
9 changes: 6 additions & 3 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
190 changes: 190 additions & 0 deletions packages/opencode/test/session/llm-system-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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"

const NPM: Record<string, string> = {
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", "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(explicit ? "claude-test" : "plain-test"),
providerID: ProviderID.make(providerID),
api: {
id: explicit ? "claude-test" : "plain-test",
url: "https://example.invalid",
npm: NPM[providerID],
},
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(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.")
Comment thread
anandgupta42 marked this conversation as resolved.
}
const stripped = systems.map((system, index) =>
system.map((message) => String(message.content).split(directories[index]).join("<dir>")).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)))
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("<env>")
}
} finally {
for (const mock of mocks) mock.mockRestore()
}
})
}
}
})
49 changes: 49 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LLM.StreamInput, "system" | "environment" | "messages">[] = []
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<ReturnType<typeof LLM.stream>>
})
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(`<env>\n Working directory: ${directory}`)
expect(environment).toContain(` Today's date: ${new Date().toDateString()}`)
Comment thread
anandgupta42 marked this conversation as resolved.
expect(request.system.join("\n")).not.toContain("<env>")
expect(request.system.join("\n")).not.toContain(directory)
expect(JSON.stringify(request.messages)).not.toContain("<env>")
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(
Expand Down
Loading
Loading