From 3b361d69d1da9fe95b8e84862a645da8b60965fc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Mar 2026 14:22:38 +0000
Subject: [PATCH 1/3] Initial plan
From 8cb5a336bee5c05ac31c3efb56e4b71b9e7c0ec1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Mar 2026 14:54:55 +0000
Subject: [PATCH 2/3] feat(cache): implement prompt prefix cache stabilization
from upstream PR #14743
- Add OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION and OPENCODE_EXPERIMENTAL_CACHE_1H_TTL flags
- Split system prompt into 2 blocks (stable/dynamic) for better cache reuse
- Freeze date and instructions behind OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION flag
- Remove Instance.directory from bash tool schema for cross-repo cache hits
- Sort skill tools alphabetically for deterministic ordering
- Add extended TTL support for first system cache marker
- Add cache audit display in TUI sidebar behind OPENCODE_CACHE_AUDIT env var
- Fix llama-server compatibility: join system blocks for non-Anthropic providers
- Update tests for all changed functionality
Co-authored-by: chand1012 <3521582+chand1012@users.noreply.github.com>
---
.../cli/cmd/tui/routes/session/sidebar.tsx | 20 +++++
packages/opencode/src/flag/flag.ts | 2 +
packages/opencode/src/provider/transform.ts | 13 +++-
packages/opencode/src/session/instruction.ts | 42 ++++++----
packages/opencode/src/session/llm.ts | 36 +++++----
packages/opencode/src/session/prompt.ts | 12 ++-
packages/opencode/src/session/system.ts | 26 ++-----
packages/opencode/src/skill/skill.ts | 29 -------
packages/opencode/src/tool/bash.ts | 9 ++-
packages/opencode/src/tool/bash.txt | 2 +-
packages/opencode/src/tool/skill.ts | 30 ++++++--
.../opencode/test/provider/transform.test.ts | 76 +++++++++++++++++++
.../opencode/test/session/instruction.test.ts | 22 +++---
packages/opencode/test/tool/bash.test.ts | 12 +++
packages/opencode/test/tool/skill.test.ts | 26 ++++++-
15 files changed, 249 insertions(+), 108 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 42ac5fbe080a..a2cf6426f557 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -53,10 +53,17 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
+ const totalInput = last.tokens.input + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
tokens: total.toLocaleString(),
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
+ cacheHitPercent: totalInput > 0 ? ((last.tokens.cache.read / totalInput) * 100).toFixed(3) : null,
+ cacheRead: last.tokens.cache.read,
+ cacheWrite: last.tokens.cache.write,
+ cacheNew: last.tokens.input,
+ cacheInput: totalInput,
+ cacheOutput: last.tokens.output,
}
})
@@ -106,6 +113,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
{context()?.percentage ?? 0}% used
{cost()} spent
+
+
+
+ Cache Audit
+
+ {context()!.cacheInput.toLocaleString()} input tokens
+ {context()!.cacheNew.toLocaleString()} new
+ {context()!.cacheRead.toLocaleString()} cache read
+ {context()!.cacheWrite.toLocaleString()} cache write
+ {context()!.cacheHitPercent}% hit rate
+ {context()!.cacheOutput.toLocaleString()} output tokens
+
+
0}>
msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
+ // Use 1h cache TTL on first system block (2x write cost vs 1.25x for default 5-min)
+ const anthropicCache = extendedTTL ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" }
const providerOptions = {
anthropic: {
cacheControl: { type: "ephemeral" },
@@ -194,18 +196,21 @@ export namespace ProviderTransform {
}
for (const msg of unique([...system, ...final])) {
+ const options = msg === system[0]
+ ? { ...providerOptions, anthropic: { cacheControl: anthropicCache } }
+ : providerOptions
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
if (lastContent && typeof lastContent === "object") {
- lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
+ lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, options)
continue
}
}
- msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
+ msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, options)
}
return msgs
@@ -261,7 +266,7 @@ export namespace ProviderTransform {
model.api.npm === "@ai-sdk/anthropic") &&
model.api.npm !== "@ai-sdk/gateway"
) {
- msgs = applyCaching(msgs, model)
+ msgs = applyCaching(msgs, model, (options.extendedTTL as boolean) ?? Flag.OPENCODE_EXPERIMENTAL_CACHE_1H_TTL)
}
// Remap providerOptions keys from stored providerID to expected SDK key
diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts
index 86f73d0fd238..901f1bbcc679 100644
--- a/packages/opencode/src/session/instruction.ts
+++ b/packages/opencode/src/session/instruction.ts
@@ -71,14 +71,15 @@ export namespace InstructionPrompt {
export async function systemPaths() {
const config = await Config.get()
- const paths = new Set()
+ const global = new Set()
+ const project = new Set()
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((p) => {
- paths.add(path.resolve(p))
+ project.add(path.resolve(p))
})
break
}
@@ -87,7 +88,7 @@ export namespace InstructionPrompt {
for (const file of globalFiles()) {
if (await Filesystem.exists(file)) {
- paths.add(path.resolve(file))
+ global.add(path.resolve(file))
break
}
}
@@ -106,22 +107,29 @@ export namespace InstructionPrompt {
}).catch(() => [])
: await resolveRelative(instruction)
matches.forEach((p) => {
- paths.add(path.resolve(p))
+ project.add(path.resolve(p))
})
}
}
- return paths
+ return { global, project }
}
- export async function system() {
- const config = await Config.get()
+ export type SystemInstructions = { global: string[]; project: string[] }
+
+ let cached: SystemInstructions | undefined
+
+ export async function system(): Promise {
+ if (Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION && cached) return cached
+
const paths = await systemPaths()
+ const config = await Config.get()
- const files = Array.from(paths).map(async (p) => {
- const content = await Filesystem.readText(p).catch(() => "")
- return content ? "Instructions from: " + p + "\n" + content : ""
- })
+ const readPaths = (set: Set) =>
+ Array.from(set).map(async (p) => {
+ const content = await Filesystem.readText(p).catch(() => "")
+ return content ? "Instructions from: " + p + "\n" + content : ""
+ })
const urls: string[] = []
if (config.instructions) {
@@ -138,7 +146,14 @@ export namespace InstructionPrompt {
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
)
- return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
+ const [global, project] = await Promise.all([
+ Promise.all(readPaths(paths.global)).then((r) => r.filter(Boolean)),
+ Promise.all([...readPaths(paths.project), ...fetches]).then((r) => r.filter(Boolean)),
+ ])
+
+ const result = { global, project }
+ if (Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION) cached = result
+ return result
}
export function loaded(messages: MessageV2.WithParts[]) {
@@ -166,7 +181,8 @@ export namespace InstructionPrompt {
}
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
- const system = await systemPaths()
+ const paths = await systemPaths()
+ const system = new Set([...paths.global, ...paths.project])
const already = loaded(messages)
const results: { filepath: string; content: string }[] = []
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index 4e42fb0d2ec7..495e9290beb7 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -33,6 +33,7 @@ export namespace LLM {
model: Provider.Model
agent: Agent.Info
system: string[]
+ systemSplit?: number
abort: AbortSignal
messages: ModelMessage[]
small?: boolean
@@ -64,20 +65,27 @@ export namespace LLM {
])
const isCodex = provider.id === "openai" && auth?.type === "oauth"
- const system = []
- system.push(
- [
- // use agent prompt otherwise provider prompt
- // For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
- ...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : 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"),
- )
+ // use agent prompt otherwise provider prompt
+ // For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
+ const prompt = input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)
+ const split = input.systemSplit ?? input.system.length
+ const system = [
+ // block 1: provider/agent prompt + global instructions (stable across repos)
+ [...prompt, ...input.system.slice(0, split)].filter(Boolean).join("\n"),
+ // block 2: env + project instructions + any custom prompt from last user message (dynamic)
+ [...input.system.slice(split), ...(input.user.system ? [input.user.system] : [])].filter(Boolean).join("\n"),
+ ].filter(Boolean)
+
+ // For non-Anthropic native API providers (OpenAI, OpenAI-compatible, llama-server, etc.),
+ // join system blocks into a single message to avoid "system message must be at the beginning"
+ // errors. Only Anthropic native API benefits from the 2-block split for cache marker placement.
+ const isAnthropicNative = input.model.api.npm === "@ai-sdk/anthropic" ||
+ input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
+ if (!isAnthropicNative && system.length > 1) {
+ const joined = system.join("\n")
+ system.length = 0
+ system.push(joined)
+ }
const header = system[0]
await Plugin.trigger(
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 939c50a3d920..315363db5fb8 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -650,13 +650,10 @@ export namespace SessionPrompt {
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
- // Build system prompt, adding structured output instruction if needed
- const skills = await SystemPrompt.skills(agent)
- const system = [
- ...(await SystemPrompt.environment(model)),
- ...(skills ? [skills] : []),
- ...(await InstructionPrompt.system()),
- ]
+ // Build system prompt: global instructions first (stable), then env + project (dynamic)
+ const instructions = await InstructionPrompt.system()
+ const system = [...instructions.global, ...(await SystemPrompt.environment(model)), ...instructions.project]
+ const systemSplit = instructions.global.length
const format = lastUser.format ?? { type: "text" }
if (format.type === "json_schema") {
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
@@ -668,6 +665,7 @@ export namespace SessionPrompt {
abort,
sessionID,
system,
+ systemSplit,
messages: [
...MessageV2.toModelMessages(msgs, model),
...(isLastStep
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index a4c4684ffeea..e3c09c517a78 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -10,9 +10,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider"
-import type { Agent } from "@/agent/agent"
-import { PermissionNext } from "@/permission/next"
-import { Skill } from "@/skill"
+import { Flag } from "@/flag/flag"
export namespace SystemPrompt {
export function instructions() {
@@ -29,18 +27,22 @@ export namespace SystemPrompt {
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}
+ let cachedDate: Date | undefined
+
export async function environment(model: Provider.Model) {
const project = Instance.project
+ const date = Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION
+ ? (cachedDate ??= new Date())
+ : new Date()
return [
[
`You 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: ${Instance.directory}`,
- ` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
- ` Today's date: ${new Date().toDateString()}`,
+ ` Today's date: ${date.toDateString()}`,
``,
``,
` ${
@@ -55,18 +57,4 @@ export namespace SystemPrompt {
].join("\n"),
]
}
-
- export async function skills(agent: Agent.Info) {
- if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return
-
- const list = await Skill.available(agent)
-
- return [
- "Skills provide specialized instructions and workflows for specific tasks.",
- "Use the skill tool to load a skill when a task matches its description.",
- // the agents seem to ingest the information about skills a bit better if we present a more verbose
- // version of them here and a less verbose version in tool description, rather than vice versa.
- Skill.fmt(list, { verbose: true }),
- ].join("\n")
- }
}
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index fa984b3e1111..c474c94dd74b 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -13,9 +13,6 @@ import { Bus } from "@/bus"
import { Session } from "@/session"
import { Discovery } from "./discovery"
import { Glob } from "../util/glob"
-import { pathToFileURL } from "url"
-import type { Agent } from "@/agent/agent"
-import { PermissionNext } from "@/permission/next"
export namespace Skill {
const log = Log.create({ service: "skill" })
@@ -189,30 +186,4 @@ export namespace Skill {
export async function dirs() {
return state().then((x) => x.dirs)
}
-
- export async function available(agent?: Agent.Info) {
- const list = await all()
- if (!agent) return list
- return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
- }
-
- export function fmt(list: Info[], opts: { verbose: boolean }) {
- if (list.length === 0) {
- return "No skills are currently available."
- }
- if (opts.verbose) {
- return [
- "",
- ...list.flatMap((skill) => [
- ` `,
- ` ${skill.name}`,
- ` ${skill.description}`,
- ` ${pathToFileURL(skill.location).href}`,
- ` `,
- ]),
- "",
- ].join("\n")
- }
- return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
- }
}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 109a665363b1..048e810d47c7 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -57,16 +57,17 @@ 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))
- .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
+ description: DESCRIPTION.replaceAll("${maxLines}", String(Truncate.MAX_LINES)).replaceAll(
+ "${maxBytes}",
+ String(Truncate.MAX_BYTES),
+ ),
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.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 current working directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt
index baafb00810ab..44853b4bdf55 100644
--- a/packages/opencode/src/tool/bash.txt
+++ b/packages/opencode/src/tool/bash.txt
@@ -1,6 +1,6 @@
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
-All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.
+All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index 17016b06f807..38da2d72d500 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -3,14 +3,26 @@ import { pathToFileURL } from "url"
import z from "zod"
import { Tool } from "./tool"
import { Skill } from "../skill"
+import { PermissionNext } from "../permission/next"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
export const SkillTool = Tool.define("skill", async (ctx) => {
- const list = await Skill.available(ctx?.agent)
+ const skills = await Skill.all()
+
+ // Filter skills by agent permissions if agent provided
+ const agent = ctx?.agent
+ const accessibleSkills = (
+ agent
+ ? skills.filter((skill) => {
+ const rule = PermissionNext.evaluate("skill", skill.name, agent.permission)
+ return rule.action !== "deny"
+ })
+ : skills
+ ).toSorted((a, b) => a.name.localeCompare(b.name))
const description =
- list.length === 0
+ accessibleSkills.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
@@ -24,10 +36,18 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
- Skill.fmt(list, { verbose: false }),
+ "",
+ ...accessibleSkills.flatMap((skill) => [
+ ` `,
+ ` ${skill.name}`,
+ ` ${skill.description}`,
+ ` ${pathToFileURL(skill.location).href}`,
+ ` `,
+ ]),
+ "",
].join("\n")
- const examples = list
+ const examples = accessibleSkills
.map((skill) => `'${skill.name}'`)
.slice(0, 3)
.join(", ")
@@ -44,7 +64,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
const skill = await Skill.get(params.name)
if (!skill) {
- const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", "))
+ const available = await Skill.all().then((x) => x.map((s) => s.name).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index ecb3a74804c2..2a42fc3be36a 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -1531,6 +1531,82 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
})
})
+describe("ProviderTransform.message - first system block gets 1h TTL when flag set", () => {
+ const anthropicModel = {
+ id: "anthropic/claude-sonnet-4-6",
+ providerID: "anthropic",
+ api: {
+ id: "claude-sonnet-4-6",
+ url: "https://api.anthropic.com",
+ npm: "@ai-sdk/anthropic",
+ },
+ capabilities: {
+ temperature: true,
+ reasoning: false,
+ attachment: true,
+ toolcall: true,
+ input: { text: true, audio: false, image: true, video: false, pdf: true },
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
+ interleaved: false,
+ },
+ cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } },
+ limit: { context: 200000, output: 8192 },
+ status: "active",
+ options: {},
+ headers: {},
+ } as any
+
+ test("first system block gets 1h TTL when extendedTTL is true", () => {
+ const msgs = [
+ { role: "system", content: "Block 1" },
+ { role: "system", content: "Block 2" },
+ { role: "user", content: "Hello" },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, anthropicModel, { extendedTTL: true }) as any[]
+
+ expect(result[0].providerOptions.anthropic.cacheControl).toEqual({ type: "ephemeral", ttl: "1h" })
+ })
+
+ test("first system block gets default ephemeral when extendedTTL is not set", () => {
+ const msgs = [
+ { role: "system", content: "Block 1" },
+ { role: "system", content: "Block 2" },
+ { role: "user", content: "Hello" },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
+
+ expect(result[0].providerOptions.anthropic.cacheControl).toEqual({ type: "ephemeral" })
+ })
+
+ test("second system block always gets default ephemeral TTL", () => {
+ const msgs = [
+ { role: "system", content: "Block 1" },
+ { role: "system", content: "Block 2" },
+ { role: "user", content: "Hello" },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, anthropicModel, { extendedTTL: true }) as any[]
+
+ expect(result[1].providerOptions.anthropic.cacheControl).toEqual({ type: "ephemeral" })
+ })
+
+ test("conversation messages get default ephemeral TTL even with extendedTTL", () => {
+ const msgs = [
+ { role: "system", content: "System" },
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "Hi" },
+ { role: "user", content: "World" },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, anthropicModel, { extendedTTL: true }) as any[]
+
+ const last = result[result.length - 1]
+ expect(last.providerOptions.anthropic.cacheControl).toEqual({ type: "ephemeral" })
+ })
+})
+
describe("ProviderTransform.message - cache control on gateway", () => {
const createModel = (overrides: Partial = {}) =>
({
diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts
index e0bf94a9500d..ec167852c9d9 100644
--- a/packages/opencode/test/session/instruction.test.ts
+++ b/packages/opencode/test/session/instruction.test.ts
@@ -16,8 +16,8 @@ describe("InstructionPrompt.resolve", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const system = await InstructionPrompt.systemPaths()
- expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
+ const paths = await InstructionPrompt.systemPaths()
+ expect(paths.project.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"), "test-message-1")
expect(results).toEqual([])
@@ -35,8 +35,8 @@ describe("InstructionPrompt.resolve", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const system = await InstructionPrompt.systemPaths()
- expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
+ const paths = await InstructionPrompt.systemPaths()
+ expect(paths.project.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
const results = await InstructionPrompt.resolve(
[],
@@ -60,8 +60,8 @@ describe("InstructionPrompt.resolve", () => {
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
- const system = await InstructionPrompt.systemPaths()
- expect(system.has(filepath)).toBe(false)
+ const paths = await InstructionPrompt.systemPaths()
+ expect(paths.project.has(filepath)).toBe(false)
const results = await InstructionPrompt.resolve([], filepath, "test-message-2")
expect(results).toEqual([])
@@ -107,8 +107,8 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
directory: projectTmp.path,
fn: async () => {
const paths = await InstructionPrompt.systemPaths()
- expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
- expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
+ expect(paths.global.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
+ expect(paths.global.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
},
})
} finally {
@@ -134,8 +134,8 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
directory: projectTmp.path,
fn: async () => {
const paths = await InstructionPrompt.systemPaths()
- expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
- expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
+ expect(paths.global.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
+ expect(paths.global.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
})
} finally {
@@ -160,7 +160,7 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
directory: projectTmp.path,
fn: async () => {
const paths = await InstructionPrompt.systemPaths()
- expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
+ expect(paths.global.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
})
} finally {
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index f947398b37e1..3bd2e1dd81ab 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -400,4 +400,16 @@ describe("tool.bash truncation", () => {
},
})
})
+
+ test("tool schema does not contain Instance.directory for stable cache hash", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const bash = await BashTool.init()
+ expect(bash.description).not.toContain(Instance.directory)
+ const schema = JSON.stringify(bash.parameters)
+ expect(schema).not.toContain(Instance.directory)
+ },
+ })
+ })
})
diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts
index 5bcdb6c2b9d3..9cce58437b3a 100644
--- a/packages/opencode/test/tool/skill.test.ts
+++ b/packages/opencode/test/tool/skill.test.ts
@@ -46,7 +46,7 @@ description: Skill for tool tests.
fn: async () => {
const tool = await SkillTool.init()
const skillPath = path.join(tmp.path, ".opencode", "skill", "tool-skill", "SKILL.md")
- expect(tool.description).toContain(`**tool-skill**: Skill for tool tests.`)
+ expect(tool.description).toContain(`${pathToFileURL(skillPath).href}`)
},
})
} finally {
@@ -110,4 +110,28 @@ Use this skill.
process.env.OPENCODE_TEST_HOME = home
}
})
+
+ test("skills are sorted alphabetically by name for stable cache hash", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ for (const name of ["zebra", "alpha", "middle"]) {
+ await Bun.write(
+ path.join(dir, ".opencode", "skill", name, "SKILL.md"),
+ `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}`,
+ )
+ }
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const tool = await SkillTool.init({})
+ const lines = tool.description.split("\n").filter((l: string) => l.includes("name>"))
+ expect(lines[0]).toContain("alpha")
+ expect(lines[1]).toContain("middle")
+ expect(lines[2]).toContain("zebra")
+ },
+ })
+ })
})
From a95b8bbaddf117e92d4e924b9f9cc7b8236cae40 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Mar 2026 15:00:32 +0000
Subject: [PATCH 3/3] refactor: address code review naming feedback
Co-authored-by: chand1012 <3521582+chand1012@users.noreply.github.com>
---
packages/opencode/src/provider/transform.ts | 4 ++--
packages/opencode/src/session/llm.ts | 4 ++--
packages/opencode/src/session/system.ts | 4 ++--
packages/opencode/src/tool/skill.ts | 8 ++++----
4 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index be32e7477fd3..5d724bd70bca 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -176,7 +176,7 @@ export namespace ProviderTransform {
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
// Use 1h cache TTL on first system block (2x write cost vs 1.25x for default 5-min)
- const anthropicCache = extendedTTL ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" }
+ const cache = extendedTTL ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" }
const providerOptions = {
anthropic: {
cacheControl: { type: "ephemeral" },
@@ -197,7 +197,7 @@ export namespace ProviderTransform {
for (const msg of unique([...system, ...final])) {
const options = msg === system[0]
- ? { ...providerOptions, anthropic: { cacheControl: anthropicCache } }
+ ? { ...providerOptions, anthropic: { cacheControl: cache } }
: providerOptions
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index 495e9290beb7..4d626001e3ac 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -79,9 +79,9 @@ export namespace LLM {
// For non-Anthropic native API providers (OpenAI, OpenAI-compatible, llama-server, etc.),
// join system blocks into a single message to avoid "system message must be at the beginning"
// errors. Only Anthropic native API benefits from the 2-block split for cache marker placement.
- const isAnthropicNative = input.model.api.npm === "@ai-sdk/anthropic" ||
+ const native = input.model.api.npm === "@ai-sdk/anthropic" ||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
- if (!isAnthropicNative && system.length > 1) {
+ if (!native && system.length > 1) {
const joined = system.join("\n")
system.length = 0
system.push(joined)
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index e3c09c517a78..7006e73ede30 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -27,12 +27,12 @@ export namespace SystemPrompt {
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}
- let cachedDate: Date | undefined
+ let frozen: Date | undefined
export async function environment(model: Provider.Model) {
const project = Instance.project
const date = Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION
- ? (cachedDate ??= new Date())
+ ? (frozen ??= new Date())
: new Date()
return [
[
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index 38da2d72d500..f04fa39bd55c 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -12,7 +12,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
// Filter skills by agent permissions if agent provided
const agent = ctx?.agent
- const accessibleSkills = (
+ const allowed = (
agent
? skills.filter((skill) => {
const rule = PermissionNext.evaluate("skill", skill.name, agent.permission)
@@ -22,7 +22,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
).toSorted((a, b) => a.name.localeCompare(b.name))
const description =
- accessibleSkills.length === 0
+ allowed.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
@@ -37,7 +37,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
"",
- ...accessibleSkills.flatMap((skill) => [
+ ...allowed.flatMap((skill) => [
` `,
` ${skill.name}`,
` ${skill.description}`,
@@ -47,7 +47,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
"",
].join("\n")
- const examples = accessibleSkills
+ const examples = allowed
.map((skill) => `'${skill.name}'`)
.slice(0, 3)
.join(", ")