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 cache = 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: cache } } + : 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..4d626001e3ac 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 native = input.model.api.npm === "@ai-sdk/anthropic" || + input.model.api.npm === "@ai-sdk/google-vertex/anthropic" + if (!native && 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..7006e73ede30 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 frozen: Date | undefined + export async function environment(model: Provider.Model) { const project = Instance.project + const date = Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION + ? (frozen ??= 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..f04fa39bd55c 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 allowed = ( + 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 + 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.", @@ -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 }), + "", + ...allowed.flatMap((skill) => [ + ` `, + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + ` `, + ]), + "", ].join("\n") - const examples = list + const examples = allowed .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") + }, + }) + }) })