#14743 · @bhagirathsinh-vaghela · opened Feb 23, 2026 at 3:17 AM UTC · last updated Mar 21, 2026 at 7:13 PM UTC

fix(cache): improve Anthropic prompt cache hit rate with system split and tool stability

appfix
76
+2305615 files

Score breakdown

Impact

9.0

Clarity

9.0

Urgency

7.0

Ease Of Review

9.0

Guidelines

9.0

Readiness

8.0

Size

3.0

Trust

5.0

Traction

9.0

Summary

This PR significantly improves Anthropic prompt cache hit rates by addressing several sources of prefix instability, leading to reduced token usage and cost. It implements always-active fixes and optional experimental stabilization features.

Open in GitHub

Description

Issue for this PR

Closes #5416, #5224 Related: #14065, #5422, #14203

Type of change

  • [x] Bug fix

What does this PR do?

Fixes cross-repo and cross-session Anthropic prompt cache misses. Same-session caching already works (AI SDK places markers correctly). This PR fixes the cases where the prefix changes between repos, sessions, or process restarts — causing full cache writes on every first prompt.

Anthropic hashes tools → system → messages in prefix order. Any change to an earlier block invalidates everything after it. OpenCode has several sources of unnecessary prefix changes.

Terminology (1-indexed): S1/S2 = system block 1/2. M1/M2 = cache marker on S1/S2.

Always-active fixes:

  1. System prompt is a single block — dynamic content (env, project AGENTS.md) invalidates the stable provider prompt. Split into 2 blocks: stable (provider prompt + global AGENTS.md) first, dynamic (env + project) second.

  2. Bash tool schema includes Instance.directory — changes per-repo, invalidating tool hash. Removed; model gets cwd from the environment block.

  3. Skill tool ordering is nondeterministicObject.values() on glob results. Sorted by name.

Opt-in fixes (behind env var flags):

  1. Date and instructions change between turnsOPENCODE_EXPERIMENTAL_CACHE_STABILIZATION=1 freezes date and caches instruction file reads for the process lifetime.

  2. Extended cache TTLOPENCODE_EXPERIMENTAL_CACHE_1H_TTL=1 sets 1h TTL on M1 (2x write cost vs 1.25x for default 5-min). Useful for sessions with idle gaps.

Commits:

| # | What | Behind flag? | |---|---|---| | 1 | Cache token audit logging | OPENCODE_CACHE_AUDIT | | 2 | Stabilize system prefix (freeze date + instructions) | OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION | | 3 | Split system prompt into 2 blocks | Always active | | 4 | Remove cwd from bash tool schema | Always active | | 5 | Sort skill tool ordering | Always active | | 6 | Optional 1h TTL on M1 | OPENCODE_EXPERIMENTAL_CACHE_1H_TTL |

What this doesn't fix:

  • Per-project skills or MCP tools that differ across repos — the skill tool description changes per project, breaking M1 even on the same machine. This is expected; per-project tools are inherently dynamic.
  • Cross-machine cache sharing (different skill tool descriptions per machine)
  • Plan/build mode switches (TaskTool description changes per mode) — deferred
  • Compaction cache alignment (#10342 — planned follow-up)

Impact beyond Anthropic: The prefix stability fixes also benefit providers with automatic prefix caching (OpenAI, DeepSeek, Gemini, xAI, Groq) — no markers needed, just a stable prefix.

How did you verify your code works?

OPENCODE_CACHE_AUDIT=1 logs [CACHE] hit/miss per LLM call. Tested with Claude Sonnet 4.6 on Anthropic direct API, bun dev, Feb 23 2026.

Cross-repo (different folder, within 5-min TTL — the key improvement):

BEFORE (no fixes):

Prompt 1: hit=0.0%   read=0      write=17,786  new=3   (full miss, no reuse)
Prompt 2: hit=99.9%  read=17,786 write=10      new=3
Prompt 3: hit=99.9%  read=17,796 write=14      new=3

AFTER (system split + tool stability):

Prompt 1: hit=97.6%  read=17,345 write=428     new=3   (block 1 reused, only env block misses)
Prompt 2: hit=99.9%  read=17,773 write=10      new=3
Prompt 3: hit=99.9%  read=17,783 write=14      new=3

The first prompt in a new repo goes from 0% → 97.6% cache hit. S1 (tools + provider prompt + global AGENTS.md) is reused across repos. These numbers are based on my setup — S1 is ~17,345 tokens, mostly tool definitions (~12k tokens), with provider prompt (~2k) and global AGENTS.md (~2.8k) making up the rest. Your numbers will differ based on your tool set (MCP servers, skills) and global AGENTS.md size, but the cross-repo miss is eliminated regardless.

Only block 2 (env with different cwd = 428 tokens) is a cache write on the first prompt in a new repo.

To reproduce:

OPENCODE_CACHE_AUDIT=1 bun dev /tmp/folder-a
# send a prompt, exit
OPENCODE_CACHE_AUDIT=1 bun dev /tmp/folder-b
# send a prompt within 5 minutes
grep '\[CACHE\]' ~/.local/share/opencode/log/dev.log

Screenshots / recordings

N/A — no UI changes.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

#5416 [FEATURE]: Anthropic (and others) caching improvement

View issue

Comments

PR comments

bhagirathsinh-vaghela

Reviewer's guide — supplementary context not covered in the PR description. Uses same terminology (S1/S2, M1/M2) defined there.


AI SDK cache marker mechanics

Ref: Anthropic prompt caching docs | Anthropic engineers' caching best practices (Feb 19 2026): Thariq Shihipar, R. Lance Martin

Max 4 cache_control markers per request. The AI SDK already places markers on the first 2 system blocks and the last 2 conversation turns. That part works — the problem is OpenCode mutating blocks before these markers, cascading hash changes downstream.

Key subtlety: before this PR, OpenCode had a single system block. M1 covered it, but M2 was unused — it fell through to conversation. The system split (commit 3) is what activates both markers, letting S1 (stable) cache independently from S2 (dynamic).

Since M1 covers the tool block too (tools hash before system in Anthropic's ordering), any tool instability (commits 4–5) completely invalidates M1 — the entire cached prefix up to that marker is lost.

Related open PRs

Several open PRs address parts of this (#5422, #14203, #10380, #11492). This PR addresses the root causes directly.

bhagirathsinh-vaghela

CI failure seems pre-existing — same NotFoundError affecting all PRs since the Windows path fixes landed in dev. Unrelated to this PR. All other checks pass.

ShanePresley

I pulled this into my fork and it's working beautifully. Unfortunately I only found this after getting a huge bill from Anthropic. Thanks OpenCode!

TomLucidor

@bhagirathsinh-vaghela could you check this with SLMs like Qwen3 or Nemotron or Kimi-Linear or GPT-OSS? Or providers using the OpenAI-compatible APIs (e.g. OpenRouter)? Also why are some of the E2E tests failing in OpenCode PR?

Bonus ask: would Speculative Decoding work with this fork? I am looking at this from the lens of vLLM-MLX and MLX-OpenAI-Server (for non-MLX there is vLLM).

bhagirathsinh-vaghela

@bhagirathsinh-vaghela could you check this with SLMs like Qwen3 or Nemotron or Kimi-Linear or GPT-OSS? Or providers using the OpenAI-compatible APIs (e.g. OpenRouter)? Also why are some of the E2E tests failing in OpenCode PR?

Bonus ask: would Speculative Decoding work with this fork? I am looking at this from the lens of vLLM-MLX and MLX-OpenAI-Server (for non-MLX there is vLLM).

@TomLucidor

The fixes are provider/model-agnostic — they stabilize the request prefix so it is byte-for-byte identical across calls. Any provider with server-side prefix caching benefits automatically. See my reviewer's guide comment above for the full breakdown of each fix.

The specific model behind the provider does not matter — the changes are purely at the request layer. You can verify with any provider using OPENCODE_CACHE_AUDIT=1 to see hit/miss per call.

E2E failures — pre-existing upstream issue, since fixed. CI is green now.

Speculative decoding — orthogonal. This PR only changes what is sent in the request, not how the server processes it.

fkroener

Looking forward to seeing less prompt re-processing with opencode. Unfortunately it seems currently this patchset breaks llama.cpp support:

[60919] srv operator(): got exception: {"error":{"code":400,"message":"Unable to generate parser for this template. Automatic parser generation failed: \n------------\nWhile executing CallExpression at line 85, column 32 in source:\n...first %}↵ {{- raise_exception('System message must be at the beginnin...\n ^\nError: Jinja Exception: System message must be at the beginning.","type":"invalid_request_error"}}

Tested with and without the new autoparser. Maybe I'm using it wrong?

fkroener

So, after partially reverting fix(cache): split system prompt into 2 blocks for independent caching, or rather naively ensuring llama.cpp gets just one system prompt (revert.patch) opencode now flies with this patchset using a llama.cpp endpoint (openai api though).

No more "erased invalidated context checkpoint" for all checkpoints and reprocessing of the entire context seemingly whenever I send a new query.

Checkpoint reuse happens usually at around 99 %, sometimes drops to 93 % - lowest was in the 70 % with > 60k tokens.

Much appreciated!

Wonder whether the split system message is something @pwilkin would be willing to support or whether it should be guarded to only be sent to Antrophic endpoints.

pwilkin

Any chance the system message could be moved to the top of the messages list? We could possibly do this for the Anthropic API, but technically the system prompt should be the first message.

fkroener

Thanks @pwilkin. Given this is actually coming from the model template (Qwen 3.5) and not the parser:

    {%- if message.role == "system" %}
        {%- if not loop.first %}
            {{- raise_exception('System message must be at the beginning.') }}
        {%- endif %}
    {%- elif message.role == "user" %}

this should probably best be handled on OpenCode's end.

sandeep-chaps

When will this PR make it into a release? We are seeing lower cache hit rates (Anthropic) across users using the same repo with a standard workflow based on opencode. -> higher token costs

Stellarthoughts

Even more important now to get it into release with the general rollout of 1M Context windows for Max subscribers. The price remained as if it was 200K window, so it's up to caching to cut costs.

https://claude.com/blog/1m-context-ga

hhieuu

Would love to see this get in as well. Caching is much less efficient in OpenCode with Claude models. We are pushing internal users to OpenCode for better general model supports, but the caching issue is a blocker.

thdxr

we are looking at it

rekram1-node

I think most of these changes prolly make sense, but it seems like the primary 2 things that are gained:

  • option for 1H ttl
  • tool prompt cache won't bust between projects as frequently.

In my experience #2 prolly won't have much impact for most ppl but we may as well do that

We actually resolved some of the ordering things in separate pr, ill look at the rest of this and then we will ship a cleaned up version

Review comments

kamelkace

Would it make sense to change the wording here, to hint to the LLM that this isn't a live updating value? Otherwise it might make some weird choices elsewhere for long lived conversations. E.g.

        `  Session started at: ${date.toDateString()}`,

bhagirathsinh-vaghela

Good point — this is better to show when the date is frozen. I'm keeping Today's date in this PR for now since it's what all OpenCode users expect(at least by experience even if they are not aware), but I'm not against the change if maintainers agree.

Separately, I've been experimenting locally with a progressive disclosure approach — making the env block fully static, instructing the model to fetch cwd, date, platform, etc. via tool calls when needed. Eliminates the block 2 cache write entirely at the cost of an occasional extra round-trip.

Interesting finding in this approach: completely removing the env block tended to result in models not bothering to fetch the info at all and assume things which is non deterministic. A static block with explicit "figure out when needed" instructions worked much better, at least with Anthropic models.

kamelkace

Separately, I've been experimenting locally with a progressive disclosure approach — making the env block fully static, instructing the model to fetch cwd, date, platform, etc. via tool calls when needed. [...] A static block with explicit "figure out when needed" instructions worked much better, at least with Anthropic models.

Hmm! I'll have to give that a shot when I patch from this PR later; I'm running locally against one of the Qwen3.5 models, so it'll be interesting data to see how they respond.

Changed Files

packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

+200
@@ -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 }) {
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show whe

packages/opencode/src/flag/flag.ts

+20
@@ -58,6 +58,8 @@ export namespace Flag {
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION = truthy("OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION")
export const OPENCODE_EXPERIMENTAL_CACHE_1H_TTL = truthy("OPENCODE_EXPERIMENTAL_CACHE_1H_TTL")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]

packages/opencode/src/provider/transform.ts

+94
@@ -171,10 +171,12 @@ export namespace ProviderTransform {
return msgs
}
function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
function applyCaching(msgs: ModelMessage[], model: Provider.Model, extendedTTL?: boolean): ModelMessage[] {
const system = msgs.filter((msg) => 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

packages/opencode/src/session/index.ts

+90
@@ -839,6 +839,15 @@ export namespace Session {
},
}
// OPENCODE_CACHE_AUDIT=1 enables per-call cache token accounting in the log
if (process.env["OPENCODE_CACHE_AUDIT"]) {
const totalInputTokens = tokens.input + tokens.cache.read + tokens.cache.write
const cacheHitPercent = totalInputTokens > 0 ? ((tokens.cache.read / totalInputTokens) * 100).toFixed(1) : "0.0"
log.info(
`[CACHE] ${input.model.id} input=${totalInputTokens} (cache_read=${tokens.cache.read} cache_write=${tokens.cache.write} new=${tokens.input}) hit=${cacheHitPercent}% output=${tokens.output} total=${tokens.total ?? 0}`,
)
}
const costInfo =
input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000
? input.model.cost.experimentalOver200K

packages/opencode/src/session/instruction.ts

+2913
@@ -71,14 +71,15 @@ export namespace InstructionPrompt {
export async function systemPaths() {
const config = await Config.get()
const paths = new Set<string>()
const global = new Set<string>()
const project = new Set<string>()
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

packages/opencode/src/session/llm.ts

+1114
@@ -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,16 @@ 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.s

packages/opencode/src/session/prompt.ts

+52
@@ -648,8 +648,10 @@ export namespace SessionPrompt {
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// Build system prompt, adding structured output instruction if needed
const system = [...(await SystemPrompt.environment(model)), ...(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)
@@ -661,6 +663,7 @@ export namespace SessionPrompt {
abort,
sessionID,
system,
systemSplit,
messages: [
...MessageV2.toModelMessages(msgs, model),
...(isLastStep

packages/opencode/src/session/system.ts

+71
@@ -10,6 +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 { Flag } from "@/flag/flag"
export namespace SystemPrompt {
export function instructions() {
@@ -26,8 +27,13 @@ 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}`,
@@ -36,7 +42,7 @@ export namespace SystemPrompt {
` Working directory: ${Instance.directory}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
` Today's date: ${date.toDateString()}`,
`</env>`,

packages/opencode/src/tool/bash.ts

+54
@@ -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

packages/opencode/src/tool/bash.txt

+11
@@ -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 <directory> && <command>` 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 <directory> && <command>` 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.

packages/opencode/src/tool/skill.ts

+86
@@ -12,12 +12,14 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
// 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
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 =
accessibleSkills.length === 0

packages/opencode/test/provider/transform.test.ts

+770
@@ -1530,6 +1530,83 @@ 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" },

packages/opencode/test/session/instruction.test.ts

+1111
@@ -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,
f

packages/opencode/test/tool/bash.test.ts

+120
@@ -399,4 +399,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)
},
})
})
})

packages/opencode/test/tool/skill.test.ts

+240
@@ -109,4 +109,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")
},
})
})
})