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/index.ts b/packages/opencode/src/session/index.ts index f2d436ff10d9..640fa5eb120f 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -846,6 +846,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 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 a8009c49d494..d2e76e6ca135 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -35,6 +35,7 @@ export namespace LLM { agent: Agent.Info permission?: Permission.Ruleset system: string[] + systemSplit?: number abort: AbortSignal messages: ModelMessage[] small?: boolean @@ -67,19 +68,19 @@ export namespace LLM { // TODO: move this to a proper hook const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth" - const system: string[] = [] - system.push( - [ - // use agent prompt otherwise provider prompt - ...(input.agent.prompt ? [input.agent.prompt] : 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"), - ) + const prompt = input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model) + const split = input.systemSplit ?? input.system.length + const shouldSplit = provider.options?.["splitSystemPrompt"] !== false + const system = shouldSplit + ? [ + // 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) + : [ + [...prompt, ...input.system, ...(input.user.system ? [input.user.system] : [])].filter(Boolean).join("\n"), + ].filter(Boolean) const header = system[0] await Plugin.trigger( @@ -88,7 +89,7 @@ export namespace LLM { { system }, ) // rejoin to maintain 2-part structure for caching if header unchanged - if (system.length > 2 && system[0] === header) { + if (shouldSplit && system.length > 2 && system[0] === header) { const rest = system.slice(1) system.length = 0 system.push(header, rest.join("\n")) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dca8085c5b2e..d306889011cc 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -652,13 +652,17 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - // Build system prompt, adding structured output instruction if needed + // Build system prompt: global instructions + global skills first (stable), then env + project (dynamic) + const instructions = await InstructionPrompt.system() const skills = await SystemPrompt.skills(agent) const system = [ + ...instructions.global, + ...(skills.global ? [skills.global] : []), ...(await SystemPrompt.environment(model)), - ...(skills ? [skills] : []), - ...(await InstructionPrompt.system()), + ...(skills.project ? [skills.project] : []), + ...instructions.project, ] + const systemSplit = instructions.global.length + (skills.global ? 1 : 0) const format = lastUser.format ?? { type: "text" } if (format.type === "json_schema") { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) @@ -671,6 +675,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 ca324652d9dc..9b1188587688 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -13,6 +13,7 @@ import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" +import { Flag } from "@/flag/flag" export namespace SystemPrompt { export function provider(model: Provider.Model) { @@ -25,8 +26,13 @@ export namespace SystemPrompt { return [PROMPT_DEFAULT] } + 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 { ` 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()}`, ``, ``, ` ${ @@ -52,17 +58,31 @@ export namespace SystemPrompt { ] } - export async function skills(agent: Agent.Info) { - if (Permission.disabled(["skill"], agent.permission).has("skill")) return + export async function skills(agent: Agent.Info): Promise<{ global?: string; project?: string }> { + if (Permission.disabled(["skill"], agent.permission).has("skill")) return {} const list = await Skill.available(agent) + const globalSkills = list.filter((s) => s.scope === "global") + const projectSkills = list.filter((s) => s.scope === "project") - return [ + // 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. + const preamble = [ "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") + + const global = globalSkills.length > 0 + ? [preamble, Skill.fmt(globalSkills, { verbose: true })].join("\n") + : undefined + + const project = projectSkills.length > 0 + ? [ + ...(globalSkills.length === 0 ? [preamble] : []), + Skill.fmt(projectSkills, { verbose: true }), + ].join("\n") + : undefined + + return { global, project } } } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 43a22219edc3..77018bfe6dbe 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -30,6 +30,7 @@ export namespace Skill { description: z.string(), location: z.string(), content: z.string(), + scope: z.enum(["global", "project"]).default("project"), }) export type Info = z.infer @@ -68,7 +69,7 @@ export namespace Skill { readonly available: (agent?: Agent.Info) => Effect.Effect } - const add = async (state: State, match: string) => { + const add = async (state: State, match: string, scope: "global" | "project" = "project") => { const md = await ConfigMarkdown.parse(match).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message @@ -98,10 +99,16 @@ export namespace Skill { description: parsed.data.description, location: match, content: md.content, + scope, } } - const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { + const scan = async ( + state: State, + root: string, + pattern: string, + opts?: { dot?: boolean; scope?: "global" | "project" }, + ) => { return Glob.scan(pattern, { cwd: root, absolute: true, @@ -109,7 +116,7 @@ export namespace Skill { symlink: true, dot: opts?.dot, }) - .then((matches) => Promise.all(matches.map((match) => add(state, match)))) + .then((matches) => Promise.all(matches.map((match) => add(state, match, opts?.scope ?? "project")))) .catch((error) => { if (!opts?.scope) throw error log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) @@ -141,7 +148,7 @@ export namespace Skill { } for (const dir of await Config.directories()) { - await scan(state, dir, OPENCODE_SKILL_PATTERN) + await scan(state, dir, OPENCODE_SKILL_PATTERN, { scope: "global" }) } const cfg = await Config.get() @@ -153,13 +160,13 @@ export namespace Skill { continue } - await scan(state, dir, SKILL_PATTERN) + await scan(state, dir, SKILL_PATTERN, { scope: "global" }) } for (const url of cfg.skills?.urls ?? []) { for (const dir of await Effect.runPromise(discovery.pull(url))) { state.dirs.add(dir) - await scan(state, dir, SKILL_PATTERN) + await scan(state, dir, SKILL_PATTERN, { scope: "global" }) } } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 50ae4abac8de..3472c3ad226a 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/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 370edeed38b6..cc26976070bd 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1629,6 +1629,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" }, + ] 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/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 47f5f6fc25dd..d41a1a0ba0c5 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -41,11 +41,12 @@ description: ${description} const first = await SystemPrompt.skills(build!) const second = await SystemPrompt.skills(build!) - expect(first).toBe(second) + expect(first).toEqual(second) - const alpha = first!.indexOf("alpha-skill") - const middle = first!.indexOf("middle-skill") - const zeta = first!.indexOf("zeta-skill") + const combined = [first.global, first.project].filter(Boolean).join("\n") + const alpha = combined.indexOf("alpha-skill") + const middle = combined.indexOf("middle-skill") + const zeta = combined.indexOf("zeta-skill") expect(alpha).toBeGreaterThan(-1) expect(middle).toBeGreaterThan(alpha) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 4d680d494f35..34e6820bce08 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) + }, + }) + }) })