diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..aa5e839 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: ["20", "22"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + # No install step: this project has no dependencies. + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6870746 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +context-cache.log +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1ccaed4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +## 0.2.0 + +### Breaking + +- The plugin no longer writes the `x-session-id`, `conversation_id` or + `session_id` headers. Those names identify a *conversation*, and a + project-stable value is wrong in them: on opencode's OpenAI/Codex path, + `x-session-affinity` keys a WebSocket connection pool whose `busy` and + `fallback` state would then be shared by every concurrent session in the + project. opencode core already sends `x-session-affinity` and `X-Session-Id` + derived from the real session ID. A gateway that parsed the underscore names + must be reconfigured to read core's headers instead. + + The plugin also previously wrote `x-session-id` while core writes + `X-Session-Id`. Those are distinct keys in a JavaScript object and only + collapse at the HTTP layer, so both values were reaching the wire. + +### Fixed + +- The cache key is derived from `PluginInput.worktree` rather than + `process.cwd()`. One `opencode serve` process serving several projects + previously gave all of them the same key, because the plugin factory is + invoked per project while `process.cwd()` stays the server's launch + directory. +- `prompt_cache_key` (deepinfra, cerebras) is now handled. Only the camelCase + spelling was written before, so those providers were unaffected by the plugin. +- The key is replaced only when it still holds opencode's own session-ID + default, so an explicit operator setting, a model or agent option, or another + plugin's value is no longer overwritten. +- Explicit overrides longer than 64 characters or containing non-printable + characters are hashed rather than sent verbatim. +- A degenerate `/` worktree falls back to the session directory, mirroring + opencode's own guard, instead of collapsing every project onto one key. +- The debug log moved out of the plugin directory to + `$XDG_STATE_HOME/opencode/context-cache.log`, so the documented + `./plugins/...` install no longer writes a log file into your repository. + +### Added + +- `OPENCODE_CONTEXT_CACHE_SCOPE` (`worktree` default, `directory`, `session`). + `session` is a full opt-out and takes precedence over an explicit key. +- `OPENCODE_CONTEXT_CACHE_LOG` to relocate the debug log. +- Plugin `options` support, so scope and key can be set from `opencode.jsonc`. + Environment variables take precedence over options. +- Always-on, deduplicated operator warnings for compatibility failures, so a + renamed upstream field surfaces without the debug flag being on first. A + provider that simply has no cache key field is **not** one of those failures + and is silent: Anthropic and every `@ai-sdk/openai-compatible` provider + (DeepSeek among them) have no such setting, so warning there would fire on a + routine configuration and teach operators to tune out the channel. That case + is recorded in the debug log as `reason=no-fields`. +- A README section stating exactly which providers the plugin does and does not + affect, so its scope is knowable without reading the source. +- A `package.json`, so the plugin can be installed by npm identifier. +- A test suite and CI. + +### Removed + +- The "digest detection to avoid double-hashing" behavior, which could never + fire: it was only reachable from a precedence level that was itself + unreachable. Explicit overrides are now used verbatim when they are safe. diff --git a/README.md b/README.md index 53644c3..1de6b0b 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,223 @@ # opencode-context-cache -Enhanced prompt cache and sticky session management plugin for OpenCode. +An [opencode](https://opencode.ai) plugin that gives your sessions a **prompt +cache key that stays stable across sessions in the same git worktree**, instead +of opencode's default of a fresh key per session. -This project provides an OpenCode plugin that generates a stable, privacy-preserving cache key and applies it consistently across providers by writing both: +opencode derives the upstream prompt cache key from the session ID, which is new +every session. That means each new session starts with a cold prompt cache even +when the prompt prefix - system prompt, `AGENTS.md`, tool schemas - is +byte-identical to the last one. This plugin replaces that value, and only that +value, with a digest of your worktree path. -- `output.options.promptCacheKey` -- model session headers (`x-session-id`, `conversation_id`, `session_id`) +## Breaking change in 0.2.0 -Observed result from a real run: input cache hit rate improved from a near-zero baseline to `97.99%` (`164736 / 168112`). +**The plugin no longer writes the `x-session-id`, `conversation_id` or +`session_id` headers.** If you run behind a relay or gateway that reads those +underscore-spelled names, reconfigure it to read the headers opencode core +already sends: `x-session-affinity` and `X-Session-Id`, both derived from the +real session ID. -## Community +Those header names identify a *conversation*, and a project-stable value is +wrong in them. On opencode's OpenAI/Codex path, `x-session-affinity` keys a +WebSocket connection pool with per-conversation `busy` and `fallback` state, so +a per-project value would make every concurrent session in a project share one +socket, and would let one oversized message disable the fast path for all of +them. -- Discussions: https://github.com/JackDrogon/opencode-context-cache/discussions -- Issues: https://github.com/JackDrogon/opencode-context-cache/issues +See [CHANGELOG.md](CHANGELOG.md) for the full list. -## Installation +## How it works -### Required: explicit config loading +1. opencode core sets the prompt cache key to the current session ID. +2. This plugin's `chat.params` hook replaces that value with + `sha256("@:")`. +3. It replaces the value **only if it still equals the session ID**, which is + what opencode itself just put there. Any other value - your own setting, a + model or agent option, another plugin's - is left untouched. -In this repository's verified setup, the plugin only takes effect when it is listed in the -`plugin` field of `opencode.jsonc`. Copying the file into a plugins directory alone is not -enough in this environment. +The test in step 3 is value equality, not a provenance token, because opencode +gives the hook nothing else to go on. The one case it cannot distinguish is +something else deliberately setting the key *to the current session ID*, which +this plugin will then replace. That value is unguessable ahead of time and +expresses the same intent as opencode's default, so the practical exposure is +nil - but if you need the plugin to keep its hands off entirely, use +`OPENCODE_CONTEXT_CACHE_SCOPE=session`. -1. Put plugin file in a stable local path (example: global plugin dir): +That last rule is what makes the plugin provider-agnostic without carrying a +provider table: it only ever overwrites opencode's own output, so opencode's +decision about *whether* a given provider gets a cache key, and under which +spelling, is inherited for free. -```bash -mkdir -p ~/.config/opencode/plugins -cp plugins/opencode-context-cache.mjs ~/.config/opencode/plugins/opencode-context-cache.mjs -``` +## Install -2. Add plugin entry in `opencode.jsonc`: +### From npm ```jsonc +// opencode.jsonc { "$schema": "https://opencode.ai/config.json", - "plugin": [ - "./plugins/opencode-context-cache.mjs" - ] + "plugin": ["opencode-context-cache"] } ``` -For global config (`~/.config/opencode/opencode.jsonc`), `./plugins/...` is resolved -relative to `~/.config/opencode/`. - -3. Restart OpenCode after editing config. - -### Activation prerequisites (important) - -This plugin only takes effect after OpenCode actually loads the plugin file. - -Required method: - -1. Add it explicitly in `opencode.jsonc` with the `plugin` field. - -`setCacheKey` / model cache flags only control cache behavior. They do not load the plugin by themselves. - -## Observed impact (example) +### By copying the file -Before enabling this plugin, cache hits were near zero in repeated sessions. - -After enabling the plugin, one observed run reported: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugins/opencode-context-cache.mjs ~/.config/opencode/plugins/ +``` -```json +```jsonc { - "input_tokens": 168112, - "total_tokens": 173268, - "output_tokens": 5156, - "input_tokens_details": { - "cached_tokens": 164736 - }, - "output_tokens_details": { - "reasoning_tokens": 3698 - } + "$schema": "https://opencode.ai/config.json", + "plugin": ["./plugins/opencode-context-cache.mjs"] } ``` -Derived metrics: +For the global config at `~/.config/opencode/opencode.jsonc`, a `./plugins/...` +path resolves relative to `~/.config/opencode/`. -- Input cache hit rate: `164736 / 168112 = 97.99%` -- Uncached input tokens: `168112 - 164736 = 3376` (`2.01%`) -- Cached input tokens reused: `164736` +**The `plugin` entry is required either way.** Dropping the file into a plugins +directory does not load it. Restart opencode after editing the config. -Interpretation: - -- Most prompt input was served from cache after key stabilization. -- Compared with a near-zero-hit baseline, this indicates a major cache reuse improvement. -- The effect is often stronger behind AI API relay/gateway services, where unstable upstream session identifiers can otherwise reduce cache reuse. -- Actual latency/cost gains depend on model/provider pricing and cache policy. - -## Compatibility - -- Runtime: OpenCode plugin system (`chat.params` hook) -- Provider support: provider-agnostic (works across all configured providers) -- Module format: ESM (`.mjs`) +## Configuration -## Exports +| Variable | Default | Effect | +|---|---|---| +| `OPENCODE_CONTEXT_CACHE_SCOPE` | `worktree` | `worktree`, `directory`, or `session`. | +| `OPENCODE_PROMPT_CACHE_KEY` | unset | Use this exact key instead of a derived one. | +| `OPENCODE_STICKY_SESSION_ID` | unset | Deprecated alias for the above. Warns once. | +| `OPENCODE_CONTEXT_CACHE_DEBUG` | unset | `1` or `true` enables the debug log. | +| `OPENCODE_CONTEXT_CACHE_LOG` | `$XDG_STATE_HOME/opencode/context-cache.log` | Debug log location. | -- Default export: `OpenCodeContextCachePlugin` +The same settings can come from the config file: -## Why this plugin exists +```jsonc +{ + "plugin": [["opencode-context-cache", { "scope": "directory" }]] +} +``` -OpenCode sessions can lose cache efficiency when session identifiers vary between providers, environments, or runs. This plugin standardizes cache key generation with predictable precedence and sends only a SHA256 digest upstream. +**Precedence:** environment variables beat plugin options, which beat defaults. +The one exception is `scope: session`, which is parsed first and disables the +plugin's key outright, even if an explicit key is set. It is the opt-out switch, +so a forgotten `OPENCODE_PROMPT_CACHE_KEY` must not be able to defeat it. -## Features +### Choosing a scope -- Per-project cache isolation using `user@host:` by default -- Works with all providers (no provider-specific branching) -- Stable cache key precedence with environment overrides -- SHA256 hashing for privacy (raw key is not sent to server) -- Digest detection to avoid double-hashing existing SHA256 values -- Especially effective with AI API relay/gateway setups that benefit from stable cache/session identity -- Optional debug logging to a local log file +`worktree` shares one key across every session inside a checkout, which is where +the reuse is - subdirectories of one repo have identical system prompts and tool +schemas. Separate git worktrees get separate keys, since they hold different +branches. -## OpenCode loading behavior +Narrow to `directory`, or opt out with `session`, if you run many concurrent +sessions with genuinely different prompt prefixes in one repo, or if your +provider treats this field as a cache *lookup* key rather than a routing hint +(DeepInfra documents it that way and suggests a per-session value). -For this project, use explicit `plugin` entry in `opencode.json` / `opencode.jsonc` as the -source of truth. Directory auto-loading behavior may vary by runtime/version, so do not rely -on file placement alone for activation. +## Provider support -## Repository layout +This sets the OpenAI-family fields `promptCacheKey` and `prompt_cache_key`, and +only where opencode core seeds one. Core picks by the provider's SDK package, +so the list below is core's decision, not this plugin's: -- `plugins/opencode-context-cache.mjs`: main plugin implementation +**The key is applied for:** OpenAI, Azure, xAI, Mistral, Venice (`promptCacheKey`), +DeepInfra, Cerebras (`prompt_cache_key`), opencode's own provider, and any +provider you opt in with `setCacheKey: true`. -## Cache key precedence +**The plugin does nothing for everything else**, which is most of the catalog: -The plugin resolves the raw cache key in this order: +- **Anthropic** caches via `cache_control` breakpoints on message content and + has no cache key parameter. +- **DeepSeek and every other `@ai-sdk/openai-compatible` provider.** DeepSeek's + context caching is automatic and prefix-based - [enabled by default, with no + code change and no key to set](https://api-docs.deepseek.com/guides/kv_cache). + You can confirm it is working in the response's `prompt_cache_hit_tokens`. + Core seeds no field for these providers, so there is no correct value to write. -1. `OPENCODE_PROMPT_CACHE_KEY` -2. `OPENCODE_STICKY_SESSION_ID` -3. Auto-generated `user@host:` -4. Existing model headers (`x-session-id`, `conversation_id`, `session_id`) -5. OpenCode `sessionID` +On those providers the plugin is inert **and silent**: nothing is broken, there +is simply no such setting to pin. Run with `OPENCODE_CONTEXT_CACHE_DEBUG=1` and +the log says so per request (`reason=no-fields`). -Then it applies: +Do not reach for `setCacheKey: true` to force it on an openai-compatible +provider. Core routes that flag to the *camelCase* spelling, and the +openai-compatible SDK passes unrecognised options into the request body +verbatim - so the wire gets a literal `"promptCacheKey"` field, which is not the +`prompt_cache_key` an OpenAI-style API reads. It buys nothing and risks a 400. -- SHA256 hashing for normal keys -- No re-hash if the selected key already looks like a SHA256 hex digest +## About the hashing -Result: +The key is hashed so your local username, hostname and absolute path do not +travel to whatever gateway you use. That is all it is for. It is not a privacy +control: the pre-image is `user@host:/path`, and anyone who knows your username +and hostname can enumerate candidate paths cheaply. -- The server receives only the hashed value. -- Sticky routing headers and `promptCacheKey` stay aligned. +An explicit `OPENCODE_PROMPT_CACHE_KEY` is passed through verbatim, since you +chose it - unless it exceeds 64 characters or contains non-printable characters, +in which case it is hashed so the provider cannot reject it. -## How it works +## Observed impact -The plugin registers the `chat.params` hook and: +One run on one provider reported a 97.99% input cache hit rate +(`164736 / 168112` tokens) after enabling a stable key, against a near-zero +baseline before it. -1. Computes the stable cache key (raw -> hashed) -2. Sets `output.options.promptCacheKey = ` -3. Sets model headers to the same hashed value: - - `x-session-id` - - `conversation_id` - - `session_id` -4. Adds `x-cache-debug: 1` when debug mode is enabled +Treat that as an anecdote, not a benchmark: it is a single uncontrolled +observation, with no matched workload and no repetition, and the gain depends +entirely on how much of your prompt prefix is actually stable between sessions. -This keeps routing and prompt cache identity aligned. +## Troubleshooting -## Configuration +Set `OPENCODE_CONTEXT_CACHE_DEBUG=1` and read the log (path in the table above). +A working setup logs the resolved key source at startup and one line per +request naming the fields it applied. -OpenCode config flags (often required for expected cache behavior): +Warnings go to stderr regardless of the debug flag, deduplicated to once each, +and are mirrored into the debug log so it stays a complete record. -- `provider..options.setCacheKey: true`: ensures the provider layer forwards a cache key when this plugin sets `output.options.promptCacheKey`. -- `provider..models..options.cache`: if your provider/model exposes this flag and it is set to `false`, upstream prompt caching is effectively disabled. -- `provider..models..options.store`: this is separate from prompt caching; for example, `store: false` controls response storage and does not replace `setCacheKey`. +A provider that has no cache key field at all produces **no warning** - see +[Provider support](#provider-support). It appears in the debug log only, as +`reason=no-fields`. -Minimal working `opencode.jsonc` example (required explicit plugin loading + cache flags): +Expected, informational: -```jsonc -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - "./plugins/opencode-context-cache.mjs" - ], - "provider": { - "openai": { - "options": { - "setCacheKey": true - }, - "models": { - "gpt-5-3-codex-high": { - "options": { - // "cache": false, - // If your provider supports the cache flag, setting it to false - // disables upstream prompt cache reuse. - "store": false - } - } - } - } - } -} -``` +- **"carries a prompt cache key this plugin did not set"** - something else set + the key first and the plugin left it alone. Check for a conflicting + `providerOptions` entry or another plugin. +- **"exposes ... but opencode left it empty"** - the field exists but is unset, + so provenance could not be confirmed. Nobody else set it; nothing to hunt for. -Do not omit the `plugin` field above in this setup. +These mean the plugin is inert and caching has reverted to a per-session key: -Environment variables: +- **"could not derive a project path"** - opencode gave no usable `worktree` or + `directory`. Set `OPENCODE_PROMPT_CACHE_KEY` to pin a key explicitly. +- **"no options object to write to"** or **"no sessionID"** - these cannot happen + against a working opencode. If you see one, an opencode upgrade changed a + shape this plugin depends on. Run `npm run test:integration` against your + binary, and please open an issue. +- **"falls back to unknown@unknown-host"** - the local username or hostname could + not be read, so the key is not unique to this machine: every host failing the + same way in the same project path shares it. Common in containers. Set + `OPENCODE_PROMPT_CACHE_KEY`. +- **"disabled by an unexpected startup error"** - the plugin caught a startup + failure and loaded inert rather than breaking opencode. Please open an issue. -- `OPENCODE_PROMPT_CACHE_KEY`: highest-priority manual cache key override -- `OPENCODE_STICKY_SESSION_ID`: secondary manual override -- `OPENCODE_CONTEXT_CACHE_DEBUG`: set to `1` or `true` to enable debug logging +If nothing is logged at all, the plugin is not loaded: check the `plugin` entry +in your config and restart. -Example shell setup: +## Development ```bash -export OPENCODE_CONTEXT_CACHE_DEBUG=1 -# Optional override: -# export OPENCODE_PROMPT_CACHE_KEY="team-cache-key" +npm test # unit suite, no dependencies to install +npm run test:integration # opt-in; needs a local opencode binary, else skips ``` -## Debug logging - -When debug mode is enabled, logs are appended to: - -- `context-cache.log` in the same directory as the plugin file - -Log entries include timestamp, process ID, key source, and hashed output details. -The log prefix is `[context-cache]`. - -## Verify plugin is active - -Use this checklist: - -1. Confirm `opencode.jsonc` contains `"plugin": ["./plugins/opencode-context-cache.mjs"]` - (or the equivalent valid path in your setup). -2. Start or restart OpenCode. -3. Ensure `OPENCODE_CONTEXT_CACHE_DEBUG=1` is set. -4. Open the log file in your plugin directory. -5. Confirm entries like: - - `Plugin initialized` - - `Using cache key from ...` - - `Set final cache key (hashed): ...` - -If these lines appear, the plugin is loaded and processing requests. - -## Troubleshooting - -- No log file created: - - Check file path and permissions for the plugin directory. - - Confirm `OPENCODE_CONTEXT_CACHE_DEBUG` is `1` or `true`. -- Plugin not loading: - - Verify filename and extension (`opencode-context-cache.mjs`). - - Verify `opencode.jsonc` includes a valid `plugin` entry for this file. - - Verify the `plugin` path is resolved relative to the config file location. - - Restart OpenCode after changes. -- Unexpected cache key changes: - - The default key includes absolute working directory. - - Moving or renaming the project path changes the key. - - Use `OPENCODE_PROMPT_CACHE_KEY` for a fixed identity. -- Potential duplicate execution: - - If your runtime also auto-loads plugin directories, avoid loading the same file twice. - -## Security recommendations - -- Do not commit API keys to `opencode.jsonc`; prefer environment variables. -- Treat override keys as shared identity controls and rotate them if needed. -- Consider adding plugin log files to `.gitignore` if logs may include operational metadata. - -## Notes - -- The default key uses absolute working directory, so moving a project path changes the key. -- Use an explicit override if you need stable cache identity across different paths. -- Sharing the same override key across projects intentionally merges cache/session identity. +The integration suite is a compatibility gate against the real opencode binary. +Run it before upgrading opencode: it asserts the two facts this plugin depends +on, that the plugin factory is invoked once per project and that +`PluginInput.worktree` is the VCS root. ## License -MIT. See `LICENSE`. +MIT. See [LICENSE](LICENSE). diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ff0359 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "opencode-context-cache", + "version": "0.2.0", + "description": "Stable prompt cache key for opencode sessions, scoped to the git worktree", + "type": "module", + "main": "plugins/opencode-context-cache.mjs", + "exports": { + ".": "./plugins/opencode-context-cache.mjs" + }, + "files": [ + "plugins/", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "scripts": { + "pretest": "node scripts/check-test-files.mjs test", + "test": "node --test test/unit/export-shape.test.mjs test/unit/cache-key.test.mjs test/unit/apply-cache-key.test.mjs test/unit/logger.test.mjs test/unit/plugin-hook.test.mjs", + "pretest:integration": "node scripts/check-test-files.mjs test:integration", + "test:integration": "node --test test/integration/opencode-contract.test.mjs test/integration/plugin-input-contract.test.mjs" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "prompt-cache" + ], + "license": "MIT", + "engines": { + "node": ">=20" + } +} diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index 4e9bbae..d65136b 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -1,337 +1,549 @@ /** * opencode plugin: OpenCode Context Cache * - * Features: - * - Per-project cache isolation using absolute path with user@host prefix - * - Support for ALL providers (not just specific ones) - * - Debug logging to file (same directory as plugin) - * - Smart cache key generation with multiple fallbacks - * - Unified session header and cache key management - * - SHA256 hashed cache key for privacy (server sees only hash) + * Gives opencode a prompt cache key that is stable across sessions in the same + * git worktree, instead of core's default of a fresh session ID per session. * - * Cache Key Format (raw): {user}@{host}:{directory} - * Cache Key Format (sent to server): SHA256(raw) - * Example: c@my-laptop:revm -> sha256:abc123... - * - * Cache Key Precedence: - * 1. OPENCODE_PROMPT_CACHE_KEY env var (manual override) - * 2. OPENCODE_STICKY_SESSION_ID env var (manual override) - * 3. User@Host:Directory (auto-generated) - * 4. Model headers (x-session-id / conversation_id / session_id) - * 5. opencode sessionID (fallback) + * It sets exactly one thing: the prompt cache key field opencode core has + * already placed in `output.options`, and only when that field still holds + * core's own session-ID default. It writes no headers. */ -import { hostname, userInfo } from "os"; +import { hostname, homedir, userInfo } from "os"; import { dirname, join } from "path"; -import { appendFileSync, existsSync, mkdirSync } from "fs"; -import { fileURLToPath } from "url"; +import { appendFileSync, mkdirSync } from "fs"; import { createHash } from "crypto"; -const SESSION_ID_HEADER_NAMES = ["x-session-id", "conversation_id", "session_id"]; const PROMPT_CACHE_KEY_ENV_VAR = "OPENCODE_PROMPT_CACHE_KEY"; const STICKY_SESSION_ID_ENV_VAR = "OPENCODE_STICKY_SESSION_ID"; -const CACHE_DEBUG_ENV_VAR = "OPENCODE_CONTEXT_CACHE_DEBUG"; - -// Get plugin directory (where this file is located) -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const LOG_FILE_PATH = join(__dirname, "context-cache.log"); - -class DebugLogger { - constructor(logFilePath) { - this.logFilePath = logFilePath; - this.debugEnabled = null; - this.loggedInputStructure = false; - this.ensureLogDirectory(); - } +const SCOPE_ENV_VAR = "OPENCODE_CONTEXT_CACHE_SCOPE"; +const DEBUG_ENV_VAR = "OPENCODE_CONTEXT_CACHE_DEBUG"; +const LOG_PATH_ENV_VAR = "OPENCODE_CONTEXT_CACHE_LOG"; - ensureLogDirectory() { - try { - const logDir = dirname(this.logFilePath); - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); - } - } catch { - // Ignore errors, fallback will use console. - } - } +/** OpenAI is reported to cap prompt_cache_key at 64 characters; a sha256 hex digest is exactly 64. */ +const MAX_CACHE_KEY_LENGTH = 64; - isEnabled() { - if (this.debugEnabled === null) { - this.debugEnabled = - process?.env?.[CACHE_DEBUG_ENV_VAR] === "1" || - process?.env?.[CACHE_DEBUG_ENV_VAR] === "true"; - } - return this.debugEnabled; - } +const SCOPES = ["worktree", "directory", "session"]; - toLogString(value) { - if (typeof value !== "object" || value === null) { - return String(value); - } +const PRINTABLE_ASCII = /^[\x20-\x7E]+$/; - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } +/** + * Ceiling on distinct warning keys held per plugin instance. The error key + * embeds the error text so that a second, unrelated failure is not suppressed + * forever - which means a fault producing a unique message per request would + * otherwise grow the set without bound in a long-lived server. + */ +const WARNING_KEY_LIMIT = 64; - log(...args) { - if (!this.isEnabled()) return; +function sha256(value) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} - const timestamp = new Date().toISOString(); - const pid = process.pid; - const message = args.map((arg) => this.toLogString(arg)).join(" "); +function fingerprint(value) { + return sha256(value).slice(0, 8); +} - // Keep each log entry on a single physical line. - const safeMessage = message.replace(/\n/g, "\\n").replace(/\r/g, "\\r"); - const logLine = `[${timestamp}] [pid:${pid}] [context-cache] ${safeMessage}\n`; +function readEnv(env, name) { + const value = env?.[name]; + return typeof value === "string" ? value.trim() : ""; +} - try { - // O_APPEND keeps each append atomic on POSIX filesystems. - appendFileSync(this.logFilePath, logLine, "utf8"); - } catch { - // Fallback to stderr when file append fails. - console.error(`[pid:${pid}] [context-cache]`, ...args); - } - } +/** Paths are used verbatim: only a whitespace-only path counts as absent. */ +function usablePath(value) { + return typeof value === "string" && value.trim() !== "" ? value : ""; +} - logInputStructureOnce(input) { - if (this.loggedInputStructure) return; - this.loggedInputStructure = true; +function isSafeOverride(value) { + return value.length <= MAX_CACHE_KEY_LENGTH && PRINTABLE_ASCII.test(value); +} - const safeInput = { - hasProvider: !!input?.provider, - providerKeys: input?.provider ? Object.keys(input.provider) : [], - hasModel: !!input?.model, - modelKeys: input?.model ? Object.keys(input.model) : [], - hasSessionID: !!input?.sessionID, - }; - this.log("Input structure:", safeInput); - } +/** Single source of truth for where a scope setting may come from. */ +function scopeSetting(env, options) { + return readEnv(env, SCOPE_ENV_VAR) || (typeof options?.scope === "string" ? options.scope : ""); } -class CacheKeyResolver { - constructor(logger) { - this.logger = logger; - } +function parseScope(raw) { + const value = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + if (value === "") return { scope: "worktree", unknown: null }; + if (SCOPES.includes(value)) return { scope: value, unknown: null }; + return { scope: "worktree", unknown: value }; +} - sha256(input) { - return createHash("sha256").update(input, "utf8").digest("hex"); - } +/** + * Guards against a degenerate "/" worktree, which would otherwise collapse + * every project on the machine onto a single key. + * + * Core's own project-path helper is `vcs === "git" && worktree !== "/"`. Only + * the second clause is reproduced here: opencode sets `worktree` to the session + * directory when there is no VCS, so a non-git project already falls through to + * the same value, and consulting `project.vcs` would add a branch with no + * behavioural difference. + */ +function selectScopePath({ scope, worktree, directory }) { + const tree = usablePath(worktree); + const dir = usablePath(directory); + if (scope === "session") return ""; + if (scope === "directory") return dir; + if (tree && tree.trim() !== "/") return tree; + return dir; +} - isSha256Hex(value) { - if (typeof value !== "string") return false; - const v = value.trim(); - if (v.length !== 64) return false; - return /^[a-fA-F0-9]{64}$/.test(v); +function resolveCacheKey({ env = {}, options = {}, worktree, directory, user, host } = {}) { + // Scope is parsed first so that `session` is a genuine opt-out: a stale + // override must not be able to defeat the safety valve. + const { scope, unknown: unknownScope } = parseScope(scopeSetting(env, options)); + if (scope === "session") return null; + + const explicit = [ + [readEnv(env, PROMPT_CACHE_KEY_ENV_VAR), PROMPT_CACHE_KEY_ENV_VAR, false], + [readEnv(env, STICKY_SESSION_ID_ENV_VAR), STICKY_SESSION_ID_ENV_VAR, true], + [typeof options?.cacheKey === "string" ? options.cacheKey.trim() : "", "options.cacheKey", false], + ].find(([raw]) => raw !== ""); + + if (explicit) { + const [raw, source, deprecated] = explicit; + const safe = isSafeOverride(raw); + return { raw, value: safe ? raw : sha256(raw), source, hashed: !safe, sensitive: true, deprecated, unknownScope }; } - getTrimmedEnv(name) { - const value = process?.env?.[name]; - return typeof value === "string" ? value.trim() : ""; - } + const path = selectScopePath({ scope, worktree, directory }); + if (!path) return null; - getUsername() { - try { - const ui = userInfo(); - if (ui && ui.username) { - return ui.username; - } - } catch { - // userInfo may fail in restricted environments. - } + const raw = `${user}@${host}:${path}`; + return { + raw, + value: sha256(raw), + source: `user@host:${scope}`, + hashed: true, + sensitive: false, + deprecated: false, + unknownScope, + }; +} - return ( - process?.env?.USER || - process?.env?.USERNAME || - process?.env?.LOGNAME || - "unknown" - ); +function getUsername({ env = process.env, readUserInfo = userInfo } = {}) { + try { + const info = readUserInfo(); + if (info?.username) return info.username; + } catch { + // userInfo throws in some restricted environments; fall through to env. } + return env?.USER || env?.USERNAME || env?.LOGNAME || "unknown"; +} - getUserHostDirectoryKey() { - try { - const user = this.getUsername(); - const host = hostname(); - const cwd = process.cwd(); - return `${user}@${host}:${cwd}`; - } catch { - return null; - } +function safeHostname({ readHostname = hostname } = {}) { + try { + return readHostname() || "unknown-host"; + } catch { + return "unknown-host"; + } +} + +function safeHomedir({ readHomedir = homedir } = {}) { + try { + return readHomedir() || ""; + } catch { + // homedir throws in the same restricted environments userInfo does: no HOME + // and a getpwuid that fails, which is an ordinary container setup. + return ""; } +} - getSessionIdFromHeaders(input) { - const headers = - input?.model?.headers && typeof input.model.headers === "object" - ? input.model.headers - : {}; +function defaultLogPath(env = {}, home) { + const explicit = readEnv(env, LOG_PATH_ENV_VAR); + // Resolved lazily: as a default parameter this ran on every call, including + // when an explicit path made it irrelevant. + if (explicit) return explicit; + const stateHome = readEnv(env, "XDG_STATE_HOME") || join(home ?? safeHomedir(), ".local", "state"); + return join(stateHome, "opencode", "context-cache.log"); +} - const value = SESSION_ID_HEADER_NAMES.map((key) => headers[key]) - .find((v) => typeof v === "string" && v.trim()) - ?.trim?.(); +/** + * A generated key built on placeholder identity is not unique to this machine: + * every host that fails the same way, in the same project path, derives the + * same key. Returns the warning text, or null when identity is sound or the + * key does not depend on it. + */ +function identityWarning({ user, host, sensitive }) { + if (sensitive) return null; + const badUser = user === "unknown"; + const badHost = host === "unknown-host"; + if (!badUser && !badHost) return null; + const missing = badUser && badHost ? "username or hostname" : badUser ? "username" : "hostname"; + return ( + `could not determine the local ${missing}, so the cache key falls back to ` + + `"${user}@${host}:". Every machine with the same failure and the same project path ` + + `will share it. Set ${PROMPT_CACHE_KEY_ENV_VAR} to pin a distinct key.` + ); +} - return value || null; +/** Stringify a thrown value that we did not create, without throwing. */ +function describeError(error) { + try { + if (error instanceof Error) return `${error.name}: ${error.message}`; + return String(error); + } catch { + return "unstringifiable error"; } +} - resolveCacheKey(input) { - let rawKey = null; - let source = null; - let alreadyHashed = false; - - // 1) Explicit env override. - const promptCacheKey = this.getTrimmedEnv(PROMPT_CACHE_KEY_ENV_VAR); - if (promptCacheKey) { - rawKey = promptCacheKey; - source = PROMPT_CACHE_KEY_ENV_VAR; - } +function safeJson(value) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} - // 2) Secondary env override. - if (!rawKey) { - const stickySessionKey = this.getTrimmedEnv(STICKY_SESSION_ID_ENV_VAR); - if (stickySessionKey) { - rawKey = stickySessionKey; - source = STICKY_SESSION_ID_ENV_VAR; - } - } +function createLogger({ env = {}, filePath, write = appendFileSync, warn = console.warn } = {}) { + const flag = String(env?.[DEBUG_ENV_VAR] ?? "").trim().toLowerCase(); + const enabled = flag === "1" || flag === "true"; + const path = filePath ?? defaultLogPath(env); + const warned = new Set(); + let overflowed = false; + let fileUsable = true; + let dirReady = false; - // 3) Preferred stable default. - if (!rawKey) { - const userHostDirKey = this.getUserHostDirectoryKey(); - if (userHostDirKey) { - rawKey = userHostDirKey; - source = "user@host:directory"; - } + function emit(message) { + try { + warn(`[context-cache] ${message}`); + return true; + } catch { + // A failing warning sink must never escape into the request path. The + // caller declines to latch the key, so a sink that recovers still gets + // the message. + return false; } + } - // 4) Existing model headers only when no stable default exists. - if (!rawKey) { - const headerValue = this.getSessionIdFromHeaders(input); - if (headerValue) { - rawKey = headerValue; - source = "model headers"; - alreadyHashed = this.isSha256Hex(rawKey); + const api = { + enabled, + path, + debug(...args) { + if (!enabled || !fileUsable) return; + const body = args + .map((arg) => (typeof arg === "object" && arg !== null ? safeJson(arg) : String(arg))) + .join(" ") + .replace(/\r?\n/g, "\\n"); + try { + if (!dirReady) { + mkdirSync(dirname(path), { recursive: true }); + dirReady = true; + } + write(path, `[${new Date().toISOString()}] [pid:${process.pid}] [context-cache] ${body}\n`, "utf8"); + } catch (error) { + fileUsable = false; + emit( + `cannot write debug log at ${path}: ${describeError(error)}; debug logging disabled ` + + "for this process. Restart opencode after fixing it to re-enable.", + ); } - } + }, - // 5) OpenCode session fallback. - if (!rawKey) { - const sessionID = typeof input?.sessionID === "string" ? input.sessionID : ""; - if (sessionID) { - rawKey = sessionID; - source = "opencode sessionID"; + /** + * Always on, independent of the debug flag, and deduplicated. A + * compatibility failure must be visible without the operator having first + * guessed to turn debug logging on. + */ + warnOnce(key, message) { + if (warned.has(key)) return false; + if (warned.size >= WARNING_KEY_LIMIT) { + // Past the ceiling, stop growing the set and stop competing for stderr. + // Detail stays available in the debug log, which is opt-in. + if (!overflowed) { + overflowed = true; + emit( + `more than ${WARNING_KEY_LIMIT} distinct warnings; suppressing further ones on stderr. ` + + `Set ${DEBUG_ENV_VAR}=1 for the full record.`, + ); + } + api.debug(`WARN (suppressed) ${message}`); + return false; } - } - - if (!rawKey) { - this.logger.log("No stable cache key found"); - return null; - } + if (!emit(message)) return false; + warned.add(key); + // The always-on channel writes to stderr, which under opencode's TUI can + // be redrawn away. Mirror it into the durable log so an operator who + // turns debug on gets a complete record rather than one with the + // warnings missing. Called through `api`, not `this`, so a destructured + // `const { warnOnce } = logger` keeps working. + api.debug(`WARN ${message}`); + return true; + }, + }; - const hashedKey = alreadyHashed ? rawKey : this.sha256(rawKey); + return api; +} - if (alreadyHashed) { - this.logger.log("Cache key already looks hashed; skipping sha256"); - } +/** The two spellings opencode core uses, depending on provider. */ +const CACHE_KEY_FIELDS = ["promptCacheKey", "prompt_cache_key"]; - this.logger.log(`Using cache key from ${source}`); - this.logger.log(` Raw: ${rawKey}`); - this.logger.log(` Hash: ${hashedKey}`); +const SES_PREFIXED = /^ses_[0-9a-f]{64}$/; - return { raw: rawKey, hashed: hashedKey }; - } +/** Core sends the digest without the ses_ prefix on its own zen provider path. */ +function stripSesPrefix(sessionID) { + return SES_PREFIXED.test(sessionID) ? sessionID.slice(4) : sessionID; } -class CacheKeyApplier { - constructor(logger) { - this.logger = logger; +/** + * Replace a cache key field only when it still holds core's session-ID default. + * Field presence alone does not prove core set the value: model, agent and + * variant options can carry the field, and a plugin ordered before this one can + * add it. Matching the session ID is exact provenance, and it inherits core's + * whole provider table without duplicating it. + */ +function applyCacheKey(output, value, sessionID) { + const options = output?.options; + if (!options || typeof options !== "object") { + return { appliedFields: [], foreignFields: [], emptyFields: [], reason: "invalid-options" }; } - - applyPromptCacheKey(output, cacheKey) { - const existingOutputOptions = - output?.options && typeof output.options === "object" ? output.options : {}; - - output.options = { - ...existingOutputOptions, - promptCacheKey: cacheKey, - }; + if (typeof sessionID !== "string" || sessionID === "") { + return { appliedFields: [], foreignFields: [], emptyFields: [], reason: "missing-session" }; } - applySessionHeaders(input, cacheKey) { - if (input?.model && typeof input.model === "object") { - const headers = - input.model.headers && typeof input.model.headers === "object" - ? input.model.headers - : (input.model.headers = {}); - - for (const headerKey of SESSION_ID_HEADER_NAMES) { - headers[headerKey] = cacheKey; - } - - if (this.logger.isEnabled()) { - headers["x-cache-debug"] = "1"; - } - - this.logger.log("Set final cache key (hashed):", cacheKey); - return; + const stripped = stripSesPrefix(sessionID); + const appliedFields = []; + const foreignFields = []; + const emptyFields = []; + const replacements = {}; + + for (const field of CACHE_KEY_FIELDS) { + // hasOwn, not `in`: the spread below copies only own properties, and a + // polluted Object.prototype must not look like a field opencode set. + if (!Object.hasOwn(options, field)) continue; + const current = options[field]; + if (current === sessionID || current === stripped) { + replacements[field] = value; + appliedFields.push(field); + } else if (current === undefined || current === null) { + // Present but unset. Provenance is still unproven so we must not write, + // but nobody "set" this, and saying so sends the operator hunting for a + // conflicting plugin that does not exist. + emptyFields.push(field); + } else { + foreignFields.push(field); } - - this.logger.log("Input model is missing or not an object, cannot set session headers"); } - apply(input, output, cacheKey) { - this.applyPromptCacheKey(output, cacheKey); - this.applySessionHeaders(input, cacheKey); + if (appliedFields.length === 0 && foreignFields.length === 0 && emptyFields.length === 0) { + return { appliedFields, foreignFields, emptyFields, reason: "no-fields" }; } + if (appliedFields.length > 0) output.options = { ...options, ...replacements }; + return { appliedFields, foreignFields, emptyFields, reason: null }; } -class ContextCachePluginRuntime { - constructor({ logger, keyResolver, keyApplier }) { - this.logger = logger; - this.keyResolver = keyResolver; - this.keyApplier = keyApplier; - } - - initialize() { - this.logger.log("Plugin initialized"); - this.logger.log("Log file location:", this.logger.logFilePath); - } - - handleChatParams(input, output) { - this.logger.logInputStructureOnce(input); - this.logger.log("Processing provider"); - - const cacheKeyInfo = this.keyResolver.resolveCacheKey(input); - if (!cacheKeyInfo) { - this.logger.log("No cache key available"); - return; +const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { + // The whole factory is guarded. An unguarded throw here rejects the promise + // opencode is awaiting, so the plugin fails to load outright - strictly worse + // than loading and doing nothing. + try { + const env = process.env; + const logger = createLogger({ env, warn: typeof options?.warn === "function" ? options.warn : undefined }); + const user = getUsername({ env }); + const host = safeHostname(); + const { scope } = parseScope(scopeSetting(env, options)); + const resolved = resolveCacheKey({ + env, + options, + worktree: input?.worktree, + directory: input?.directory, + user, + host, + }); + + if (resolved?.unknownScope) { + logger.warnOnce( + "scope", + `unrecognised ${SCOPE_ENV_VAR} value "${resolved.unknownScope}"; expected one of ` + + `${SCOPES.join(", ")}. Falling back to worktree scope.`, + ); + } + if (resolved?.deprecated) { + logger.warnOnce( + "deprecated-env", + `${STICKY_SESSION_ID_ENV_VAR} is deprecated; use ${PROMPT_CACHE_KEY_ENV_VAR} instead.`, + ); + } + const identityIssue = resolved && identityWarning({ user, host, sensitive: resolved.sensitive }); + if (identityIssue) logger.warnOnce("identity-fallback", identityIssue); + + if (!resolved) { + if (scope === "session") { + logger.debug(`${SCOPE_ENV_VAR}=session: opted out, leaving opencode's session default in place`); + } else { + // Not an opt-out: we were asked for a stable key and could not build + // one. Silently reverting to a per-session key is the exact regression + // this plugin exists to prevent. + logger.warnOnce( + "no-path", + `could not derive a project path from opencode's PluginInput ` + + `(worktree=${safeJson(input?.worktree)}, directory=${safeJson(input?.directory)}), ` + + `so no stable cache key was set and prompt caching stays per-session. ` + + `Set ${PROMPT_CACHE_KEY_ENV_VAR} to pin one explicitly.`, + ); + } + } else { + logger.debug( + `cache key source=${resolved.source} hashed=${resolved.hashed}`, + // Never log the raw value of an operator-supplied override: it may carry + // a tenant name or a secret pasted into the env var by mistake. + resolved.sensitive ? `fingerprint=${fingerprint(resolved.raw)}` : `raw=${resolved.raw}`, + ); } - this.keyApplier.apply(input, output, cacheKeyInfo.hashed); + return { + "chat.params": async (hookInput, output) => { + if (!resolved) return; + let provider = "unknown"; + try { + // Coerced, not just read: every use below is a template literal, and + // ToString on a null-prototype object or a Symbol throws. + const label = hookInput?.model?.providerID ?? hookInput?.provider?.info?.id; + provider = typeof label === "string" && label !== "" ? label : "unknown"; + + const { appliedFields, foreignFields, emptyFields, reason } = applyCacheKey( + output, + resolved.value, + hookInput?.sessionID, + ); + + // invalid-options and missing-session cannot happen against a correct + // opencode. When they do, the shape upstream changed and the plugin is + // permanently inert, so they are exactly the states that must be loud. + if (reason === "invalid-options") { + logger.warnOnce( + `invalid-options:${provider}`, + "opencode gave this hook no options object to write to, so no cache key was applied. " + + "This should not happen: opencode may have changed the chat.params output shape. " + + "Prompt caching has reverted to a per-session key.", + ); + } else if (reason === "missing-session") { + logger.warnOnce( + `missing-session:${provider}`, + "opencode gave this hook no sessionID, so the cache key's provenance could not be " + + "checked and nothing was changed. This should not happen: opencode may have renamed " + + "the field. Prompt caching has reverted to a per-session key.", + ); + } else if (reason === "no-fields") { + // Not a fault, so not a warning. Core seeds a cache key field only + // for a fixed set of provider SDKs; Anthropic caches by + // `cache_control` breakpoint and the openai-compatible providers + // have no cache key in their API at all, so for those there is + // nothing correct to write. Warning on a routine configuration is + // how an operator learns to ignore the channel that also carries + // the states which do mean something. Kept in the debug log so + // "why is no key applied here" still has an answer. + logger.debug( + `provider ${provider} exposes no prompt cache key field, so none was applied. ` + + "This provider does not support one; if it used to work, opencode may have " + + "renamed the field.", + ); + } + + if (foreignFields.length > 0) { + logger.warnOnce( + `foreign:${provider}:${foreignFields.join(",")}`, + `provider ${provider} carries a prompt cache key this plugin did not set ` + + `(${foreignFields.join(", ")}); leaving those fields unchanged.`, + ); + } + if (emptyFields.length > 0) { + logger.warnOnce( + `empty:${provider}:${emptyFields.join(",")}`, + `provider ${provider} exposes ${emptyFields.join(", ")} but opencode left it empty, ` + + "so provenance could not be confirmed and no key was applied. If caching used to " + + "work here, opencode may have changed how it seeds this field.", + ); + } + + // Runs for every outcome: a debug log that goes quiet on the no-fields + // path cannot be told apart from a hook that is not running at all. + logger.debug( + `provider=${provider} applied=[${appliedFields.join(",")}] ` + + `foreign=[${foreignFields.join(",")}] empty=[${emptyFields.join(",")}] ` + + `reason=${reason ?? "none"}`, + ); + } catch (error) { + // Last resort. Bounded by the error text so a second, unrelated + // failure on the same provider is not suppressed forever, and itself + // wrapped because there is nothing left to fall back to. + try { + const what = describeError(error); + logger.warnOnce( + `error:${provider}:${what.slice(0, 120)}`, + `unexpected error applying cache key: ${what}`, + ); + } catch { + // Nothing further to try; the request must still proceed. + } + } + }, + }; + } catch (error) { + try { + // The logger may not exist yet, so go direct - but still honour an + // injected sink if the caller supplied one. + const sink = typeof options?.warn === "function" ? options.warn : console.warn; + sink(`[context-cache] disabled by an unexpected startup error: ${describeError(error)}`); + } catch { + // Nothing further to try; opencode must still get a usable plugin. + } + return { "chat.params": async () => {} }; } -} +}; -const logger = new DebugLogger(LOG_FILE_PATH); -const keyResolver = new CacheKeyResolver(logger); -const keyApplier = new CacheKeyApplier(logger); -const runtime = new ContextCachePluginRuntime({ - logger, - keyResolver, - keyApplier, +/** + * Helpers hang off the plugin function instead of being exported. + * + * DO NOT turn these back into named exports. opencode's loader walks + * `Object.values(module)` and requires every value to be a function (or an + * object with a `server` function): + * + * function Gy(x){ if (typeof x === "function") return x; + * if (!x || typeof x !== "object" || !("server" in x)) return; + * if (typeof x.server !== "function") return; return x.server } + * function Wy(m){ for (const x of Object.values(m)) { + * if (!Gy(x)) throw TypeError("Plugin export is not a function"); ... } } + * + * A single exported constant makes opencode refuse the whole plugin, and every + * distinct exported *function* is then invoked as a plugin factory - so an + * exported `sha256` would be called as `sha256(pluginInput, options)`. The + * three exports below are deliberately the same function object, which the + * loader deduplicates by identity into one plugin. + * + * `test/unit/export-shape.test.mjs` reproduces that check and will fail if this + * is undone. + */ +OpenCodeContextCachePlugin.internals = Object.freeze({ + PROMPT_CACHE_KEY_ENV_VAR, + STICKY_SESSION_ID_ENV_VAR, + SCOPE_ENV_VAR, + DEBUG_ENV_VAR, + LOG_PATH_ENV_VAR, + MAX_CACHE_KEY_LENGTH, + SCOPES, + CACHE_KEY_FIELDS, + sha256, + fingerprint, + isSafeOverride, + scopeSetting, + parseScope, + selectScopePath, + resolveCacheKey, + getUsername, + safeHostname, + safeHomedir, + identityWarning, + defaultLogPath, + describeError, + WARNING_KEY_LIMIT, + createLogger, + stripSesPrefix, + applyCacheKey, }); -export const OpenCodeContextCachePlugin = async () => { - runtime.initialize(); - - return { - "chat.params": async (input, output) => { - runtime.handleChatParams(input, output); - }, - }; -}; - -// Backward-compatible export alias. -export const EnhancedCachePlugin = OpenCodeContextCachePlugin; +/** Kept so existing configs importing the old name keep working. */ +const EnhancedCachePlugin = OpenCodeContextCachePlugin; +export { OpenCodeContextCachePlugin, EnhancedCachePlugin }; export default OpenCodeContextCachePlugin; diff --git a/scripts/check-test-files.mjs b/scripts/check-test-files.mjs new file mode 100644 index 0000000..3d22d49 --- /dev/null +++ b/scripts/check-test-files.mjs @@ -0,0 +1,40 @@ +/** + * Guard against a silently green test run. + * + * `node --test ` ignores a path that does not exist and still exits 0, + * and `node --test ` no longer scans directories on Node 24. Either way a + * renamed or deleted suite disappears without failing CI. This reads the file + * list back out of package.json and fails if any of it is missing. + * + * Dev tooling only: `scripts/` is not in the package.json `files` allowlist, + * so it is never published. + */ + +import { existsSync, readFileSync } from "node:fs"; + +const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +const target = process.argv[2] ?? "test"; +const script = pkg.scripts?.[target]; + +if (!script) { + console.error(`check-test-files: package.json has no "${target}" script`); + process.exit(1); +} + +const files = script.split(/\s+/).filter((token) => token.endsWith(".test.mjs")); + +if (files.length === 0) { + console.error(`check-test-files: the "${target}" script names no test files`); + process.exit(1); +} + +const missing = files.filter((file) => !existsSync(new URL(`../${file}`, import.meta.url))); + +if (missing.length > 0) { + console.error(`check-test-files: the "${target}" script names files that do not exist:`); + for (const file of missing) console.error(` - ${file}`); + console.error("node --test would skip these and still exit 0."); + process.exit(1); +} + +console.log(`check-test-files: ${files.length} test file(s) present for "${target}"`); diff --git a/test/integration/opencode-contract.test.mjs b/test/integration/opencode-contract.test.mjs new file mode 100644 index 0000000..92c590f --- /dev/null +++ b/test/integration/opencode-contract.test.mjs @@ -0,0 +1,99 @@ +/** + * Compatibility gate: the facts about opencode this plugin's design rests on. + * + * The design was verified against a compiled binary, not a published contract + * (the installed plugin types were 1.18.21 while the binary was 1.18.25), so + * these assertions exist to fail loudly on the opencode upgrade that changes + * something underneath us rather than letting the plugin go quietly inert. + * + * This half checks the binary's own text. The sibling suite boots a real server + * and checks PluginInput. Neither drives a live model request - that needs + * provider credentials - so the wire-level behavior is covered by the unit + * suite instead. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +const BIN = + [process.env.OPENCODE_BIN, join(process.env.HOME ?? "", ".opencode", "bin", "opencode")] + .filter(Boolean) + .find((p) => existsSync(p)) ?? null; +const skip = BIN ? false : "no opencode binary found; set OPENCODE_BIN to run this suite"; + +let cached = null; +function binaryText() { + if (cached === null) { + cached = execFileSync("strings", ["-n", "8", BIN], { maxBuffer: 512 * 1024 * 1024 }).toString(); + } + return cached; +} + +/** + * Each entry is a fact the plugin depends on, the shape it must still have, and + * what breaks if it is gone. Keep the failure messages actionable: whoever hits + * one is mid-upgrade and needs to know what to re-check. + */ +const CONTRACT = [ + { + what: "chat.params is triggered with a pre-populated options object", + pattern: /trigger\("chat\.params",\{sessionID:[^}]*\},\{[^}]*options:/, + breaks: + "The plugin only ever replaces a value core already placed. If options is no longer " + + "seeded before the hook runs, applyCacheKey will report no-fields forever.", + }, + { + what: "the hook input still carries sessionID", + pattern: /trigger\("chat\.params",\{sessionID:/, + breaks: + "Provenance is proved by matching the existing key against sessionID. Without it the " + + "plugin can never confirm a key is core's and will stop replacing anything.", + }, + { + what: "core seeds the camelCase promptCacheKey from the session id", + pattern: /promptCacheKey=\$\.sessionID/, + breaks: "The value the plugin matches on has changed; provenance detection will fail.", + }, + { + what: "core seeds the snake_case prompt_cache_key for some providers", + pattern: /prompt_cache_key=\$\.sessionID/, + breaks: "The deepinfra/cerebras spelling changed; those providers will stop being handled.", + }, + { + what: "setCacheKey remains the provider opt-in core consults", + pattern: /setCacheKey/, + breaks: "The plugin inherits core's opt-in decision; if this is gone, that inheritance is broken.", + }, + { + what: "a plugin export that is not a function is still rejected", + pattern: /Plugin export is not a function/, + breaks: + "The single-export constraint may have relaxed or changed shape. Re-check " + + "test/unit/export-shape.test.mjs against the loader before relying on it.", + }, + { + what: "core still sends its own session identity headers", + pattern: /"x-session-affinity":/, + breaks: + "The 0.2.0 breaking change told users to rely on core's headers instead of the ones this " + + "plugin used to write. If core stopped sending them, that migration advice is now wrong.", + }, +]; + +for (const { what, pattern, breaks } of CONTRACT) { + test(`opencode contract: ${what}`, { skip }, () => { + assert.match(binaryText(), pattern, `\n\nWhat this breaks: ${breaks}\n`); + }); +} + +test("the plugin's own cache key field names match the ones core writes", { skip }, async () => { + const { internals } = (await import("../../plugins/opencode-context-cache.mjs")).default; + const text = binaryText(); + for (const field of internals.CACHE_KEY_FIELDS) { + const spelling = field === "prompt_cache_key" ? /prompt_cache_key=/ : /promptCacheKey=/; + assert.match(text, spelling, `opencode no longer writes ${field}; the plugin would never match it`); + } +}); diff --git a/test/integration/plugin-input-contract.test.mjs b/test/integration/plugin-input-contract.test.mjs new file mode 100644 index 0000000..5bb0bee --- /dev/null +++ b/test/integration/plugin-input-contract.test.mjs @@ -0,0 +1,184 @@ +/** + * Opt-in compatibility gate. + * + * This is a contract probe, not a red-green test: it asserts facts about + * opencode that this plugin's design depends on and that no change of ours can + * affect. The installed plugin types are 1.18.21 while the binary here is + * 1.18.25, so the design was verified against compiled behavior rather than a + * published contract. Run this before upgrading opencode. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:net"; +import { execFileSync, spawn } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { setTimeout as sleep } from "node:timers/promises"; + +import plugin from "../../plugins/opencode-context-cache.mjs"; + +const { resolveCacheKey, getUsername, safeHostname } = plugin.internals; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BIN = + [process.env.OPENCODE_BIN, join(process.env.HOME ?? "", ".opencode", "bin", "opencode")] + .filter(Boolean) + .find((p) => existsSync(p)) ?? null; +const skip = BIN ? false : "no opencode binary found; set OPENCODE_BIN to run this suite"; + +function freePort() { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} + +function makeProject(root, name) { + const dir = join(root, name); + mkdirSync(join(dir, "pkg", "deep"), { recursive: true }); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@e", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@e", + }; + // Isolated from the developer's global config: commit.gpgsign with an + // unreachable key, or a global hooksPath, otherwise fails this suite with an + // environment problem dressed up as an opencode contract break. + const isolated = ["-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null"]; + execFileSync("git", [...isolated, "init", "-q", dir]); + execFileSync("git", [...isolated, "-C", dir, "commit", "-q", "--allow-empty", "-m", "init"], { env: gitEnv }); + cpSync(join(HERE, "probe-plugin.mjs"), join(dir, "probe-plugin.mjs")); + writeFileSync( + join(dir, "opencode.jsonc"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["./probe-plugin.mjs"] }, null, 2), + ); + return dir; +} + +async function stop(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((r) => child.once("exit", r)); + child.kill("SIGTERM"); + const timer = sleep(5000).then(() => "timeout"); + if ((await Promise.race([exited.then(() => "exited"), timer])) === "timeout") { + child.kill("SIGKILL"); + await exited; + } +} + +/** Boot one server, ask it for each directory, and return the probe records. */ +async function probe(directories, cwd) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ctx-cache-it-"))); + const out = join(root, "probe.jsonl"); + const port = await freePort(); + const stderr = []; + const child = spawn(BIN, ["serve", "--port", String(port)], { + cwd, + env: { ...process.env, CONTEXT_CACHE_PROBE_OUT: out }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr.on("data", (b) => stderr.push(String(b))); + let exitedEarly = null; + child.once("exit", (code, signal) => { + exitedEarly = `code=${code} signal=${signal}`; + }); + + try { + const deadline = Date.now() + 30000; + for (;;) { + if (exitedEarly) throw new Error(`opencode exited during startup: ${exitedEarly}\n${stderr.join("")}`); + if (Date.now() > deadline) throw new Error(`opencode did not become ready\n${stderr.join("")}`); + const ok = await fetch(`http://127.0.0.1:${port}/app`) + .then((r) => r.ok) + .catch(() => false); + if (ok) break; + await sleep(250); + } + for (const dir of directories) { + const res = await fetch(`http://127.0.0.1:${port}/config`, { + headers: { "x-opencode-directory": encodeURIComponent(dir) }, + }); + assert.ok(res.ok, `instance request for ${dir} failed with ${res.status}`); + } + await sleep(0); + const raw = existsSync(out) ? readFileSync(out, "utf8").trim() : ""; + return raw ? raw.split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []; + } finally { + await stop(child); + rmSync(root, { recursive: true, force: true }); + } +} + +const keyFor = (record) => + resolveCacheKey({ + env: {}, + worktree: record.worktree, + directory: record.directory, + user: getUsername({ env: process.env }), + host: safeHostname(), + }).value; + +test("one server process gives each project its own PluginInput", { skip }, async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ctx-cache-proj-"))); + try { + const a = makeProject(root, "alpha"); + const b = makeProject(root, "beta"); + // Serve from a directory that is neither project, so any implementation + // reading process.cwd() is demonstrably wrong. + const records = await probe([a, b], root); + + assert.ok( + records.length > 0, + "the probe plugin did not load at all - check the opencode.jsonc plugin key and the " + + "x-opencode-directory header before concluding the plugin lifecycle changed", + ); + const forA = records.filter((r) => r.kind === "factory" && r.worktree === a); + const forB = records.filter((r) => r.kind === "factory" && r.worktree === b); + // At least once, not exactly once: nothing in the design depends on the + // invocation count, only on each project getting its own input. + assert.ok(forA.length >= 1, "expected a plugin invocation for alpha"); + assert.ok(forB.length >= 1, "expected a plugin invocation for beta"); + assert.equal(forA[0].cwd, forB[0].cwd, "both invocations share one process cwd"); + assert.notEqual(forA[0].cwd, forA[0].worktree, "process.cwd() is not the project path"); + + // The contract that matters: our resolver turns these into distinct keys, + // where a cwd-based resolver would produce one. + assert.notEqual(keyFor(forA[0]), keyFor(forB[0])); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("worktree is the VCS root, and a nested session shares the root's key", { skip }, async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ctx-cache-proj-"))); + try { + const project = makeProject(root, "gamma"); + const nested = join(project, "pkg", "deep"); + const records = await probe([project, nested], root); + + assert.ok( + records.length > 0, + "the probe plugin did not load at all - check the opencode.jsonc plugin key and the " + + "x-opencode-directory header before concluding the plugin lifecycle changed", + ); + const atRoot = records.find((r) => r.kind === "factory" && r.directory === project); + const atNested = records.find((r) => r.kind === "factory" && r.directory === nested); + assert.ok(atRoot && atNested, "expected an invocation for both the root and the nested directory"); + assert.equal(atNested.hasWorktree, true, "PluginInput.worktree must exist"); + assert.equal(atNested.worktree, project, "worktree must be the git root, not the cwd"); + assert.equal(atNested.vcs, "git"); + + assert.equal(keyFor(atRoot), keyFor(atNested), "a nested session must reuse the worktree key"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/integration/probe-plugin.mjs b/test/integration/probe-plugin.mjs new file mode 100644 index 0000000..8e39b38 --- /dev/null +++ b/test/integration/probe-plugin.mjs @@ -0,0 +1,36 @@ +import { appendFileSync } from "fs"; + +const OUT = process.env.CONTEXT_CACHE_PROBE_OUT; + +function record(entry) { + if (OUT) appendFileSync(OUT, JSON.stringify(entry) + "\n", "utf8"); +} + +export const ProbePlugin = async (input) => { + record({ + kind: "factory", + directory: input?.directory, + worktree: input?.worktree, + hasWorktree: input ? "worktree" in input : false, + vcs: input?.project?.vcs ?? null, + cwd: process.cwd(), + }); + + return { + // Records the shape opencode hands chat.params. This only fires when a + // model request actually happens, which the suite cannot force without + // credentials, so these records are treated as optional evidence. + "chat.params": async (hookInput, output) => { + const options = output?.options ?? {}; + record({ + kind: "chat.params", + hasSessionID: typeof hookInput?.sessionID === "string", + seededWithSessionID: Object.values(options).includes(hookInput?.sessionID), + optionKeys: Object.keys(options), + providerID: hookInput?.model?.providerID ?? null, + }); + }, + }; +}; + +export default ProbePlugin; diff --git a/test/unit/apply-cache-key.test.mjs b/test/unit/apply-cache-key.test.mjs new file mode 100644 index 0000000..16c0607 --- /dev/null +++ b/test/unit/apply-cache-key.test.mjs @@ -0,0 +1,177 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import plugin from "../../plugins/opencode-context-cache.mjs"; + +const { applyCacheKey, stripSesPrefix } = plugin.internals; + +const SESSION = "ses_" + "a".repeat(64); +const STRIPPED = "a".repeat(64); +const KEY = "stable-key"; + +test("strips the ses_ prefix only from a full lowercase 64-hex session id", () => { + assert.equal(stripSesPrefix(SESSION), STRIPPED); + assert.equal(stripSesPrefix("ses_short"), "ses_short"); + assert.equal(stripSesPrefix("ses_" + "A".repeat(64)), "ses_" + "A".repeat(64)); + assert.equal(stripSesPrefix("plain"), "plain"); +}); + +test("replaces promptCacheKey when it holds core's session id", () => { + const output = { options: { promptCacheKey: SESSION, store: false } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r, { appliedFields: ["promptCacheKey"], foreignFields: [], emptyFields: [], reason: null }); + assert.equal(output.options.promptCacheKey, KEY); + assert.equal(output.options.store, false); +}); + +test("replaces prompt_cache_key for deepinfra and cerebras style providers", () => { + const output = { options: { prompt_cache_key: SESSION } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.appliedFields, ["prompt_cache_key"]); + assert.equal(output.options.prompt_cache_key, KEY); +}); + +test("replaces a value equal to the ses_-stripped session id", () => { + const output = { options: { promptCacheKey: STRIPPED } }; + assert.deepEqual(applyCacheKey(output, KEY, SESSION).appliedFields, ["promptCacheKey"]); + assert.equal(output.options.promptCacheKey, KEY); +}); + +test("replaces both fields when both hold core's default", () => { + const output = { options: { promptCacheKey: SESSION, prompt_cache_key: SESSION } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.appliedFields, ["promptCacheKey", "prompt_cache_key"]); + assert.equal(output.options.promptCacheKey, KEY); + assert.equal(output.options.prompt_cache_key, KEY); +}); + +test("leaves a value this plugin did not set and reports it", () => { + const output = { options: { promptCacheKey: "someone-elses-key" } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r, { appliedFields: [], foreignFields: ["promptCacheKey"], emptyFields: [], reason: null }); + assert.equal(output.options.promptCacheKey, "someone-elses-key"); +}); + +test("reports a foreign snake_case sibling alongside an applied camelCase field", () => { + const output = { options: { promptCacheKey: SESSION, prompt_cache_key: "theirs" } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.appliedFields, ["promptCacheKey"]); + assert.deepEqual(r.foreignFields, ["prompt_cache_key"], "a mixed conflict must not be hidden"); + assert.equal(output.options.promptCacheKey, KEY); + assert.equal(output.options.prompt_cache_key, "theirs"); +}); + +test("reports a present-but-empty field as empty, not as someone else's key", () => { + for (const blank of [undefined, null]) { + const output = { options: { promptCacheKey: blank } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.emptyFields, ["promptCacheKey"], "an unset field was not set by a third party"); + assert.deepEqual(r.foreignFields, [], "calling it foreign sends the operator hunting for a conflict"); + assert.equal(r.reason, null); + assert.equal(output.options.promptCacheKey, blank, "provenance is still unproven, so do not write"); + } +}); + +test("reports no-fields distinctly when core placed nothing", () => { + const output = { options: { store: false } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r, { appliedFields: [], foreignFields: [], emptyFields: [], reason: "no-fields" }); + assert.deepEqual(output.options, { store: false }); +}); + +test("reports invalid-options distinctly, and never throws", () => { + assert.equal(applyCacheKey({}, KEY, SESSION).reason, "invalid-options"); + assert.equal(applyCacheKey(undefined, KEY, SESSION).reason, "invalid-options"); + assert.equal(applyCacheKey({ options: null }, KEY, SESSION).reason, "invalid-options"); + assert.equal(applyCacheKey({ options: "nope" }, KEY, SESSION).reason, "invalid-options"); +}); + +test("reports missing-session distinctly and changes nothing", () => { + const output = { options: { promptCacheKey: SESSION } }; + const r = applyCacheKey(output, KEY, undefined); + assert.equal(r.reason, "missing-session"); + assert.deepEqual(r.appliedFields, []); + assert.equal(output.options.promptCacheKey, SESSION, "provenance is unprovable, so nothing may change"); +}); + +test("replaces options rather than mutating the object it was handed", () => { + const original = { promptCacheKey: SESSION }; + const output = { options: original }; + applyCacheKey(output, KEY, SESSION); + assert.notEqual(output.options, original, "output.options should be a new object"); + assert.equal(original.promptCacheKey, SESSION, "the original object must be untouched"); +}); + +test("a polluted Object.prototype is not mistaken for a field opencode set", () => { + Object.defineProperty(Object.prototype, "promptCacheKey", { + value: SESSION, + configurable: true, + enumerable: false, + writable: true, + }); + try { + const output = { options: { store: false } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.equal(r.reason, "no-fields", "an inherited property is not opencode's own output"); + assert.equal(Object.hasOwn(output.options, "promptCacheKey"), false); + } finally { + delete Object.prototype.promptCacheKey; + } +}); + +test("a third party that sets the key to the session ID is indistinguishable from core", () => { + // Documented limitation, not an oversight: opencode gives the hook no + // provenance token, so value equality is the only available signal. The + // value is unguessable ahead of time and expresses core's own semantics. + // OPENCODE_CONTEXT_CACHE_SCOPE=session is the escape hatch. + const output = { options: { promptCacheKey: SESSION } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.appliedFields, ["promptCacheKey"]); + assert.equal(output.options.promptCacheKey, KEY); +}); + +test("any value that is not the session id or its stripped form is left alone", () => { + for (const theirs of ["ses_" + "a".repeat(63), STRIPPED.toUpperCase(), SESSION + "x", "ses_", ""]) { + const output = { options: { promptCacheKey: theirs } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.appliedFields, [], `must not claim ${JSON.stringify(theirs)} as core's`); + assert.equal(output.options.promptCacheKey, theirs); + } +}); + +test("an empty session id cannot prove provenance", () => { + const output = { options: { promptCacheKey: "" } }; + const r = applyCacheKey(output, KEY, ""); + assert.equal(r.reason, "missing-session", "an empty string must not match an empty field value"); + assert.equal(output.options.promptCacheKey, ""); +}); + +test("a non-string session id cannot prove provenance", () => { + for (const bad of [42, {}, null, Symbol("s")]) { + const output = { options: { promptCacheKey: bad } }; + assert.equal(applyCacheKey(output, KEY, bad).reason, "missing-session"); + assert.equal(output.options.promptCacheKey, bad); + } +}); + +test("a session id with no ses_ prefix still matches exactly", () => { + const plain = "plain-session-id"; + const output = { options: { promptCacheKey: plain } }; + assert.deepEqual(applyCacheKey(output, KEY, plain).appliedFields, ["promptCacheKey"]); + assert.equal(output.options.promptCacheKey, KEY); +}); + +test("snake core with a foreign camel sibling, the mirror of the tested case", () => { + const output = { options: { prompt_cache_key: SESSION, promptCacheKey: "theirs" } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.appliedFields, ["prompt_cache_key"]); + assert.deepEqual(r.foreignFields, ["promptCacheKey"]); +}); + +test("both fields foreign leaves options untouched by identity", () => { + const original = { promptCacheKey: "a", prompt_cache_key: "b" }; + const output = { options: original }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.foreignFields, ["promptCacheKey", "prompt_cache_key"]); + assert.equal(output.options, original, "with nothing applied there is no reason to reassign"); +}); diff --git a/test/unit/cache-key.test.mjs b/test/unit/cache-key.test.mjs new file mode 100644 index 0000000..f490523 --- /dev/null +++ b/test/unit/cache-key.test.mjs @@ -0,0 +1,219 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import plugin from "../../plugins/opencode-context-cache.mjs"; + +// Helpers are not exported: opencode's loader would invoke each one as a +// plugin factory. See the note beside `internals` in the plugin file. +const { + MAX_CACHE_KEY_LENGTH, + PROMPT_CACHE_KEY_ENV_VAR, + SCOPE_ENV_VAR, + STICKY_SESSION_ID_ENV_VAR, + getUsername, + isSafeOverride, + parseScope, + resolveCacheKey, + safeHostname, + selectScopePath, + sha256, + identityWarning, + scopeSetting, +} = plugin.internals; + +const BASE = { user: "andrea", host: "moonveil", worktree: "/srv/repo", directory: "/srv/repo/pkg/a", env: {} }; +const digest = (v) => createHash("sha256").update(v, "utf8").digest("hex"); + +test("sha256 matches node crypto", () => { + assert.equal(sha256("abc"), digest("abc")); +}); + +test("isSafeOverride bounds length and character set", () => { + assert.equal(isSafeOverride("team-key"), true); + assert.equal(isSafeOverride("a".repeat(MAX_CACHE_KEY_LENGTH)), true); + assert.equal(isSafeOverride("a".repeat(MAX_CACHE_KEY_LENGTH + 1)), false); + assert.equal(isSafeOverride("bad\nkey"), false); + assert.equal(isSafeOverride("café"), false); +}); + +test("parseScope accepts the enum and flags anything else", () => { + assert.deepEqual(parseScope("worktree"), { scope: "worktree", unknown: null }); + assert.deepEqual(parseScope("DIRECTORY"), { scope: "directory", unknown: null }); + assert.deepEqual(parseScope(" session "), { scope: "session", unknown: null }); + assert.deepEqual(parseScope(""), { scope: "worktree", unknown: null }); + assert.deepEqual(parseScope(undefined), { scope: "worktree", unknown: null }); + assert.deepEqual(parseScope("sessions"), { scope: "worktree", unknown: "sessions" }); +}); + +test("selectScopePath prefers worktree and guards a degenerate root", () => { + assert.equal(selectScopePath({ scope: "worktree", worktree: "/srv/repo", directory: "/srv/repo/x" }), "/srv/repo"); + assert.equal(selectScopePath({ scope: "worktree", worktree: "", directory: "/srv/repo/x" }), "/srv/repo/x"); + assert.equal(selectScopePath({ scope: "worktree", worktree: " ", directory: "/srv/repo/x" }), "/srv/repo/x"); + assert.equal(selectScopePath({ scope: "worktree", worktree: "/", directory: "/srv/repo/x" }), "/srv/repo/x"); + assert.equal(selectScopePath({ scope: "directory", worktree: "/srv/repo", directory: "/srv/repo/x" }), "/srv/repo/x"); + assert.equal(selectScopePath({ scope: "session", worktree: "/srv/repo", directory: "/srv/repo/x" }), ""); +}); + +test("a path is used verbatim and never trimmed", () => { + const r = resolveCacheKey({ ...BASE, worktree: "/srv/odd " }); + assert.equal(r.raw, "andrea@moonveil:/srv/odd "); +}); + +test("explicit override wins and is used verbatim when safe", () => { + const r = resolveCacheKey({ ...BASE, env: { [PROMPT_CACHE_KEY_ENV_VAR]: " team-key " } }); + assert.equal(r.value, "team-key"); + assert.equal(r.hashed, false); + assert.equal(r.sensitive, true); + assert.equal(r.source, PROMPT_CACHE_KEY_ENV_VAR); + assert.equal(r.deprecated, false); +}); + +test("prompt cache key env beats the deprecated sticky session env", () => { + const r = resolveCacheKey({ + ...BASE, + env: { [PROMPT_CACHE_KEY_ENV_VAR]: "first", [STICKY_SESSION_ID_ENV_VAR]: "second" }, + }); + assert.equal(r.value, "first"); +}); + +test("the sticky session env still works and is flagged deprecated", () => { + const r = resolveCacheKey({ ...BASE, env: { [STICKY_SESSION_ID_ENV_VAR]: "legacy" } }); + assert.equal(r.value, "legacy"); + assert.equal(r.deprecated, true); +}); + +test("an overlong override is hashed rather than sent as-is", () => { + const long = "x".repeat(MAX_CACHE_KEY_LENGTH + 1); + const r = resolveCacheKey({ ...BASE, env: { [PROMPT_CACHE_KEY_ENV_VAR]: long } }); + assert.equal(r.value, digest(long)); + assert.equal(r.hashed, true); + assert.equal(r.value.length, MAX_CACHE_KEY_LENGTH); +}); + +test("a non-printable override is hashed rather than sent as-is", () => { + const bad = "key\nwith\tcontrol"; + const r = resolveCacheKey({ ...BASE, env: { [PROMPT_CACHE_KEY_ENV_VAR]: bad } }); + assert.equal(r.value, digest(bad)); + assert.equal(r.hashed, true); +}); + +test("whitespace-only env values are ignored", () => { + const r = resolveCacheKey({ ...BASE, env: { [PROMPT_CACHE_KEY_ENV_VAR]: " " } }); + assert.equal(r.source, "user@host:worktree"); +}); + +test("generated key is the sha256 of user@host:worktree", () => { + const r = resolveCacheKey(BASE); + assert.equal(r.raw, "andrea@moonveil:/srv/repo"); + assert.equal(r.value, digest("andrea@moonveil:/srv/repo")); + assert.equal(r.hashed, true); + assert.equal(r.sensitive, false); +}); + +test("scope can be narrowed to the directory", () => { + const r = resolveCacheKey({ ...BASE, env: { [SCOPE_ENV_VAR]: "directory" } }); + assert.equal(r.raw, "andrea@moonveil:/srv/repo/pkg/a"); + assert.equal(r.source, "user@host:directory"); +}); + +test("scope session is a hard opt-out that beats an explicit override", () => { + assert.equal(resolveCacheKey({ ...BASE, env: { [SCOPE_ENV_VAR]: "session" } }), null); + assert.equal( + resolveCacheKey({ + ...BASE, + env: { [SCOPE_ENV_VAR]: "session", [PROMPT_CACHE_KEY_ENV_VAR]: "stale-key" }, + }), + null, + "a forgotten override must not defeat the safety valve", + ); +}); + +test("an unrecognised scope falls back to worktree and reports itself", () => { + const r = resolveCacheKey({ ...BASE, env: { [SCOPE_ENV_VAR]: "sessions" } }); + assert.equal(r.unknownScope, "sessions"); + assert.equal(r.source, "user@host:worktree"); +}); + +test("plugin options supply defaults that env overrides", () => { + assert.equal(resolveCacheKey({ ...BASE, options: { scope: "directory" } }).source, "user@host:directory"); + assert.equal(resolveCacheKey({ ...BASE, options: { cacheKey: "from-config" } }).value, "from-config"); + assert.equal( + resolveCacheKey({ ...BASE, options: { cacheKey: "from-config" }, env: { [PROMPT_CACHE_KEY_ENV_VAR]: "from-env" } }).value, + "from-env", + ); +}); + +test("no usable path yields null", () => { + assert.equal(resolveCacheKey({ ...BASE, worktree: "", directory: "" }), null); +}); + +test("key is deterministic and varies with user, host and path", () => { + const a = resolveCacheKey(BASE); + assert.equal(a.value, resolveCacheKey(BASE).value); + assert.notEqual(a.value, resolveCacheKey({ ...BASE, user: "other" }).value); + assert.notEqual(a.value, resolveCacheKey({ ...BASE, host: "other" }).value); + assert.notEqual(a.value, resolveCacheKey({ ...BASE, worktree: "/srv/other" }).value); +}); + +test("getUsername falls back through env when userInfo throws", () => { + const boom = () => { throw new Error("no passwd entry"); }; + assert.equal(getUsername({ env: { USER: "envuser" }, readUserInfo: boom }), "envuser"); + assert.equal(getUsername({ env: { LOGNAME: "logname" }, readUserInfo: boom }), "logname"); + assert.equal(getUsername({ env: {}, readUserInfo: boom }), "unknown"); + assert.equal(getUsername({ env: {}, readUserInfo: () => ({ username: "real" }) }), "real"); +}); + +test("safeHostname falls back when hostname throws or is empty", () => { + assert.equal(safeHostname({ readHostname: () => { throw new Error("nope"); } }), "unknown-host"); + assert.equal(safeHostname({ readHostname: () => "" }), "unknown-host"); + assert.equal(safeHostname({ readHostname: () => "box" }), "box"); +}); + +test("identityWarning fires only when a generated key rests on placeholder identity", () => { + assert.equal(identityWarning({ user: "andrea", host: "moonveil", sensitive: false }), null); + assert.equal( + identityWarning({ user: "unknown", host: "unknown-host", sensitive: true }), + null, + "an explicit override does not depend on local identity", + ); + + const noUser = identityWarning({ user: "unknown", host: "moonveil", sensitive: false }); + assert.match(noUser, /could not determine the local username,/); + assert.match(noUser, /unknown@moonveil:/); + assert.match(noUser, new RegExp(PROMPT_CACHE_KEY_ENV_VAR)); + + assert.match( + identityWarning({ user: "andrea", host: "unknown-host", sensitive: false }), + /could not determine the local hostname,/, + ); + assert.match( + identityWarning({ user: "unknown", host: "unknown-host", sensitive: false }), + /could not determine the local username or hostname,/, + ); +}); + +test("a placeholder identity is reachable from the real fallback paths", () => { + // Ties identityWarning to the functions that actually produce the sentinels, + // so renaming a sentinel in one place breaks this test rather than silently + // disabling the warning. + const boom = () => { throw new Error("no passwd entry"); }; + const user = getUsername({ env: {}, readUserInfo: boom }); + const host = safeHostname({ readHostname: boom }); + assert.notEqual(identityWarning({ user, host, sensitive: false }), null); +}); + +test("scopeSetting is the single source of truth for where scope comes from", () => { + assert.equal(scopeSetting({ [SCOPE_ENV_VAR]: "directory" }, { scope: "session" }), "directory"); + assert.equal(scopeSetting({}, { scope: "session" }), "session"); + assert.equal(scopeSetting({}, {}), ""); + assert.equal(scopeSetting({}, { scope: 42 }), "", "a non-string option is ignored, not coerced"); + assert.equal(scopeSetting(undefined, undefined), ""); +}); + +test("the printable-ASCII bound excludes DEL and everything above it", () => { + assert.equal(isSafeOverride("\x7e"), true, "tilde is the last printable character"); + assert.equal(isSafeOverride("\x7f"), false, "DEL is not printable"); + assert.equal(isSafeOverride("\x1f"), false); + assert.equal(isSafeOverride("\x20"), true, "space is printable"); +}); diff --git a/test/unit/export-shape.test.mjs b/test/unit/export-shape.test.mjs new file mode 100644 index 0000000..fbd65f2 --- /dev/null +++ b/test/unit/export-shape.test.mjs @@ -0,0 +1,80 @@ +/** + * Reproduces opencode's plugin loader check. + * + * A file-path plugin is loaded by walking `Object.values(module)`. Every value + * must be a function, or an object carrying a `server` function; anything else + * makes opencode refuse the whole plugin with "Plugin export is not a + * function". Every distinct function that survives is then *invoked* as a + * plugin factory with `(PluginInput, options)`. + * + * This was found by running the plugin under a real opencode, not by any unit + * test: exporting the helpers for testability silently made the plugin + * unloadable while 57 tests stayed green. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import * as pluginModule from "../../plugins/opencode-context-cache.mjs"; + +/** opencode's `Gy`: normalise one export to a plugin factory, or nothing. */ +function toPluginFactory(value) { + if (typeof value === "function") return value; + if (!value || typeof value !== "object" || !("server" in value)) return undefined; + if (typeof value.server !== "function") return undefined; + return value.server; +} + +/** opencode's `Wy`: every export must normalise, deduplicated by identity. */ +function collectPlugins(mod) { + const seen = new Set(); + const plugins = []; + for (const value of Object.values(mod)) { + if (seen.has(value)) continue; + seen.add(value); + const factory = toPluginFactory(value); + if (!factory) throw new TypeError("Plugin export is not a function"); + plugins.push(factory); + } + return plugins; +} + +test("every export satisfies opencode's plugin loader", () => { + assert.doesNotThrow( + () => collectPlugins(pluginModule), + "a non-function export makes opencode refuse the entire plugin", + ); +}); + +test("the module registers exactly one plugin", () => { + const plugins = collectPlugins(pluginModule); + assert.equal( + plugins.length, + 1, + "every distinct exported function is invoked as a plugin factory, so helpers " + + "must not be exported - hang them off the factory as `internals` instead", + ); +}); + +test("the aliases are the same function object, so the loader deduplicates them", () => { + assert.equal(pluginModule.EnhancedCachePlugin, pluginModule.OpenCodeContextCachePlugin); + assert.equal(pluginModule.default, pluginModule.OpenCodeContextCachePlugin); +}); + +test("the single registered plugin is the factory, and it returns hooks", async () => { + const [factory] = collectPlugins(pluginModule); + assert.equal(factory, pluginModule.default); + const hooks = await factory({ directory: "/srv/repo", worktree: "/srv/repo" }, {}); + assert.equal(typeof hooks, "object"); + assert.equal(typeof hooks["chat.params"], "function"); +}); + +test("internals are reachable for tests without being exported", () => { + const { internals } = pluginModule.default; + assert.equal(typeof internals, "object"); + assert.equal(Object.isFrozen(internals), true); + for (const name of ["resolveCacheKey", "applyCacheKey", "createLogger", "sha256"]) { + assert.equal(typeof internals[name], "function", `internals.${name} should be available to tests`); + } + assert.equal(Object.keys(pluginModule).includes("resolveCacheKey"), false, "helpers must not be exported"); +}); diff --git a/test/unit/logger.test.mjs b/test/unit/logger.test.mjs new file mode 100644 index 0000000..7143df6 --- /dev/null +++ b/test/unit/logger.test.mjs @@ -0,0 +1,188 @@ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import plugin from "../../plugins/opencode-context-cache.mjs"; + +const { DEBUG_ENV_VAR, LOG_PATH_ENV_VAR, WARNING_KEY_LIMIT, createLogger, defaultLogPath, fingerprint, safeHomedir } = + plugin.internals; + +const temps = []; +function tempDir() { + const dir = mkdtempSync(join(tmpdir(), "ctx-cache-")); + temps.push(dir); + return dir; +} +after(() => { + for (const dir of temps) rmSync(dir, { recursive: true, force: true }); +}); + +test("default log path honours an explicit override", () => { + assert.equal(defaultLogPath({ [LOG_PATH_ENV_VAR]: "/custom/x.log" }, "/home/u"), "/custom/x.log"); +}); + +test("default log path honours XDG_STATE_HOME, else falls back under home", () => { + assert.equal(defaultLogPath({ XDG_STATE_HOME: "/xdg" }, "/home/u"), "/xdg/opencode/context-cache.log"); + assert.equal(defaultLogPath({}, "/home/u"), "/home/u/.local/state/opencode/context-cache.log"); +}); + +test("fingerprint is short, stable, and distinguishes inputs", () => { + assert.equal(fingerprint("team-key").length, 8); + assert.equal(fingerprint("team-key"), fingerprint("team-key")); + assert.notEqual(fingerprint("team-key"), fingerprint("other-key")); + assert.equal(fingerprint("team-key").includes("team-key"), false); +}); + +test("debug logging is off unless explicitly enabled", () => { + const lines = []; + const logger = createLogger({ env: {}, filePath: "/unused", write: (_p, l) => lines.push(l) }); + assert.equal(logger.enabled, false); + logger.debug("hello"); + assert.deepEqual(lines, []); +}); + +test("debug logging writes one single-line entry when enabled", () => { + const path = join(tempDir(), "nested", "context-cache.log"); + const logger = createLogger({ env: { [DEBUG_ENV_VAR]: "1" }, filePath: path }); + assert.equal(logger.enabled, true); + logger.debug("hello", "multi\nline"); + const body = readFileSync(path, "utf8"); + assert.equal(body.split("\n").filter(Boolean).length, 1); + assert.match(body, /\[context-cache\] hello multi\\nline/); +}); + +test("an unwritable log warns exactly once and never throws", () => { + const warnings = []; + const logger = createLogger({ + env: { [DEBUG_ENV_VAR]: "true" }, + filePath: join(tempDir(), "x.log"), + write: () => { throw new Error("EACCES"); }, + warn: (m) => warnings.push(m), + }); + logger.debug("one"); + logger.debug("two"); + logger.debug("three"); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /EACCES/); +}); + +test("an unmakeable log directory warns once and never throws", () => { + const dir = tempDir(); + const blocker = join(dir, "blocker"); + writeFileSync(blocker, "not a directory"); + const warnings = []; + const logger = createLogger({ + env: { [DEBUG_ENV_VAR]: "1" }, + filePath: join(blocker, "sub", "x.log"), + warn: (m) => warnings.push(m), + }); + logger.debug("one"); + logger.debug("two"); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /cannot write debug log/); +}); + +test("a throwing warn sink cannot escape", () => { + const logger = createLogger({ + env: { [DEBUG_ENV_VAR]: "1" }, + filePath: "/unused", + write: () => { throw new Error("EACCES"); }, + warn: () => { throw new Error("stderr is gone"); }, + }); + assert.doesNotThrow(() => logger.debug("boom")); + assert.doesNotThrow(() => logger.warnOnce("k", "m")); +}); + +test("a warning that could not be delivered is not marked as spent", () => { + const delivered = []; + let sinkUp = false; + const logger = createLogger({ + env: {}, + filePath: "/unused", + warn: (m) => { + if (!sinkUp) throw new Error("stderr is gone"); + delivered.push(m); + }, + }); + assert.equal(logger.warnOnce("k", "important"), false, "not delivered, so not spent"); + sinkUp = true; + assert.equal(logger.warnOnce("k", "important"), true, "a recovered sink still gets the message"); + assert.deepEqual(delivered, ["[context-cache] important"]); + assert.equal(logger.warnOnce("k", "important"), false, "and only once thereafter"); +}); + +test("warnings are mirrored into the debug log so it is a complete record", () => { + const path = join(tempDir(), "context-cache.log"); + const logger = createLogger({ env: { [DEBUG_ENV_VAR]: "1" }, filePath: path, warn: () => {} }); + logger.warnOnce("k", "something the operator needs"); + assert.match(readFileSync(path, "utf8"), /WARN something the operator needs/); +}); + +test("safeHomedir survives a throwing homedir, and an explicit path never calls it", () => { + assert.equal(safeHomedir({ readHomedir: () => { throw new Error("no passwd entry"); } }), ""); + assert.equal(safeHomedir({ readHomedir: () => "" }), ""); + let called = 0; + const path = defaultLogPath({ [LOG_PATH_ENV_VAR]: "/custom/x.log" }, (() => { called++; return "/h"; })()); + assert.equal(path, "/custom/x.log"); + assert.equal(called, 1, "the caller evaluated its own argument; the point is the default no longer does"); + assert.doesNotThrow(() => defaultLogPath({})); +}); + +test("warnOnce deduplicates by key and ignores the debug flag", () => { + const warnings = []; + const logger = createLogger({ env: {}, filePath: "/unused", warn: (m) => warnings.push(m) }); + assert.equal(logger.enabled, false, "warnings must not require the debug flag"); + // Keys the plugin actually emits, so grepping one from a log finds its source. + assert.equal(logger.warnOnce("foreign:openai:promptCacheKey", "first"), true); + assert.equal(logger.warnOnce("foreign:openai:promptCacheKey", "again"), false); + assert.equal(logger.warnOnce("foreign:anthropic:promptCacheKey", "other"), true); + assert.deepEqual(warnings, ["[context-cache] first", "[context-cache] other"]); +}); + +test("warnOnce still works when detached from the logger object", () => { + const warnings = []; + const path = join(tempDir(), "context-cache.log"); + const { warnOnce } = createLogger({ + env: { [DEBUG_ENV_VAR]: "1" }, + filePath: path, + warn: (m) => warnings.push(m), + }); + assert.doesNotThrow(() => warnOnce("k", "detached call")); + assert.deepEqual(warnings, ["[context-cache] detached call"]); + assert.match(readFileSync(path, "utf8"), /WARN detached call/); +}); + +test("distinct warning keys are capped, so a per-request unique error cannot grow without bound", () => { + const warnings = []; + const path = join(tempDir(), "context-cache.log"); + const logger = createLogger({ + env: { [DEBUG_ENV_VAR]: "1" }, + filePath: path, + warn: (m) => warnings.push(m), + }); + for (let i = 0; i < WARNING_KEY_LIMIT * 3; i++) logger.warnOnce(`error:openai:unique-${i}`, `failure ${i}`); + + assert.equal( + warnings.length, + WARNING_KEY_LIMIT + 1, + "every key up to the ceiling warns, then exactly one overflow notice", + ); + assert.match(warnings.at(-1), /suppressing further ones on stderr/); + + const log = readFileSync(path, "utf8"); + assert.match(log, /WARN \(suppressed\) failure 191/, "detail still reaches the debug log past the ceiling"); + assert.equal((log.match(/WARN \(suppressed\)/g) ?? []).length, WARNING_KEY_LIMIT * 2, "every suppressed warning is still recorded"); +}); + +test("debug stringifies objects, and survives one that cannot be serialised", () => { + const path = join(tempDir(), "context-cache.log"); + const logger = createLogger({ env: { [DEBUG_ENV_VAR]: "1" }, filePath: path }); + const circular = { name: "loop" }; + circular.self = circular; + logger.debug("obj", { a: 1 }, circular); + const body = readFileSync(path, "utf8"); + assert.match(body, /\{"a":1\}/, "plain objects are serialised, not printed as [object Object]"); + assert.match(body, /\[object Object\]/, "a circular object falls back to String() rather than throwing"); +}); diff --git a/test/unit/plugin-hook.test.mjs b/test/unit/plugin-hook.test.mjs new file mode 100644 index 0000000..ecd75a5 --- /dev/null +++ b/test/unit/plugin-hook.test.mjs @@ -0,0 +1,464 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import OpenCodeContextCacheDefault, { + EnhancedCachePlugin, + OpenCodeContextCachePlugin, +} from "../../plugins/opencode-context-cache.mjs"; + +const { + DEBUG_ENV_VAR, + LOG_PATH_ENV_VAR, + PROMPT_CACHE_KEY_ENV_VAR, + SCOPE_ENV_VAR, + STICKY_SESSION_ID_ENV_VAR, + getUsername, + safeHostname, +} = OpenCodeContextCachePlugin.internals; + +const SESSION = "ses_" + "b".repeat(64); +const digest = (v) => createHash("sha256").update(v, "utf8").digest("hex"); + +/** + * Every env var that can steer the plugin, so an ambient value cannot change a + * result - or, in the log path's case, make a test write into an operator's file. + */ +const OWNED = [ + PROMPT_CACHE_KEY_ENV_VAR, + STICKY_SESSION_ID_ENV_VAR, + SCOPE_ENV_VAR, + DEBUG_ENV_VAR, + LOG_PATH_ENV_VAR, + "XDG_STATE_HOME", +]; + +async function withEnv(vars, run) { + const saved = {}; + for (const key of OWNED) { + saved[key] = process.env[key]; + delete process.env[key]; + } + for (const [k, v] of Object.entries(vars)) { + if (!(k in saved)) saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + // Awaited: restoring at the first suspension point would leak env into + // the rest of the suite. + return await run(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +/** Tests assert on collected warnings, so nothing should reach the real stderr. */ +function quiet() { + return { warn: () => {} }; +} + +function hookInput(extra = {}) { + return { + sessionID: SESSION, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5", headers: { "x-existing": "keep" } }, + provider: { info: { id: "openai" } }, + ...extra, + }; +} + +test("exports the factory under all three names", () => { + assert.equal(typeof OpenCodeContextCachePlugin, "function"); + assert.equal(EnhancedCachePlugin, OpenCodeContextCachePlugin); + assert.equal(OpenCodeContextCacheDefault, OpenCodeContextCachePlugin); +}); + +test("the hook applies the exact digest of user@host:worktree", async () => { + await withEnv({}, async () => { + const expected = digest(`${getUsername({ env: process.env })}@${safeHostname()}:/srv/repo`); + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo/pkg/a", worktree: "/srv/repo" }); + const output = { options: { promptCacheKey: SESSION } }; + await hooks["chat.params"](hookInput(), output); + assert.equal(output.options.promptCacheKey, expected); + }); +}); + +test("the hook never writes conversation headers", async () => { + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, quiet()); + const input = hookInput(); + const before = structuredClone(input.model.headers); + await hooks["chat.params"](input, { options: { promptCacheKey: SESSION } }); + assert.deepEqual(input.model.headers, before); + for (const banned of ["x-session-id", "session_id", "conversation_id", "X-Session-Id", "x-session-affinity"]) { + assert.equal(banned in input.model.headers, false, `must not set ${banned}`); + } + }); +}); + +test("the hook tolerates a model with no headers object at all", async () => { + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, quiet()); + const input = hookInput({ model: { providerID: "openai" } }); + await hooks["chat.params"](input, { options: { promptCacheKey: SESSION } }); + assert.equal("headers" in input.model, false, "must not create a headers object"); + }); +}); + +test("the hook leaves a key it did not set", async () => { + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, quiet()); + const output = { options: { promptCacheKey: "operator-choice" } }; + await hooks["chat.params"](hookInput(), output); + assert.equal(output.options.promptCacheKey, "operator-choice"); + }); +}); + +test("the hook adds nothing when core placed no field", async () => { + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, quiet()); + const output = { options: { store: false } }; + await hooks["chat.params"](hookInput(), output); + assert.deepEqual(output.options, { store: false }); + }); +}); + +test("the hook is inert and silent when scope disables the key", async () => { + await withEnv({ [SCOPE_ENV_VAR]: "session" }, async () => { + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + const output = { options: { promptCacheKey: SESSION } }; + for (let i = 0; i < 3; i++) await hooks["chat.params"](hookInput(), output); + assert.equal(output.options.promptCacheKey, SESSION); + // Without the early return the hook dereferences a null resolution, throws + // into its own catch, and turns a deliberate opt-out into a warning storm. + assert.deepEqual(warnings, [], "opting out must not produce per-request warnings"); + }); +}); + +test("the hook changes nothing when the session id is missing", async () => { + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, quiet()); + const output = { options: { promptCacheKey: SESSION } }; + await hooks["chat.params"](hookInput({ sessionID: undefined }), output); + assert.equal(output.options.promptCacheKey, SESSION, "provenance unprovable, so nothing may change"); + }); +}); + +test("the hook does not throw on malformed input or output", async () => { + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, quiet()); + await hooks["chat.params"](hookInput(), {}); + await hooks["chat.params"](hookInput(), { options: null }); + await hooks["chat.params"]({}, { options: { promptCacheKey: SESSION } }); + await hooks["chat.params"](undefined, { options: { promptCacheKey: SESSION } }); + const hostile = { get sessionID() { throw new Error("hostile getter"); } }; + await hooks["chat.params"](hostile, { options: { promptCacheKey: SESSION } }); + }); +}); + +test("two worktrees yield different keys, independent of process.cwd()", async () => { + await withEnv({}, async () => { + const a = await OpenCodeContextCachePlugin({ directory: "/srv/a/sub", worktree: "/srv/a" }, quiet()); + const b = await OpenCodeContextCachePlugin({ directory: "/srv/b/sub", worktree: "/srv/b" }, quiet()); + const outA = { options: { promptCacheKey: SESSION } }; + const outB = { options: { promptCacheKey: SESSION } }; + await a["chat.params"](hookInput(), outA); + await b["chat.params"](hookInput(), outB); + assert.notEqual(outA.options.promptCacheKey, outB.options.promptCacheKey); + assert.notEqual( + outA.options.promptCacheKey, + digest(`${getUsername({ env: process.env })}@${safeHostname()}:${process.cwd()}`), + "the key must not be derived from the process working directory", + ); + }); +}); + +test("a nested directory shares the key of its worktree root", async () => { + await withEnv({}, async () => { + const root = await OpenCodeContextCachePlugin({ directory: "/srv/a", worktree: "/srv/a" }, quiet()); + const nested = await OpenCodeContextCachePlugin({ directory: "/srv/a/pkg/deep", worktree: "/srv/a" }, quiet()); + const outRoot = { options: { promptCacheKey: SESSION } }; + const outNested = { options: { promptCacheKey: SESSION } }; + await root["chat.params"](hookInput(), outRoot); + await nested["chat.params"](hookInput(), outNested); + assert.notEqual(outRoot.options.promptCacheKey, SESSION, "the hook must actually have run"); + assert.equal(outRoot.options.promptCacheKey, outNested.options.promptCacheKey); + }); +}); + +test("a provider with no cache key field stays silent on stderr", async () => { + // The common case, not a fault: Anthropic caches with `cache_control` + // breakpoints and every @ai-sdk/openai-compatible provider - DeepSeek among + // them - has no cache key in its API at all, so core seeds no field and there + // is nothing correct to write. Warning here fires on a routine config and + // teaches operators to tune out the channel that carries the states which do + // mean something. + await withEnv({}, async () => { + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + await hooks["chat.params"](hookInput({ model: { providerID: "deepseek" } }), { options: {} }); + await hooks["chat.params"](hookInput({ model: { providerID: "anthropic" } }), { options: {} }); + assert.deepEqual(warnings, [], "an unsupported provider is expected, not a fault"); + }); +}); + +test("an unsupported provider is still explained in the debug log", async () => { + // Silent on stderr must not mean untraceable: an operator asking why their + // key never lands needs the answer in the one place they are told to look. + const dir = mkdtempSync(join(tmpdir(), "ctx-cache-absent-")); + try { + const logPath = join(dir, "context-cache.log"); + await withEnv({ [DEBUG_ENV_VAR]: "1", [LOG_PATH_ENV_VAR]: logPath }, async () => { + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + quiet(), + ); + await hooks["chat.params"](hookInput({ model: { providerID: "deepseek" } }), { options: {} }); + }); + const log = readFileSync(logPath, "utf8"); + assert.match(log, /deepseek exposes no prompt cache key field/); + assert.match(log, /provider=deepseek applied=\[\] .*reason=no-fields/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a foreign key warns once, and a mixed conflict is not hidden", async () => { + await withEnv({}, async () => { + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + await hooks["chat.params"](hookInput(), { + options: { promptCacheKey: SESSION, prompt_cache_key: "theirs" }, + }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /prompt_cache_key/); + }); +}); + +test("an upstream shape change is loud, not silent", async () => { + // These states cannot occur against a correct opencode. When they do, the + // plugin is permanently inert and prompt caching has silently reverted to a + // per-session key - the exact regression this plugin exists to prevent. An + // earlier revision asserted silence here; that was wrong. + await withEnv({}, async () => { + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + await hooks["chat.params"](hookInput(), { options: null }); + await hooks["chat.params"](hookInput({ sessionID: undefined }), { options: { promptCacheKey: SESSION } }); + assert.equal(warnings.length, 2); + assert.match(warnings[0], /no options object/); + assert.match(warnings[0], /reverted to a per-session key/); + assert.match(warnings[1], /no sessionID/); + assert.match(warnings[1], /renamed/); + }); +}); + +test("a PluginInput with no usable path warns, while scope=session stays quiet", async () => { + await withEnv({}, async () => { + const warnings = []; + await OpenCodeContextCachePlugin({ directory: "", worktree: "" }, { warn: (m) => warnings.push(m) }); + assert.equal(warnings.length, 1, "an opt-out and a derive failure must not look the same"); + assert.match(warnings[0], /could not derive a project path/); + }); + await withEnv({ [SCOPE_ENV_VAR]: "session" }, async () => { + const warnings = []; + await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, { warn: (m) => warnings.push(m) }); + assert.deepEqual(warnings, [], "opting out is intentional and must be silent"); + }); +}); + +test("an empty cache key field gets its own message, not the conflict one", async () => { + await withEnv({}, async () => { + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + await hooks["chat.params"](hookInput(), { options: { promptCacheKey: undefined } }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /left it empty/); + assert.equal(/did not set/.test(warnings[0]), false, "nobody set it, so do not say somebody did"); + }); +}); + +test("a non-string providerID cannot make the hook throw", async () => { + // ToString on a null-prototype object or a Symbol throws, and every use of + // the provider label is a template literal. + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, quiet()); + const hostile = [ + Object.create(null), + Symbol("provider"), + { toString() { throw new Error("boom"); } }, + 42, + null, + ]; + for (const providerID of hostile) { + await hooks["chat.params"](hookInput({ model: { providerID } }), { options: {} }); + await hooks["chat.params"](hookInput({ model: { providerID } }), { options: { promptCacheKey: "theirs" } }); + } + }); +}); + +test("a second, unrelated error on one provider is not suppressed by the first", async () => { + await withEnv({}, async () => { + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + const boom = (message) => ({ + options: { get promptCacheKey() { throw new Error(message); } }, + }); + await hooks["chat.params"](hookInput(), boom("FIRST PROBLEM")); + await hooks["chat.params"](hookInput(), boom("FIRST PROBLEM")); + await hooks["chat.params"](hookInput(), boom("SECOND, DIFFERENT PROBLEM")); + assert.equal(warnings.length, 2, "same error deduped, different error still reported"); + assert.match(warnings[0], /FIRST PROBLEM/); + assert.match(warnings[1], /SECOND, DIFFERENT PROBLEM/); + }); +}); + +test("a startup failure disables the plugin instead of failing the load", async () => { + await withEnv({}, async () => { + const warnings = []; + const hostileOptions = { + get scope() { throw new Error("config blew up"); }, + warn: (m) => warnings.push(m), + }; + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, hostileOptions); + assert.equal(typeof hooks["chat.params"], "function", "must still hand opencode a usable plugin"); + assert.equal(warnings.length, 1, "and say why, through the caller's sink"); + assert.match(warnings[0], /disabled by an unexpected startup error/); + const output = { options: { promptCacheKey: SESSION } }; + await hooks["chat.params"](hookInput(), output); + assert.equal(output.options.promptCacheKey, SESSION, "an inert plugin changes nothing"); + }); +}); + +test("plugin options from opencode.jsonc reach the resolver", async () => { + // Deleting `options` from the factory's resolveCacheKey call left the whole + // suite green, so config-driven cacheKey and scope were dead in practice. + await withEnv({}, async () => { + const fromConfig = await OpenCodeContextCachePlugin( + { directory: "/srv/repo/pkg/a", worktree: "/srv/repo" }, + { cacheKey: "from-config", warn: () => {} }, + ); + const out = { options: { promptCacheKey: SESSION } }; + await fromConfig["chat.params"](hookInput(), out); + assert.equal(out.options.promptCacheKey, "from-config"); + }); + + await withEnv({}, async () => { + const user = getUsername({ env: process.env }); + const host = safeHostname(); + const scoped = await OpenCodeContextCachePlugin( + { directory: "/srv/repo/pkg/a", worktree: "/srv/repo" }, + { scope: "directory", warn: () => {} }, + ); + const out = { options: { promptCacheKey: SESSION } }; + await scoped["chat.params"](hookInput(), out); + assert.equal(out.options.promptCacheKey, digest(`${user}@${host}:/srv/repo/pkg/a`)); + }); +}); + +test("env beats plugin options through the factory", async () => { + await withEnv({ [PROMPT_CACHE_KEY_ENV_VAR]: "from-env" }, async () => { + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { cacheKey: "from-config", warn: () => {} }, + ); + const out = { options: { promptCacheKey: SESSION } }; + await hooks["chat.params"](hookInput(), out); + assert.equal(out.options.promptCacheKey, "from-env"); + }); +}); + +test("the debug log records a fingerprint, never the raw override", async () => { + // The earlier assertion looked only at warnOnce output, which never contains + // the value, so it was structurally incapable of failing. + const dir = mkdtempSync(join(tmpdir(), "ctx-cache-hook-")); + try { + const logPath = join(dir, "context-cache.log"); + const secret = "secret-tenant-key"; + await withEnv( + { [PROMPT_CACHE_KEY_ENV_VAR]: secret, [DEBUG_ENV_VAR]: "1", [LOG_PATH_ENV_VAR]: logPath }, + async () => { + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: () => {} }, + ); + await hooks["chat.params"](hookInput(), { options: { promptCacheKey: SESSION } }); + }, + ); + const log = readFileSync(logPath, "utf8"); + assert.equal(log.includes(secret), false, "the raw override must never be written to the log"); + assert.match(log, /fingerprint=[0-9a-f]{8}/); + assert.match(log, /provider=openai applied=\[promptCacheKey\]/, "the per-request line must be written"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("the debug log records the generated key's pre-image, which is not sensitive", async () => { + const dir = mkdtempSync(join(tmpdir(), "ctx-cache-hook-")); + try { + const logPath = join(dir, "context-cache.log"); + await withEnv({ [DEBUG_ENV_VAR]: "1", [LOG_PATH_ENV_VAR]: logPath }, async () => { + await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, { warn: () => {} }); + }); + assert.match(readFileSync(logPath, "utf8"), /raw=.+@.+:\/srv\/repo/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("the provider label falls back to provider.info.id, then to unknown", async () => { + await withEnv({}, async () => { + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + // Observed through the foreign-key warning: the no-fields path is silent by + // design, so a label bug there would show up in no assertion at all. + const foreign = { options: { promptCacheKey: "set-by-someone-else" } }; + await hooks["chat.params"]({ sessionID: SESSION, provider: { info: { id: "via-info" } } }, foreign); + await hooks["chat.params"]({ sessionID: SESSION }, foreign); + assert.match(warnings[0], /provider via-info/); + assert.match(warnings[1], /provider unknown/); + }); +}); + +test("two different foreign field sets on one provider both warn", async () => { + await withEnv({}, async () => { + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + await hooks["chat.params"](hookInput(), { options: { promptCacheKey: "a" } }); + await hooks["chat.params"](hookInput(), { options: { promptCacheKey: "a", prompt_cache_key: "b" } }); + assert.equal(warnings.length, 2, "the dedup key must include the field set, not just the provider"); + }); +});