From afdd029436c9fcb7847f9f397b36f9591bc339d7 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 18:21:24 +0900 Subject: [PATCH 01/19] docs: spec for cache key rework and header removal Records the verified opencode core behavior this change depends on: the session-ID-derived prompt cache key, the per-provider option name split, and the x-session-affinity WebSocket connection pool that makes sharing conversation identity across sessions unsafe. --- ...2026-08-30-cache-key-and-headers-design.md | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md diff --git a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md new file mode 100644 index 0000000..572cf2b --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md @@ -0,0 +1,329 @@ +# Design: stable prompt cache key, without conversation-identity headers + +Date: 2026-08-30 +Status: approved, pending adversarial review +Branch: `rework-cache-key-and-headers` + +## 1. Problem + +opencode derives the upstream prompt cache key from the opencode session ID. +From the shipped binary (`~/.opencode/bin/opencode`): + +```js +if ($.providerOptions?.setCacheKey !== false) { + if ($.model.api.npm === "@ai-sdk/deepinfra" || $.model.api.npm === "@ai-sdk/cerebras") + Z.prompt_cache_key = $.sessionID; + else if ($.model.api.npm === "@ai-sdk/openai" || "@ai-sdk/azure" || "@ai-sdk/xai" + || "@ai-sdk/mistral" || "venice-ai-sdk-provider" || $.providerOptions?.setCacheKey === true) + Z.promptCacheKey = $.sessionID; +} +``` + +A session ID is new on every session, so every new session starts with a cold +prompt cache even when the prompt prefix (system prompt, AGENTS.md, tool +schemas) is byte-identical to the previous one. Pinning the key to something +stable per project is the correct fix, and is the premise this plugin was +forked for. + +## 2. What the current implementation gets wrong + +### 2.1 It conflates two identities that need opposite lifetimes + +The plugin derives one value and writes it to both the prompt cache key and to +three conversation-identity headers (`x-session-id`, `conversation_id`, +`session_id`). + +Those are not the same kind of identifier: + +- A prompt cache key is a **routing hint**. A stale or over-broad value can + only cause a cache miss. Sharing it widely is safe and is the entire win. +- A session/conversation ID keys **mutable server-side state**. Sharing it + across concurrent sessions is a correctness bug. + +The consuming code in opencode settles it. `x-session-affinity` keys a +WebSocket connection pool: + +```js +let N = A["x-session-affinity"] ?? A["session-id"]; +if (!N) return Z(H, O); +let V = `${N}:conversation`; +let D = Q.get(V) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 }; +if (D.fallback) return Z(H, O); +if (D.busy) return Z(H, O); +D.busy = true; +D.socket = await NA(D, ...); +``` + +Pinning a conversation identity to a per-directory constant would therefore: + +1. Force every concurrent session in one project through a single socket. The + second concurrent request observes `busy` and silently drops to the slower + HTTP fallback path. +2. Let one oversized message in any session set the sticky `fallback` flag for + the whole directory (`MESSAGE_TOO_BIG_CLOSE_CODE` sets `D.fallback = true`), + degrading every other session sharing that key rather than only its own. + +**Decision: the plugin stops writing conversation-identity headers entirely.** +It sets only the prompt cache key. + +### 2.2 The headers it writes collide with core's + +Core assembles outbound headers as: + +```js +headers: { + ...providerID.startsWith("opencode") + ? { "x-opencode-session": e.sessionID, ... } + : { "x-session-affinity": e.sessionID, "X-Session-Id": e.sessionID, "User-Agent": _i }, + ...e.parentSessionID ? { "x-parent-session-id": e.parentSessionID } : {}, + ...e.model.headers, + ...g +} +``` + +Core writes `X-Session-Id`; the plugin writes `x-session-id`. In a JS object +spread these are distinct keys, so both survive into the request and only +collapse at the HTTP layer, yielding either a comma-joined value or +last-write-wins depending on the runtime. Resolved by 2.1 (we write no +headers), and recorded here so the removal is not re-litigated. + +### 2.3 It mutates shared provider state via the wrong hook + +The plugin writes to `input.model.headers` inside `chat.params`. That object is +the model entry from the provider registry, not per-request state. opencode +exposes a dedicated `chat.headers` hook whose output is spread *after* +`model.headers`, so `chat.params` header writes are also lower precedence than +core's own (opencode's built-in OpenAI plugin sets `session-id` from +`chat.headers`, which the plugin cannot override from where it sits). + +Resolved by 2.1. + +### 2.4 The cache key ignores the API and reads `process.cwd()` + +`PluginInput` provides the right values: + +```ts +type PluginInput = { client, project, directory: string, worktree: string, serverUrl, $ } +``` + +`getUserHostDirectoryKey()` calls `process.cwd()` instead. Combined with +module-level singletons constructed outside the plugin factory, any deployment +where one server process serves more than one project collapses every project +onto a single cache identity. + +### 2.5 Two of five documented precedence levels are unreachable + +`getUserHostDirectoryKey()` returns `null` only if `hostname()` or +`process.cwd()` throws. Levels 4 (model headers) and 5 (session ID) are +therefore dead, and `alreadyHashed` is only ever set in level 4, so the +advertised "digest detection to avoid double-hashing" can never fire. + +### 2.6 Overstated claims + +- "Works with ALL providers" - the mechanism is OpenAI-family only. Anthropic + caching uses `cache_control` breakpoints on content blocks and ignores a + cache key entirely. +- "SHA256 hashed cache key for privacy" - the pre-image is + `user@host:/absolute/path`. Given username and hostname, candidate paths are + trivially enumerable. This is obfuscation, not privacy. +- `97.99%` is a single anecdotal run with no stated baseline methodology. + +### 2.7 Minor + +- `ensureLogDirectory()` creates the dirname of a file inside `__dirname`, + which necessarily already exists. It is a no-op. +- The log file is written beside the plugin, so the README's own + `"./plugins/..."` install example writes it into the user's repository. The + repo ships no `.gitignore`. +- No `package.json`, so the plugin cannot be installed by npm identifier, which + is how opencode's `plugin` config array normally references plugins. +- No tests, no CI. + +## 3. Design + +### 3.1 Shape + +The plugin remains a **single self-contained `.mjs` file**. Upstream's install +path is "copy this one file into your plugins directory"; splitting into a +`src/` tree would break it. The file exports its pure functions as named +exports so tests import them directly. + +All state is constructed inside the plugin factory. No module-level mutable +state. + +### 3.2 Key resolution (pure) + +``` +resolveCacheKey({ env, worktree, directory, user, host }) + -> { raw, value, source } | null +``` + +`raw` is the pre-image, used only in debug logs. `value` is what is sent +upstream: equal to `raw` for explicit overrides, and `sha256(raw)` for the +generated key. + +Precedence: + +| # | Source | Hashed? | +|---|--------|---------| +| 1 | `OPENCODE_PROMPT_CACHE_KEY` | no, used verbatim | +| 2 | `OPENCODE_STICKY_SESSION_ID` (compat, logged as deprecated) | no, used verbatim | +| 3 | `user@host:` | sha256 | +| 4 | none available | returns `null`, plugin no-ops | + +Two changes from upstream: + +- **Explicit overrides are never hashed.** The operator chose that string; they + get that string. This deletes the `isSha256Hex` digest-sniffing branch. +- **Level 4 is reachable.** opencode can pass `worktree: ""` (observed in the + binary: `worktree:"",directory:j.directory??""`), so "no key available" is a + real state with a real test, not dead code. + +Scope is the **worktree**, falling back to `directory` when empty. All sessions +inside one checkout share a key, which is where the reuse is: the system +prompt, AGENTS.md/CLAUDE.md and tool schemas are identical across +subdirectories. Separate git worktrees get separate keys, which is correct +since they hold different branches. An over-broad key can only cause a miss, +never a correctness bug, now that no mutable state hangs off it. + +Hashing is retained for the auto-generated key only, on the honest rationale +that it keeps the local username, hostname and home directory layout from +reaching a third-party gateway. + +### 3.3 Applying the key + +``` +applyCacheKey(options, key) -> boolean // mutates `options` in place, returns whether it applied +``` + +```js +if ("promptCacheKey" in options) { options.promptCacheKey = key; applied = true } +if ("prompt_cache_key" in options) { options.prompt_cache_key = key; applied = true } +``` + +**Only replace a field core already placed.** This inherits core's entire +provider table and opt-in logic rather than duplicating a table that will drift +as opencode adds providers: + +- `setCacheKey: false` is respected automatically - core places no field, so we + place none. +- `setCacheKey: true` on an exotic relay makes core place `promptCacheKey`, and + we swap in the stable value. +- deepinfra and cerebras get `prompt_cache_key`, which upstream misses entirely + by hardcoding the camelCase name. + +This depends on core populating `output.options` before triggering the hook, +which is verified: + +```js +plugin.trigger("chat.params", + { sessionID, agent, model, provider, message }, + { temperature, topP, topK, maxOutputTokens, options: d }) +``` + +If that ever changes, the plugin degrades to doing nothing rather than to doing +something wrong. `applyCacheKey` returns whether it applied, and the hook logs +a warning when it did not, so the degradation is visible rather than silent. + +### 3.4 Hook wiring + +```js +export const OpenCodeContextCachePlugin = async ({ directory, worktree }) => { + const logger = createLogger({ env: process.env }); + const resolved = resolveCacheKey({ env: process.env, directory, worktree, + user: getUsername(), host: safeHostname() }); + // ... log resolution outcome once + return { + "chat.params": async (_input, output) => { + if (!resolved) return; + if (!applyCacheKey(output.options, resolved.value)) logger.warn(...); + }, + }; +}; +export const EnhancedCachePlugin = OpenCodeContextCachePlugin; // compat +export default OpenCodeContextCachePlugin; +``` + +The key is resolved once per plugin instance rather than per request: +`directory` and `worktree` are fixed for the life of an instance. + +### 3.5 Logging + +- Default path `${XDG_STATE_HOME:-~/.local/state}/opencode/context-cache.log`, + overridable via `OPENCODE_CONTEXT_CACHE_LOG`. +- Enabled by `OPENCODE_CONTEXT_CACHE_DEBUG` in `{1, true}`. +- `ensureLogDirectory` becomes real (`mkdir -p` on a directory that may not exist). +- On write failure: emit exactly one stderr warning naming the path and the + error, then disable logging. Not silent, and not TUI-spamming. + +### 3.6 Error handling + +| Condition | Behavior | +|---|---| +| `hostname()` throws | fall back to `"unknown-host"`; key still stable per machine-user-path | +| `userInfo()` throws | fall back to `USER`/`USERNAME`/`LOGNAME`, then `"unknown"` | +| `worktree` and `directory` both empty | resolve to `null`; hook no-ops; core's session-ID default stands | +| `output.options` absent or not an object | no-op; log a warning | +| neither cache key field present | no-op; log a warning naming the provider | +| log file unwritable | one stderr warning, then logging disabled | + +The plugin never throws out of the hook. A cache-key optimization must not be +able to fail a user's request. + +## 4. Testing + +`node --test`, zero devDependencies, so CI runs with no install step. + +**`resolveCacheKey`** +- each precedence level selects the expected source +- `OPENCODE_PROMPT_CACHE_KEY` wins over `OPENCODE_STICKY_SESSION_ID` +- explicit overrides are returned verbatim, not hashed +- whitespace-only env values are ignored, not treated as a key +- auto key is sha256 of `user@host:path` +- worktree preferred over directory; directory used when worktree is `""` +- returns `null` when both are `""` +- deterministic across calls; differs across differing user, host, or path + +**`applyCacheKey`** +- replaces `promptCacheKey` when present +- replaces `prompt_cache_key` when present +- replaces both when both present +- adds nothing when neither is present, and returns `false` +- leaves unrelated options untouched + +**Plugin factory (regression tests for the bugs found)** +- two instances built with different worktrees produce different keys + (upstream's `process.cwd()` plus module singletons produce the same key here) +- `input.model.headers` is deeply unchanged after the hook runs +- `output.options` gains no new key when core placed none +- the hook does not throw when `output.options` is missing + +**Logger** +- disabled by default +- writes when enabled +- an unwritable path produces one warning and does not throw + +## 5. Deliverables + +- rewritten `plugins/opencode-context-cache.mjs` +- `test/*.test.mjs` +- `package.json` (`type: module`, `scripts.test`, `files`, exports) +- `.gitignore` (log file, `node_modules`) +- `.github/workflows/test.yml` +- README rewritten: drop "all providers" and "privacy" claims, reframe the + 97.99% figure as one anecdotal run, document the removal of header writing + and why + +## 6. Upstream + +Two pull requests. The fork gets the change directly. Upstream gets a PR whose +body leads with the `x-session-affinity` connection-pool evidence, since it +asks the maintainer to accept the removal of an advertised feature. + +## 7. Explicitly out of scope + +- Anthropic `cache_control` breakpoint injection. That is a different mechanism + with a different hook surface and a different failure mode; folding it in + would destabilize this change. +- Publishing to npm. `package.json` makes it installable; the publish decision + is the maintainer's. From 9c73ec81f4097a600dab003003bda91dc26dc903 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 18:35:31 +0900 Subject: [PATCH 02/19] docs: revise spec after Codex adversarial review Replaces the field-presence check with an exact provenance test against sessionID, bounds explicit overrides, separates operator warnings from the debug log, softens two overclaims, and rewrites the test plan around hook-level and integration coverage. Records what was cleared and what was declined so a later round does not re-derive it. --- ...2026-08-30-cache-key-and-headers-design.md | 349 +++++++++++++++--- 1 file changed, 290 insertions(+), 59 deletions(-) diff --git a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md index 572cf2b..5f83541 100644 --- a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md +++ b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md @@ -1,7 +1,7 @@ # Design: stable prompt cache key, without conversation-identity headers Date: 2026-08-30 -Status: approved, pending adversarial review +Status: approved; revised after Codex adversarial review (see section 8) Branch: `rework-cache-key-and-headers` ## 1. Problem @@ -40,8 +40,8 @@ Those are not the same kind of identifier: - A session/conversation ID keys **mutable server-side state**. Sharing it across concurrent sessions is a correctness bug. -The consuming code in opencode settles it. `x-session-affinity` keys a -WebSocket connection pool: +One demonstrated consumer makes the cost concrete. On opencode's built-in +OpenAI/Codex path, `x-session-affinity` keys a WebSocket connection pool: ```js let N = A["x-session-affinity"] ?? A["session-id"]; @@ -54,7 +54,8 @@ D.busy = true; D.socket = await NA(D, ...); ``` -Pinning a conversation identity to a per-directory constant would therefore: +On that path, pinning a conversation identity to a per-directory constant +would: 1. Force every concurrent session in one project through a single socket. The second concurrent request observes `busy` and silently drops to the slower @@ -63,9 +64,24 @@ Pinning a conversation identity to a per-directory constant would therefore: the whole directory (`MESSAGE_TOO_BIG_CLOSE_CODE` sets `D.fallback = true`), degrading every other session sharing that key rather than only its own. +This pool is **not** universal - it does not establish behavior for Azure, xAI, +Mistral, DeepInfra, Cerebras, or third-party relays. It is an existence proof +that the cost is real, not the whole argument. The general argument is that +these header names mean "this conversation", so a project-stable value is +semantically wrong in them whoever consumes it, and core already sends +`x-session-affinity` and `X-Session-Id` derived from the real session ID, which +makes the plugin's versions redundant where they are understood at all. + **Decision: the plugin stops writing conversation-identity headers entirely.** It sets only the prompt cache key. +**This is a breaking change.** The current README advertises sticky-session +headers for relay/gateway use, including the non-standard `conversation_id` and +`session_id` names. A gateway parsing those underscore names loses them. This +must be called out in the README, the changelog and the upstream PR rather than +shipped quietly; the replacement guidance is that core's own +`x-session-affinity` / `X-Session-Id` already carry per-session identity. + ### 2.2 The headers it writes collide with core's Core assembles outbound headers as: @@ -106,10 +122,26 @@ Resolved by 2.1. type PluginInput = { client, project, directory: string, worktree: string, serverUrl, $ } ``` -`getUserHostDirectoryKey()` calls `process.cwd()` instead. Combined with -module-level singletons constructed outside the plugin factory, any deployment -where one server process serves more than one project collapses every project -onto a single cache identity. +`getUserHostDirectoryKey()` calls `process.cwd()` instead. + +This was verified empirically rather than inferred. A probe plugin recording its +`PluginInput`, loaded into one `opencode serve` process started from +`/home/andrea` and then asked for two separate projects, produced: + +``` +--- invocation 1 --- --- invocation 2 --- + directory: .../probe directory: .../probe2 + worktree: .../probe worktree: .../probe2 + cwd: /home/andrea cwd: /home/andrea +``` + +So the factory is invoked once per project with correct per-project values, +while `process.cwd()` is the server's launch directory for both. Upstream +therefore computes the identical key `andrea@host:/home/andrea` for two +unrelated projects, collapsing them onto one cache identity. The same probe +confirms `worktree` is populated and is the VCS root, and that a session started +in a nested subdirectory reports that subdirectory as `directory` while still +reporting the repo root as `worktree`. ### 2.5 Two of five documented precedence levels are unreachable @@ -173,18 +205,47 @@ Precedence: Two changes from upstream: -- **Explicit overrides are never hashed.** The operator chose that string; they - get that string. This deletes the `isSha256Hex` digest-sniffing branch. +- **Explicit overrides are used verbatim when safe.** The operator chose that + string; they get that string. This deletes the `isSha256Hex` digest-sniffing + branch. "Safe" means at most 64 characters and printable ASCII: OpenAI is + reported to cap `prompt_cache_key` at 64 characters (not verified against a + live API here, so treated as a cheap defensive bound rather than an + established fact), and a sha256 hex digest is exactly 64. An override that + exceeds the bound or carries non-printable characters is hashed instead, and + the substitution is logged, so the plugin can never emit a value the provider + will reject. - **Level 4 is reachable.** opencode can pass `worktree: ""` (observed in the binary: `worktree:"",directory:j.directory??""`), so "no key available" is a real state with a real test, not dead code. -Scope is the **worktree**, falling back to `directory` when empty. All sessions +Scope is the **worktree**, falling back to `directory` when the worktree is +empty or `"/"`. That guard mirrors opencode's own, which picks a project path +with `e.vcs === "git" && e.worktree !== "/" ? e.worktree : e.directory` - a +degenerate `/` worktree would otherwise collapse every project on the machine +onto a single key, which is the exact bug class this change exists to fix. All sessions inside one checkout share a key, which is where the reuse is: the system prompt, AGENTS.md/CLAUDE.md and tool schemas are identical across subdirectories. Separate git worktrees get separate keys, which is correct -since they hold different branches. An over-broad key can only cause a miss, -never a correctness bug, now that no mutable state hangs off it. +since they hold different branches. + +An over-broad key is low-risk but not risk-free, and the earlier draft of this +spec overclaimed by calling it "never a correctness bug". Two qualifications: + +- For OpenAI, `prompt_cache_key` is a routing hint and exact prefix matching + protects correctness, so the failure mode is degraded hit rate rather than + wrong output. But concurrent agents in one worktree can hold unrelated system + prompts and tool sets, and OpenAI's own guidance is to split a busy group when + hit rate degrades. Cache thrash under concurrency is a real cost. +- DeepInfra documents `prompt_cache_key` as an explicit KV-cache lookup key and + suggests a per-session value. That is a stronger contract than "routing hint", + and this design cannot claim universal safety across every backend and relay + implementing the field. + +Mitigation: scope is configurable via `OPENCODE_CONTEXT_CACHE_SCOPE` +(`worktree` | `directory` | `session`), defaulting to `worktree`. Operators +running many concurrent divergent sessions, or a provider with lookup-key +semantics, can narrow it without patching the plugin. `session` resolves to +`null` so core's own per-session default stands untouched. Hashing is retained for the auto-generated key only, on the honest rationale that it keeps the local username, hostname and home directory layout from @@ -193,37 +254,76 @@ reaching a third-party gateway. ### 3.3 Applying the key ``` -applyCacheKey(options, key) -> boolean // mutates `options` in place, returns whether it applied +applyCacheKey(output, key, sessionID) -> "applied" | "absent" | "foreign" +``` + +Replace a cache-key field **only when its current value is provably the one +core just put there**. Core's default is the session ID: + +```js +Z.prompt_cache_key = $.sessionID; // deepinfra, cerebras +Z.promptCacheKey = $.sessionID; // openai, azure, xai, mistral, venice, setCacheKey:true +Z.promptCacheKey = /^ses_[0-9a-f]{64}$/.test(id) ? id.slice(4) : id; // opencode zen path ``` +so the provenance test is exact: + ```js -if ("promptCacheKey" in options) { options.promptCacheKey = key; applied = true } -if ("prompt_cache_key" in options) { options.prompt_cache_key = key; applied = true } +const isCoreDefault = (v) => v === sessionID || v === stripSesPrefix(sessionID); ``` -**Only replace a field core already placed.** This inherits core's entire -provider table and opt-in logic rather than duplicating a table that will drift -as opencode adds providers: +For each of `promptCacheKey` and `prompt_cache_key`: if absent, skip. If present +and `isCoreDefault`, replace. If present and anything else, leave it alone and +report `foreign`. -- `setCacheKey: false` is respected automatically - core places no field, so we - place none. -- `setCacheKey: true` on an exotic relay makes core place `promptCacheKey`, and - we swap in the stable value. -- deepinfra and cerebras get `prompt_cache_key`, which upstream misses entirely - by hardcoding the camelCase name. +An earlier draft used bare presence (`"promptCacheKey" in options`) as the +signal. That is wrong, and the adversarial review was right to reject it: +presence does not prove core set the value. Model, agent or variant options can +carry the field; a plugin ordered before this one can add it; a merge can leave +it present with value `undefined`. Overwriting on presence alone would defeat an +explicit operator setting and make behavior depend on plugin order. + +Matching against the session ID fixes that precisely, and keeps the property +that made the presence check attractive in the first place: no provider table to +duplicate and no drift as opencode adds providers. It inherits core's entire +opt-in decision tree, because we only ever replace core's own output. + +- `setCacheKey: false` -> core writes nothing -> nothing to match -> we skip. +- `setCacheKey: true` on a relay -> core writes the session ID -> we replace it. +- deepinfra/cerebras -> core writes `prompt_cache_key` -> we replace that name, + which upstream misses entirely by hardcoding the camelCase spelling. +- A user or plugin set their own key -> not the session ID -> untouched. + +**Replacement, not in-place mutation.** `output.options` is reassigned to a new +object rather than mutated: + +```js +output.options = { ...options, ...replacements }; +``` + +The review established that core builds a fresh options object per request via a +non-mutating merge, so in-place mutation would be safe today. Replacement is +kept anyway because it is free and stays correct if that ever changes: `_y()` +returns `Object.values(model.variants)[0]` directly on its fallthrough path, and +nothing in the plugin should be one refactor away from writing a cache key into +shared model config. That is the same bug class as upstream's `model.headers` +mutation, and it is not worth being clever about. This depends on core populating `output.options` before triggering the hook, -which is verified: +which is verified - `Plugin.trigger` passes the caller's output object straight +through to every hook and returns it unchanged: ```js -plugin.trigger("chat.params", - { sessionID, agent, model, provider, message }, - { temperature, topP, topK, maxOutputTokens, options: d }) +J = y.fn("Plugin.trigger")(function*(W, K, U) { + if (!W) return U; + for (let z of (yield* c0.get(X)).hooks) { let M = z[W]; if (!M) continue; + yield* y.promise(async () => M(K, U)); } + return U; +}) ``` If that ever changes, the plugin degrades to doing nothing rather than to doing -something wrong. `applyCacheKey` returns whether it applied, and the hook logs -a warning when it did not, so the degradation is visible rather than silent. +something wrong. ### 3.4 Hook wiring @@ -234,9 +334,10 @@ export const OpenCodeContextCachePlugin = async ({ directory, worktree }) => { user: getUsername(), host: safeHostname() }); // ... log resolution outcome once return { - "chat.params": async (_input, output) => { + "chat.params": async (input, output) => { if (!resolved) return; - if (!applyCacheKey(output.options, resolved.value)) logger.warn(...); + const outcome = applyCacheKey(output, resolved.value, input?.sessionID); + report(outcome, input); // debug log always; one deduped operator warning, see 3.5 }, }; }; @@ -254,7 +355,26 @@ The key is resolved once per plugin instance rather than per request: - Enabled by `OPENCODE_CONTEXT_CACHE_DEBUG` in `{1, true}`. - `ensureLogDirectory` becomes real (`mkdir -p` on a directory that may not exist). - On write failure: emit exactly one stderr warning naming the path and the - error, then disable logging. Not silent, and not TUI-spamming. + error, then disable file logging. Not silent, and not TUI-spamming. + +**Operator-visible warnings are a separate channel from the debug log.** The +earlier draft claimed compatibility failures would be "visible rather than +silent" while routing them through the debug-gated logger, which means silent by +default - the review was right to call that a silent failure. A future opencode +field rename could disable the plugin indefinitely with nobody noticing. + +So: a `console.warn` fires independently of `OPENCODE_CONTEXT_CACHE_DEBUG`, +**deduplicated to at most one per (plugin instance, provider, category)**, for: + +- `absent` - a key was resolved but neither cache-key field was present. Names + the provider and states that this is expected for providers that do not use a + prompt cache key, so an Anthropic user sees one informative line, once, and a + field rename is still surfaced. +- `foreign` - a field was present but held a value that was not core's default, + so it was left alone. Names what was found, so an operator can tell a + deliberate override from a conflict. + +Per-request detail stays in the debug log. Nothing warns per request. ### 3.6 Error handling @@ -263,9 +383,13 @@ The key is resolved once per plugin instance rather than per request: | `hostname()` throws | fall back to `"unknown-host"`; key still stable per machine-user-path | | `userInfo()` throws | fall back to `USER`/`USERNAME`/`LOGNAME`, then `"unknown"` | | `worktree` and `directory` both empty | resolve to `null`; hook no-ops; core's session-ID default stands | -| `output.options` absent or not an object | no-op; log a warning | -| neither cache key field present | no-op; log a warning naming the provider | -| log file unwritable | one stderr warning, then logging disabled | +| `output.options` absent or not an object | no-op; debug log | +| neither cache key field present | no-op; one deduped operator warning (`absent`) | +| field present, value is not core's default | leave it; one deduped operator warning (`foreign`) | +| field present with value `undefined` | treated as not core's default -> `foreign`, left alone | +| `input.sessionID` missing | cannot prove provenance; no replacement; debug log | +| explicit override >64 chars or non-printable | hashed instead, substitution logged | +| log file unwritable | one stderr warning, then file logging disabled | The plugin never throws out of the hook. A cache-key optimization must not be able to fail a user's request. @@ -274,45 +398,98 @@ able to fail a user's request. `node --test`, zero devDependencies, so CI runs with no install step. -**`resolveCacheKey`** +The review's sharpest criticism of the first draft was that most listed tests +would pass an implementation whose hook never runs. Unit tests of the pure +helpers are necessary but not sufficient; the suite must drive the real exported +factory and the hook it returns. + +**`resolveCacheKey` (pure)** - each precedence level selects the expected source - `OPENCODE_PROMPT_CACHE_KEY` wins over `OPENCODE_STICKY_SESSION_ID` -- explicit overrides are returned verbatim, not hashed +- safe explicit overrides returned verbatim, not hashed +- an override >64 chars is hashed instead, and reports that it was +- an override with non-printable characters is hashed instead - whitespace-only env values are ignored, not treated as a key - auto key is sha256 of `user@host:path` -- worktree preferred over directory; directory used when worktree is `""` -- returns `null` when both are `""` -- deterministic across calls; differs across differing user, host, or path - -**`applyCacheKey`** -- replaces `promptCacheKey` when present -- replaces `prompt_cache_key` when present -- replaces both when both present -- adds nothing when neither is present, and returns `false` +- worktree preferred; directory used when worktree is `""` or `"/"` + (mirrors core's own `e.vcs === "git" && e.worktree !== "/"` guard, so a + degenerate `/` worktree cannot collapse every project onto one key) +- `OPENCODE_CONTEXT_CACHE_SCOPE` of `directory` forces directory scope; + `session` resolves to `null` +- returns `null` when both paths are empty +- deterministic; differs across differing user, host, or path + +**`applyCacheKey` (pure, provenance)** +- replaces `promptCacheKey` when it equals `sessionID` +- replaces `prompt_cache_key` when it equals `sessionID` +- replaces a value equal to the `ses_`-stripped session ID (zen path) +- returns `foreign` and changes nothing when the value is a third party's key +- returns `foreign` and changes nothing when the value is `undefined` +- returns `absent` and adds nothing when neither field is present - leaves unrelated options untouched +- does not mutate the object it was given (asserts a new object identity) -**Plugin factory (regression tests for the bugs found)** -- two instances built with different worktrees produce different keys - (upstream's `process.cwd()` plus module singletons produce the same key here) +**Hook-level tests, driving the real factory** + +These exist specifically to fail an implementation whose hook never runs or +wires the wrong key. + +- factory returns an object exposing `chat.params` +- invoking that hook on an options object seeded with `sessionID` yields exactly + the key `resolveCacheKey` would have produced for the same `PluginInput` - + binds the hook to the resolver, so a hook that no-ops or passes a wrong value + fails +- invoking it with a foreign value leaves the options untouched - `input.model.headers` is deeply unchanged after the hook runs -- `output.options` gains no new key when core placed none -- the hook does not throw when `output.options` is missing +- the hook does not throw when `output.options` is missing, when `input` is + missing, or when `sessionID` is absent +- two factory instances built with different worktrees produce different keys, + and neither depends on `process.cwd()` (asserted by running the factory from a + third, unrelated cwd - upstream returns the same key for both here) + +**Warning channel** +- `absent` and `foreign` each warn once and then stay quiet across repeated + hook invocations for the same provider +- warnings fire with `OPENCODE_CONTEXT_CACHE_DEBUG` unset +- the raw value of an explicit override never appears in any log line; only its + source and a short fingerprint do **Logger** -- disabled by default -- writes when enabled -- an unwritable path produces one warning and does not throw +- disabled by default; writes when enabled +- an unwritable path produces exactly one warning and does not throw + +**Integration, opt-in (`test/integration/`)** + +Skipped automatically when no opencode binary is present, so CI stays green; +run locally and before an opencode upgrade as a compatibility gate. This is the +review's requested lifecycle test, and the harness is already proven: a probe +plugin recording its `PluginInput` under `opencode serve`. + +- one server process, started from an unrelated cwd, serving two projects: + asserts the factory is invoked once per project with that project's own + `directory`/`worktree`, and that the resulting keys differ +- a session started in a nested subdirectory of a repo produces the same key as + one started at the repo root +- asserts `PluginInput.worktree` is still populated and is the VCS root, which + is the contract the whole design rests on and the thing most likely to break + across an opencode upgrade + +Note the version skew this guards against: the installed plugin types are +1.18.21 while the binary is 1.18.25, so the compiled behavior this design was +verified against is not fully described by the shipped type definitions. ## 5. Deliverables - rewritten `plugins/opencode-context-cache.mjs` -- `test/*.test.mjs` +- `test/*.test.mjs` and `test/integration/*.test.mjs` (the latter self-skipping + when no opencode binary is present) - `package.json` (`type: module`, `scripts.test`, `files`, exports) - `.gitignore` (log file, `node_modules`) - `.github/workflows/test.yml` - README rewritten: drop "all providers" and "privacy" claims, reframe the - 97.99% figure as one anecdotal run, document the removal of header writing - and why + 97.99% figure as one anecdotal run, document the removal of header writing as + a breaking change with migration guidance, and document + `OPENCODE_CONTEXT_CACHE_SCOPE` and `OPENCODE_CONTEXT_CACHE_LOG` ## 6. Upstream @@ -327,3 +504,57 @@ asks the maintainer to accept the removal of an advertised feature. would destabilize this change. - Publishing to npm. `package.json` makes it installable; the publish decision is the maintainer's. + +## 8. Adversarial review record + +Codex reviewed this spec (job `task-mtflow5i-9oguil`, effort high) against a +seven-point attack list. Recorded here so a later round does not re-derive it. + +**Cleared.** + +- *Stale key lifetime.* Feared that resolving once per factory invocation goes + stale if one plugin instance serves several projects or a worktree is + retargeted. Codex found plugin state is created via `InstanceState.make`, keyed + by resolved directory; `/experimental/worktree` creates a new directory-backed + instance and `/experimental/worktree/reset` resets git state within the same + directory without retargeting. Independently confirmed by the probe in 2.4. + Factory-time resolution stands. The residual risk - reliance on compiled + behavior rather than a documented contract, with types at 1.18.21 and the + binary at 1.18.25 - is addressed by the opt-in integration test in section 4. +- *Shared options object.* Feared in-place mutation could leak into shared model + config. Codex established core builds a fresh options object per request via a + non-mutating merge. Replacement is retained anyway as a free hedge (3.3). +- *JSON injection via explicit overrides.* Not a risk; serialization escapes. + +**Accepted and folded in.** + +- *Presence does not prove provenance* - the strongest finding. Rewrote 3.3 to + match against `sessionID` instead of testing field presence. Codex proposed a + maintained provider table or a presence heuristic; the session-ID match is + better than both, and the finding is what made it visible. +- *Over-broad key claim overstated* - softened in 3.2, with the DeepInfra + lookup-key semantics and OpenAI cache-thrash concerns recorded, and + `OPENCODE_CONTEXT_CACHE_SCOPE` added so scope can be narrowed without a patch. +- *Header-removal evidence too narrow* - the WebSocket pool is opencode's + OpenAI/Codex path, not universal. 2.1 now presents it as an existence proof + and rests the argument on semantic mismatch plus redundancy with core's own + headers, and labels the removal a breaking change with migration guidance. +- *Unbounded verbatim overrides* - 64-character and printable-ASCII bound added, + falling back to hashing (3.2). +- *Raw override in debug logs* - only source plus a short fingerprint is logged + for explicit overrides (3.5). +- *"Visible rather than silent" routed through a debug-gated logger* - a + separate, deduplicated, always-on operator warning channel added (3.5). +- *Tests would pass a hook that never runs* - section 4 rewritten around + hook-level and integration tests. + +**Considered and not taken.** + +- *Populate conversation headers from per-request `sessionID` instead of + removing them.* Declined: that option was explicitly weighed and rejected + before this spec was written, and core already emits `x-session-affinity` and + `X-Session-Id` from the session ID, so the plugin's versions would duplicate + core for every consumer that understands them. +- *Provider-specific cache scope with a prompt-version component.* Declined as + over-engineering for this plugin's purpose, and it reintroduces the provider + table that 3.3 exists to avoid. The scope env var covers the real need. From b560da9af99188a4c0fccb64e6dfe8bf902c8f3a Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 18:39:47 +0900 Subject: [PATCH 03/19] docs: implementation plan for the cache key rework Six TDD tasks: resolution, provenance, logging, factory, an opt-in opencode compatibility gate, and CI plus README. --- .../plans/2026-08-30-cache-key-and-headers.md | 1151 +++++++++++++++++ 1 file changed, 1151 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-cache-key-and-headers.md diff --git a/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md b/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md new file mode 100644 index 0000000..c050446 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md @@ -0,0 +1,1151 @@ +# OpenCode Context Cache Rework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the plugin's session-header writing and `process.cwd()`-derived cache key with a single, provenance-checked prompt cache key scoped to the git worktree. + +**Architecture:** One self-contained ESM file exporting pure helpers plus a plugin factory. `resolveCacheKey` computes the key once per factory invocation from `PluginInput`; `applyCacheKey` replaces a cache-key field in `output.options` only when its current value is provably opencode's own session-ID default. No conversation headers are written. No module-level mutable state. + +**Tech Stack:** Node ESM (`.mjs`), `node:test`, `node:crypto`, zero runtime and zero dev dependencies. + +**Spec:** `docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md` + +## Global Constraints + +- Single shipped file: `plugins/opencode-context-cache.mjs`. Do not split into `src/`; upstream's install path is copying that one file. +- Zero dependencies, runtime and dev. Tests run on `node --test` with no install step. +- Node `>=20`. +- The plugin must never throw out of the `chat.params` hook. +- Never write `x-session-id`, `conversation_id`, `session_id`, `X-Session-Id`, or `x-session-affinity`. Never touch `input.model.headers`. +- Never log the raw value of an operator-supplied override; log its source and an 8-character fingerprint only. +- `MAX_CACHE_KEY_LENGTH = 64`. Printable ASCII is `/^[\x20-\x7E]+$/`. +- Keep the `EnhancedCachePlugin` named export and the default export as aliases. +- Env var names, exact: `OPENCODE_PROMPT_CACHE_KEY`, `OPENCODE_STICKY_SESSION_ID`, `OPENCODE_CONTEXT_CACHE_SCOPE`, `OPENCODE_CONTEXT_CACHE_DEBUG`, `OPENCODE_CONTEXT_CACHE_LOG`. +- Commit messages: no `Co-Authored-By` agent attribution. Use a plain dash, never an em dash, in all prose and code comments. + +## File Structure + +| File | Responsibility | +|---|---| +| `plugins/opencode-context-cache.mjs` | Everything shipped: pure helpers + factory. Rewritten. | +| `package.json` | npm-installable identity, `test` scripts. New. | +| `.gitignore` | log file, `node_modules`. New. | +| `test/cache-key.test.mjs` | `resolveCacheKey`, `selectScopePath`, override bounds. | +| `test/apply-cache-key.test.mjs` | `applyCacheKey` provenance and immutability. | +| `test/logger.test.mjs` | log path, write failure, `warnOnce` dedup. | +| `test/plugin-hook.test.mjs` | the real factory and the hook it returns. | +| `test/integration/lifecycle.test.mjs` | opt-in, runs a real `opencode serve`. | +| `test/integration/probe-plugin.mjs` | fixture plugin that records its `PluginInput`. | +| `.github/workflows/test.yml` | CI: `npm test` on Node 20 and 22. | +| `README.md` | Rewritten, honest claims, breaking-change notice. | + +--- + +### Task 1: Scaffolding and cache key resolution + +**Files:** +- Create: `package.json` +- Create: `.gitignore` +- Create: `plugins/opencode-context-cache.mjs` (replacing the existing file wholesale) +- Test: `test/cache-key.test.mjs` + +**Interfaces:** +- Consumes: nothing. +- Produces: `sha256(value) -> string`, `isSafeOverride(value) -> boolean`, `selectScopePath({scope, worktree, directory}) -> string`, `resolveCacheKey({env, worktree, directory, user, host}) -> {raw, value, source, hashed, sensitive} | null`, and the constants `PROMPT_CACHE_KEY_ENV_VAR`, `STICKY_SESSION_ID_ENV_VAR`, `SCOPE_ENV_VAR`, `DEBUG_ENV_VAR`, `LOG_PATH_ENV_VAR`, `MAX_CACHE_KEY_LENGTH`. + +- [ ] **Step 1: Create `package.json`** + +```json +{ + "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", + "LICENSE" + ], + "scripts": { + "test": "node --test test/*.test.mjs", + "test:integration": "node --test test/integration/*.test.mjs" + }, + "keywords": ["opencode", "opencode-plugin", "prompt-cache"], + "license": "MIT", + "engines": { + "node": ">=20" + } +} +``` + +- [ ] **Step 2: Create `.gitignore`** + +```gitignore +node_modules/ +context-cache.log +*.log +``` + +- [ ] **Step 3: Write the failing test** + +Create `test/cache-key.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import { + MAX_CACHE_KEY_LENGTH, + PROMPT_CACHE_KEY_ENV_VAR, + SCOPE_ENV_VAR, + STICKY_SESSION_ID_ENV_VAR, + isSafeOverride, + resolveCacheKey, + selectScopePath, + sha256, +} from "../plugins/opencode-context-cache.mjs"; + +const BASE = { user: "andrea", host: "moonveil", worktree: "/srv/repo", directory: "/srv/repo/pkg/a" }; +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("selectScopePath prefers worktree, guards against 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: "directory", worktree: "/srv/repo", directory: "/srv/repo/x" }), "/srv/repo/x"); + assert.equal(selectScopePath({ scope: "session", worktree: "/srv/repo", directory: "/srv/repo/x" }), ""); +}); + +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.raw, "team-key"); + assert.equal(r.hashed, false); + assert.equal(r.sensitive, true); + assert.equal(r.source, PROMPT_CACHE_KEY_ENV_VAR); +}); + +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("deprecated sticky session env is still honoured", () => { + const r = resolveCacheKey({ ...BASE, env: { [STICKY_SESSION_ID_ENV_VAR]: "legacy" } }); + assert.equal(r.value, "legacy"); + assert.equal(r.source, STICKY_SESSION_ID_ENV_VAR); +}); + +test("an unsafe 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, 64); +}); + +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, env: {} }); + 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 env can narrow 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 yields no key so core's default stands", () => { + assert.equal(resolveCacheKey({ ...BASE, env: { [SCOPE_ENV_VAR]: "session" } }), null); +}); + +test("an explicit override still wins over scope session", () => { + const r = resolveCacheKey({ + ...BASE, + env: { [SCOPE_ENV_VAR]: "session", [PROMPT_CACHE_KEY_ENV_VAR]: "team-key" }, + }); + assert.equal(r.value, "team-key"); +}); + +test("no usable path yields null", () => { + assert.equal(resolveCacheKey({ user: "a", host: "b", worktree: "", directory: "", env: {} }), null); +}); + +test("key is deterministic and varies with user, host and path", () => { + const a = resolveCacheKey({ ...BASE, env: {} }); + assert.equal(a.value, resolveCacheKey({ ...BASE, env: {} }).value); + assert.notEqual(a.value, resolveCacheKey({ ...BASE, user: "other", env: {} }).value); + assert.notEqual(a.value, resolveCacheKey({ ...BASE, host: "other", env: {} }).value); + assert.notEqual(a.value, resolveCacheKey({ ...BASE, worktree: "/srv/other", env: {} }).value); +}); +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `npm test` +Expected: FAIL. The existing `plugins/opencode-context-cache.mjs` exports none of these names, so the import throws `SyntaxError: The requested module ... does not provide an export named 'resolveCacheKey'`. + +- [ ] **Step 5: Replace `plugins/opencode-context-cache.mjs` with the resolution layer** + +Delete the entire existing contents. The `DebugLogger`, `CacheKeyResolver`, `CacheKeyApplier` and `ContextCachePluginRuntime` classes and the module-level singletons all go. Write: + +```js +/** + * opencode plugin: OpenCode Context Cache + * + * 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. + * + * It sets exactly one thing: the prompt cache key field that 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, homedir, userInfo } from "os"; +import { dirname, join } from "path"; +import { appendFileSync, mkdirSync } from "fs"; +import { createHash } from "crypto"; + +export const PROMPT_CACHE_KEY_ENV_VAR = "OPENCODE_PROMPT_CACHE_KEY"; +export const STICKY_SESSION_ID_ENV_VAR = "OPENCODE_STICKY_SESSION_ID"; +export const SCOPE_ENV_VAR = "OPENCODE_CONTEXT_CACHE_SCOPE"; +export const DEBUG_ENV_VAR = "OPENCODE_CONTEXT_CACHE_DEBUG"; +export const LOG_PATH_ENV_VAR = "OPENCODE_CONTEXT_CACHE_LOG"; + +/** OpenAI is reported to cap prompt_cache_key at 64 characters; a sha256 hex digest is exactly 64. */ +export const MAX_CACHE_KEY_LENGTH = 64; + +const PRINTABLE_ASCII = /^[\x20-\x7E]+$/; + +export function sha256(value) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function trimmedEnv(env, name) { + const value = env?.[name]; + return typeof value === "string" ? value.trim() : ""; +} + +export function isSafeOverride(value) { + return value.length <= MAX_CACHE_KEY_LENGTH && PRINTABLE_ASCII.test(value); +} + +/** + * Mirrors core's own project-path guard: + * vcs === "git" && worktree !== "/" ? worktree : directory + * A degenerate "/" worktree would otherwise collapse every project on the + * machine onto a single key. + */ +export function selectScopePath({ scope, worktree, directory }) { + const tree = typeof worktree === "string" ? worktree.trim() : ""; + const dir = typeof directory === "string" ? directory.trim() : ""; + if (scope === "session") return ""; + if (scope === "directory") return dir; + if (tree && tree !== "/") return tree; + return dir; +} + +export function resolveCacheKey({ env = {}, worktree, directory, user, host } = {}) { + for (const name of [PROMPT_CACHE_KEY_ENV_VAR, STICKY_SESSION_ID_ENV_VAR]) { + const raw = trimmedEnv(env, name); + if (!raw) continue; + const safe = isSafeOverride(raw); + return { raw, value: safe ? raw : sha256(raw), source: name, hashed: !safe, sensitive: true }; + } + + const scope = trimmedEnv(env, SCOPE_ENV_VAR).toLowerCase() || "worktree"; + const path = selectScopePath({ scope, worktree, directory }); + if (!path) return null; + + const raw = `${user}@${host}:${path}`; + return { raw, value: sha256(raw), source: `user@host:${scope}`, hashed: true, sensitive: false }; +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `npm test` +Expected: PASS, 14 tests. + +- [ ] **Step 7: Commit** + +```bash +git add package.json .gitignore plugins/opencode-context-cache.mjs test/cache-key.test.mjs +git commit -m "feat: resolve a worktree-scoped prompt cache key + +Replaces the process.cwd() key with one derived from PluginInput, bounds +explicit overrides to what a provider will accept, and adds a scope knob." +``` + +--- + +### Task 2: Provenance-checked application + +**Files:** +- Modify: `plugins/opencode-context-cache.mjs` (append) +- Test: `test/apply-cache-key.test.mjs` + +**Interfaces:** +- Consumes: nothing from Task 1 at runtime; shares the file. +- Produces: `CACHE_KEY_FIELDS: string[]`, `stripSesPrefix(sessionID) -> string`, `applyCacheKey(output, value, sessionID) -> "applied" | "absent" | "foreign"`. + +- [ ] **Step 1: Write the failing test** + +Create `test/apply-cache-key.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { applyCacheKey, stripSesPrefix } from "../plugins/opencode-context-cache.mjs"; + +const SESSION = "ses_" + "a".repeat(64); +const KEY = "stable-key"; + +test("strips the ses_ prefix only from a full 64-hex session id", () => { + assert.equal(stripSesPrefix(SESSION), "a".repeat(64)); + assert.equal(stripSesPrefix("ses_short"), "ses_short"); + assert.equal(stripSesPrefix("plain"), "plain"); +}); + +test("replaces promptCacheKey when it holds core's session id", () => { + const output = { options: { promptCacheKey: SESSION, store: false } }; + assert.equal(applyCacheKey(output, KEY, SESSION), "applied"); + 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 } }; + assert.equal(applyCacheKey(output, KEY, SESSION), "applied"); + assert.equal(output.options.prompt_cache_key, KEY); +}); + +test("replaces a value equal to the ses_-stripped session id", () => { + const output = { options: { promptCacheKey: "a".repeat(64) } }; + assert.equal(applyCacheKey(output, KEY, SESSION), "applied"); + assert.equal(output.options.promptCacheKey, KEY); +}); + +test("leaves a value this plugin did not set", () => { + const output = { options: { promptCacheKey: "someone-elses-key" } }; + assert.equal(applyCacheKey(output, KEY, SESSION), "foreign"); + assert.equal(output.options.promptCacheKey, "someone-elses-key"); +}); + +test("treats a present-but-undefined field as foreign, not as core's", () => { + const output = { options: { promptCacheKey: undefined } }; + assert.equal(applyCacheKey(output, KEY, SESSION), "foreign"); + assert.equal(output.options.promptCacheKey, undefined); +}); + +test("adds nothing when no cache key field is present", () => { + const output = { options: { store: false } }; + assert.equal(applyCacheKey(output, KEY, SESSION), "absent"); + assert.deepEqual(output.options, { store: false }); +}); + +test("does not throw and reports absent when options are missing", () => { + assert.equal(applyCacheKey({}, KEY, SESSION), "absent"); + assert.equal(applyCacheKey(undefined, KEY, SESSION), "absent"); + assert.equal(applyCacheKey({ options: null }, KEY, SESSION), "absent"); +}); + +test("cannot prove provenance without a session id", () => { + const output = { options: { promptCacheKey: SESSION } }; + assert.equal(applyCacheKey(output, KEY, undefined), "absent"); + assert.equal(output.options.promptCacheKey, SESSION); +}); + +test("replaces the core-owned field and leaves a foreign sibling alone", () => { + const output = { options: { promptCacheKey: SESSION, prompt_cache_key: "theirs" } }; + assert.equal(applyCacheKey(output, KEY, SESSION), "applied"); + assert.equal(output.options.promptCacheKey, KEY); + assert.equal(output.options.prompt_cache_key, "theirs"); +}); + +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"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test` +Expected: FAIL with `does not provide an export named 'applyCacheKey'`. + +- [ ] **Step 3: Append the application layer to the plugin file** + +```js +/** The two spellings opencode core uses, depending on provider. */ +export const CACHE_KEY_FIELDS = ["promptCacheKey", "prompt_cache_key"]; + +const SES_PREFIXED = /^ses_[0-9a-f]{64}$/; + +/** Core sends the digest without the ses_ prefix on its own zen provider path. */ +export function stripSesPrefix(sessionID) { + return SES_PREFIXED.test(sessionID) ? sessionID.slice(4) : sessionID; +} + +/** + * 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. + */ +export function applyCacheKey(output, value, sessionID) { + const options = output?.options; + if (!options || typeof options !== "object") return "absent"; + if (typeof sessionID !== "string" || sessionID === "") return "absent"; + + const stripped = stripSesPrefix(sessionID); + const replacements = {}; + let sawForeign = false; + + for (const field of CACHE_KEY_FIELDS) { + if (!(field in options)) continue; + const current = options[field]; + if (current === sessionID || current === stripped) replacements[field] = value; + else sawForeign = true; + } + + if (Object.keys(replacements).length === 0) return sawForeign ? "foreign" : "absent"; + + output.options = { ...options, ...replacements }; + return "applied"; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test` +Expected: PASS, 25 tests total. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/opencode-context-cache.mjs test/apply-cache-key.test.mjs +git commit -m "feat: replace the cache key only when it is core's own default + +Field presence does not prove provenance; matching opencode's session ID +does, and it leaves deliberate operator and plugin settings untouched." +``` + +--- + +### Task 3: Logging and the operator warning channel + +**Files:** +- Modify: `plugins/opencode-context-cache.mjs` (append) +- Test: `test/logger.test.mjs` + +**Interfaces:** +- Consumes: `sha256` from Task 1. +- Produces: `defaultLogPath(env, home) -> string`, `fingerprint(value) -> string`, `createLogger({env, filePath, write, warn}) -> {enabled, path, debug(...args), warnOnce(key, message) -> boolean}`. + +- [ ] **Step 1: Write the failing test** + +Create `test/logger.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { + DEBUG_ENV_VAR, + LOG_PATH_ENV_VAR, + createLogger, + defaultLogPath, + fingerprint, +} from "../plugins/opencode-context-cache.mjs"; + +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 a short, stable, non-reversible tag", () => { + assert.equal(fingerprint("team-key").length, 8); + assert.equal(fingerprint("team-key"), fingerprint("team-key")); + assert.notEqual(fingerprint("team-key"), fingerprint("other-key")); +}); + +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 dir = mkdtempSync(join(tmpdir(), "ctx-cache-")); + const path = join(dir, "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: "/unused", + 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("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.warnOnce("absent:openai", "first"), true); + assert.equal(logger.warnOnce("absent:openai", "again"), false); + assert.equal(logger.warnOnce("absent:anthropic", "other"), true); + assert.deepEqual(warnings, ["[context-cache] first", "[context-cache] other"]); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test` +Expected: FAIL with `does not provide an export named 'createLogger'`. + +- [ ] **Step 3: Append the logging layer** + +```js +export function fingerprint(value) { + return sha256(value).slice(0, 8); +} + +export function defaultLogPath(env = {}, home = homedir()) { + const explicit = trimmedEnv(env, LOG_PATH_ENV_VAR); + if (explicit) return explicit; + const stateHome = trimmedEnv(env, "XDG_STATE_HOME") || join(home, ".local", "state"); + return join(stateHome, "opencode", "context-cache.log"); +} + +/** + * Two channels. `debug` is opt-in and file-backed. `warnOnce` is always on and + * deduplicated: a compatibility failure must be visible without the operator + * having first guessed to turn debug logging on. + */ +export 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 fileUsable = true; + + function emit(message) { + warn(`[context-cache] ${message}`); + } + + return { + 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"); + const line = `[${new Date().toISOString()}] [pid:${process.pid}] [context-cache] ${body}\n`; + try { + mkdirSync(dirname(path), { recursive: true }); + write(path, line, "utf8"); + } catch (error) { + fileUsable = false; + emit(`cannot write debug log at ${path}: ${error?.message ?? error}; debug logging disabled`); + } + }, + + warnOnce(key, message) { + if (warned.has(key)) return false; + warned.add(key); + emit(message); + return true; + }, + }; +} + +function safeJson(value) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test` +Expected: PASS, 32 tests total. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/opencode-context-cache.mjs test/logger.test.mjs +git commit -m "feat: split operator warnings from the opt-in debug log + +Compatibility failures now surface without the debug flag, deduplicated, +and the log moves out of the plugin directory into the XDG state dir." +``` + +--- + +### Task 4: Plugin factory and hook wiring + +**Files:** +- Modify: `plugins/opencode-context-cache.mjs` (append) +- Test: `test/plugin-hook.test.mjs` + +**Interfaces:** +- Consumes: `resolveCacheKey`, `applyCacheKey`, `createLogger`, `fingerprint` from Tasks 1-3. +- Produces: `getUsername(env) -> string`, `safeHostname() -> string`, `OpenCodeContextCachePlugin(pluginInput, options?) -> Promise<{"chat.params": fn}>`, plus `EnhancedCachePlugin` and default aliases. + +- [ ] **Step 1: Write the failing test** + +Create `test/plugin-hook.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import OpenCodeContextCacheDefault, { + EnhancedCachePlugin, + OpenCodeContextCachePlugin, + PROMPT_CACHE_KEY_ENV_VAR, + SCOPE_ENV_VAR, + resolveCacheKey, + getUsername, + safeHostname, +} from "../plugins/opencode-context-cache.mjs"; + +const SESSION = "ses_" + "b".repeat(64); + +function hookInput(extra = {}) { + return { + sessionID: SESSION, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5", headers: { "x-existing": "keep" } }, + provider: { info: { id: "openai" } }, + ...extra, + }; +} + +function withEnv(vars, run) { + const saved = {}; + for (const [k, v] of Object.entries(vars)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + return run(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +test("exports the factory under all three names", () => { + assert.equal(typeof OpenCodeContextCachePlugin, "function"); + assert.equal(EnhancedCachePlugin, OpenCodeContextCachePlugin); + assert.equal(OpenCodeContextCacheDefault, OpenCodeContextCachePlugin); +}); + +test("factory returns a chat.params hook", async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + assert.equal(typeof hooks["chat.params"], "function"); +}); + +test("the hook applies exactly the key the resolver would produce", async () => { + await withEnv({ [PROMPT_CACHE_KEY_ENV_VAR]: undefined, [SCOPE_ENV_VAR]: undefined }, async () => { + const input = { directory: "/srv/repo/pkg/a", worktree: "/srv/repo" }; + const expected = resolveCacheKey({ + env: process.env, + directory: input.directory, + worktree: input.worktree, + user: getUsername(process.env), + host: safeHostname(), + }); + const hooks = await OpenCodeContextCachePlugin(input); + const output = { options: { promptCacheKey: SESSION } }; + await hooks["chat.params"](hookInput(), output); + assert.equal(output.options.promptCacheKey, expected.value); + }); +}); + +test("the hook never writes conversation headers", async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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 leaves a key it did not set", async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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 () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + const output = { options: { store: false } }; + await hooks["chat.params"](hookInput(), output); + assert.deepEqual(output.options, { store: false }); +}); + +test("the hook is inert when no key could be resolved", async () => { + await withEnv({ [SCOPE_ENV_VAR]: "session" }, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + const output = { options: { promptCacheKey: SESSION } }; + await hooks["chat.params"](hookInput(), output); + assert.equal(output.options.promptCacheKey, SESSION); + }); +}); + +test("the hook does not throw on malformed input or output", async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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 } }); + await hooks["chat.params"](hookInput({ sessionID: undefined }), { options: { promptCacheKey: SESSION } }); +}); + +test("two worktrees yield different keys, independent of process.cwd()", async () => { + const output = (key) => ({ options: { promptCacheKey: key } }); + const a = await OpenCodeContextCachePlugin({ directory: "/srv/a/sub", worktree: "/srv/a" }); + const b = await OpenCodeContextCachePlugin({ directory: "/srv/b/sub", worktree: "/srv/b" }); + const outA = output(SESSION); + const outB = output(SESSION); + await a["chat.params"](hookInput(), outA); + await b["chat.params"](hookInput(), outB); + assert.notEqual(outA.options.promptCacheKey, outB.options.promptCacheKey); +}); + +test("a nested directory shares the key of its worktree root", async () => { + const root = await OpenCodeContextCachePlugin({ directory: "/srv/a", worktree: "/srv/a" }); + const nested = await OpenCodeContextCachePlugin({ directory: "/srv/a/pkg/deep", worktree: "/srv/a" }); + const outRoot = { options: { promptCacheKey: SESSION } }; + const outNested = { options: { promptCacheKey: SESSION } }; + await root["chat.params"](hookInput(), outRoot); + await nested["chat.params"](hookInput(), outNested); + assert.equal(outRoot.options.promptCacheKey, outNested.options.promptCacheKey); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test` +Expected: FAIL with `does not provide an export named 'getUsername'`. + +- [ ] **Step 3: Append the factory** + +```js +export function getUsername(env = process.env) { + try { + const info = userInfo(); + 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"; +} + +export function safeHostname() { + try { + return hostname() || "unknown-host"; + } catch { + return "unknown-host"; + } +} + +export const OpenCodeContextCachePlugin = async (input = {}) => { + const env = process.env; + const logger = createLogger({ env }); + const resolved = resolveCacheKey({ + env, + worktree: input?.worktree, + directory: input?.directory, + user: getUsername(env), + host: safeHostname(), + }); + + if (!resolved) { + logger.debug("no stable cache key resolved; leaving opencode's session default in place"); + } 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}`, + ); + } + + return { + "chat.params": async (hookInput, output) => { + if (!resolved) return; + const provider = hookInput?.model?.providerID ?? hookInput?.provider?.info?.id ?? "unknown"; + try { + const outcome = applyCacheKey(output, resolved.value, hookInput?.sessionID); + if (outcome === "applied") { + logger.debug(`applied cache key for provider=${provider}`); + return; + } + if (outcome === "foreign") { + logger.warnOnce( + `foreign:${provider}`, + `provider ${provider} already carries a prompt cache key this plugin did not set; leaving it unchanged`, + ); + return; + } + logger.warnOnce( + `absent:${provider}`, + `provider ${provider} exposes no prompt cache key field, so none was applied. ` + + "This is expected for providers that do not support one; if this provider used to work, " + + "opencode may have renamed the field.", + ); + } catch (error) { + // A cache optimization must never fail the user's request. + logger.warnOnce(`error:${provider}`, `unexpected error applying cache key: ${error?.stack ?? error}`); + } + }, + }; +}; + +/** Kept so existing configs importing the old name keep working. */ +export const EnhancedCachePlugin = OpenCodeContextCachePlugin; + +export default OpenCodeContextCachePlugin; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test` +Expected: PASS, 42 tests total. + +- [ ] **Step 5: Verify no header writing survives anywhere in the file** + +Run: `grep -nE "x-session-id|session_id|conversation_id|model\.headers|x-session-affinity" plugins/opencode-context-cache.mjs` +Expected: no output. If anything matches, remove it. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/opencode-context-cache.mjs test/plugin-hook.test.mjs +git commit -m "feat: resolve the key once per plugin instance from PluginInput + +Drops all conversation-identity header writes and the module-level +singletons, so two projects served by one process no longer share a key." +``` + +--- + +### Task 5: Opt-in integration test against a real opencode + +**Files:** +- Create: `test/integration/probe-plugin.mjs` +- Create: `test/integration/lifecycle.test.mjs` + +**Interfaces:** +- Consumes: nothing from the plugin under test; it asserts the opencode contract the plugin depends on. +- Produces: nothing consumed by later tasks. + +This is the compatibility gate. The installed plugin types are 1.18.21 while the binary is 1.18.25, so the behavior this design rests on is verified against compiled code, not a published contract. Run this before upgrading opencode. + +- [ ] **Step 1: Create the probe fixture** + +Create `test/integration/probe-plugin.mjs`: + +```js +import { appendFileSync } from "fs"; + +const OUT = process.env.CONTEXT_CACHE_PROBE_OUT; + +export const ProbePlugin = async (input) => { + if (OUT) { + appendFileSync( + OUT, + JSON.stringify({ + directory: input?.directory, + worktree: input?.worktree, + hasWorktree: input ? "worktree" in input : false, + vcs: input?.project?.vcs ?? null, + cwd: process.cwd(), + }) + "\n", + "utf8", + ); + } + return {}; +}; + +export default ProbePlugin; +``` + +- [ ] **Step 2: Write the failing test** + +Create `test/integration/lifecycle.test.mjs`: + +```js +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +function findOpencode() { + const candidates = [ + process.env.OPENCODE_BIN, + join(process.env.HOME ?? "", ".opencode", "bin", "opencode"), + ].filter(Boolean); + return candidates.find((p) => existsSync(p)) ?? null; +} + +const BIN = findOpencode(); +const skip = BIN ? false : "no opencode binary found; set OPENCODE_BIN to run this suite"; + +const root = mkdtempSync(join(tmpdir(), "ctx-cache-it-")); +const probeOut = join(root, "probe.jsonl"); +const servers = []; + +after(() => { + for (const s of servers) s.kill("SIGTERM"); +}); + +function makeProject(name) { + const dir = join(root, name); + mkdirSync(join(dir, "pkg", "deep"), { recursive: true }); + execFileSync("git", ["init", "-q", dir]); + execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", "init"], { + env: { + ...process.env, + GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@e", + GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@e", + }, + }); + 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 serveAndProbe(port, directories, cwd) { + const child = spawn(BIN, ["serve", "--port", String(port)], { + cwd, + env: { ...process.env, CONTEXT_CACHE_PROBE_OUT: probeOut }, + stdio: "ignore", + }); + servers.push(child); + await new Promise((r) => setTimeout(r, 8000)); + for (const dir of directories) { + await fetch(`http://127.0.0.1:${port}/config`, { + headers: { "x-opencode-directory": encodeURIComponent(dir) }, + }).catch(() => {}); + } + await new Promise((r) => setTimeout(r, 3000)); + child.kill("SIGTERM"); + return readFileSync(probeOut, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); +} + +test("one server process gives each project its own PluginInput", { skip }, async () => { + const a = makeProject("alpha"); + const b = makeProject("beta"); + // Start the server from a directory that is neither project, so any + // implementation reading process.cwd() is demonstrably wrong. + const records = await serveAndProbe(47901, [a, b], root); + + const forA = records.find((r) => r.worktree === a); + const forB = records.find((r) => r.worktree === b); + + assert.ok(forA, "expected a plugin invocation for project alpha"); + assert.ok(forB, "expected a plugin invocation for project beta"); + assert.notEqual(forA.worktree, forB.worktree); + assert.equal(forA.cwd, forB.cwd, "both invocations share one process cwd"); + assert.notEqual(forA.cwd, forA.worktree, "process.cwd() is not the project path"); +}); + +test("PluginInput still exposes worktree as the VCS root", { skip }, async () => { + const project = makeProject("gamma"); + const nested = join(project, "pkg", "deep"); + const records = await serveAndProbe(47902, [nested], nested); + const record = records.find((r) => r.directory === nested); + + assert.ok(record, "expected a plugin invocation for the nested directory"); + assert.equal(record.hasWorktree, true, "PluginInput.worktree must exist"); + assert.equal(record.worktree, project, "worktree must be the git root, not the cwd"); + assert.equal(record.vcs, "git"); +}); +``` + +- [ ] **Step 3: Run the integration suite** + +Run: `npm run test:integration` +Expected: PASS with 2 tests when an opencode binary is present; both reported as skipped otherwise. + +- [ ] **Step 4: Confirm the unit suite did not pick these up** + +Run: `npm test` +Expected: still 42 tests. `test/*.test.mjs` must not match `test/integration/`. + +- [ ] **Step 5: Commit** + +```bash +git add test/integration +git commit -m "test: add an opt-in opencode lifecycle compatibility gate + +Asserts the two contracts this design rests on - one plugin instance per +project, and worktree as the VCS root - against the real binary. Skips +when none is installed, so CI stays green." +``` + +--- + +### Task 6: CI and README + +**Files:** +- Create: `.github/workflows/test.yml` +- Modify: `README.md` (rewrite) + +**Interfaces:** +- Consumes: the `test` script from Task 1. +- Produces: nothing. + +- [ ] **Step 1: Create the CI workflow** + +Create `.github/workflows/test.yml`: + +```yaml +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 }} + - run: npm test +``` + +No install step: the project has no dependencies. + +- [ ] **Step 2: Rewrite `README.md`** + +Replace the file. Required content, in order: + +1. **Title and one-paragraph summary.** State plainly that the plugin sets a prompt cache key stable across sessions in one git worktree, replacing opencode's per-session default. +2. **A "Breaking change in 0.2.0" section, near the top.** State that the plugin no longer writes `x-session-id`, `conversation_id` or `session_id`; that opencode core already sends `x-session-affinity` and `X-Session-Id` derived from the real session ID; and that a gateway relying on the underscore names must be reconfigured to read core's headers. Give the reason in one sentence: those header names identify a conversation, and a project-stable value in them is wrong, because on opencode's OpenAI/Codex path `x-session-affinity` keys a WebSocket pool whose `busy` and `fallback` state would then be shared by every concurrent session in the project. +3. **How it works.** The three-line version: core sets the cache key to the session ID; this plugin replaces that value, and only that value, with `sha256(user@host:)`. +4. **Install.** Both routes: npm identifier in the `plugin` array, and copying the single file. Keep the existing warning that the `plugin` entry is required. +5. **Configuration.** A table of all five env vars with defaults, plus `OPENCODE_CONTEXT_CACHE_SCOPE` values (`worktree` default, `directory`, `session`). +6. **Provider support.** Honest: this sets OpenAI-family `promptCacheKey` / `prompt_cache_key`. Anthropic uses `cache_control` breakpoints and ignores a cache key, so the plugin is inert there and says so once in the log. Remove every "works with ALL providers" claim. +7. **Hashing.** Describe as keeping the local username, hostname and path off the wire. Do not call it privacy: the pre-image space is small enough to enumerate. +8. **Observed impact.** Keep the 97.99% figure but label it explicitly as a single anecdotal run on one provider with no controlled baseline. +9. **Troubleshooting.** Debug flag, log location, and what the two operator warnings mean. + +Delete: the `isSha256Hex` digest-detection bullet, the five-level precedence list (it is three levels now), and every reference to setting session headers. + +- [ ] **Step 3: Verify no stale claims survive** + +Run: `grep -niE "all providers|privacy|sticky session header|conversation_id|x-session-id" README.md` +Expected: matches only inside the "Breaking change" section, where those names are named in order to say they were removed. + +- [ ] **Step 4: Run the full suite one more time** + +Run: `npm test && npm run test:integration` +Expected: 42 unit tests pass; integration passes or skips. + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/test.yml README.md +git commit -m "docs: rewrite README for the new behavior, add CI + +Documents the header removal as a breaking change, drops the all-providers +and privacy claims, and labels the cache hit figure as a single run." +``` + +--- + +## Self-Review + +**Spec coverage.** Section 3.1 shape -> Task 1 Step 5. 3.2 resolution, scope, override bounds -> Task 1. 3.3 provenance and replacement -> Task 2. 3.4 hook wiring -> Task 4. 3.5 logging and warning channel -> Task 3. 3.6 error handling table -> Tasks 2 and 4 (every row has a test). Section 4 unit tests -> Tasks 1-4; hook-level tests -> Task 4; integration -> Task 5. Section 5 deliverables -> all six tasks. No gaps. + +**Placeholder scan.** Every code step carries complete code. Task 6 Step 2 specifies README content as a numbered list of required sections rather than full prose; that is a documentation step, and each item states exactly what it must say and what must be deleted, with a grep gate in Step 3 to verify. + +**Type consistency.** `resolveCacheKey` returns `{raw, value, source, hashed, sensitive}` in Task 1 and is destructured for exactly those fields in Task 4. `applyCacheKey(output, value, sessionID)` takes the output object, not `options`, in both Task 2 and Task 4. `createLogger` exposes `enabled`, `path`, `debug`, `warnOnce` in Task 3 and Task 4 uses only those. `trimmedEnv` and `safeJson` are module-private and defined once each, in Tasks 1 and 3 respectively. From bfeb564de5bcb00e35e9f002229922719a73582a Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 18:54:35 +0900 Subject: [PATCH 04/19] docs: fold the plan review back into the spec Records the second Codex round: the tri-state result could not represent the states the error table distinguishes, scope=session did not opt out, and three findings were folded in as prose without code. --- .../plans/2026-08-30-cache-key-and-headers.md | 1271 +++++++++++------ ...2026-08-30-cache-key-and-headers-design.md | 84 +- 2 files changed, 928 insertions(+), 427 deletions(-) diff --git a/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md b/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md index c050446..382ecba 100644 --- a/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md +++ b/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md @@ -13,14 +13,18 @@ ## Global Constraints - Single shipped file: `plugins/opencode-context-cache.mjs`. Do not split into `src/`; upstream's install path is copying that one file. +- **Every commit must leave a loadable plugin.** After each task, `plugins/opencode-context-cache.mjs` must still export `OpenCodeContextCachePlugin`, `EnhancedCachePlugin` and a default, and that factory must return an object with a `chat.params` function. An intermediate commit may ship a plugin that does nothing; it may never ship one opencode cannot load. - Zero dependencies, runtime and dev. Tests run on `node --test` with no install step. - Node `>=20`. -- The plugin must never throw out of the `chat.params` hook. +- **The hook must never throw.** The entire hook body, including provider-label extraction, lives inside one `try`. Logging and warning sinks are themselves wrapped so a failing `console.warn` cannot escape. - Never write `x-session-id`, `conversation_id`, `session_id`, `X-Session-Id`, or `x-session-affinity`. Never touch `input.model.headers`. - Never log the raw value of an operator-supplied override; log its source and an 8-character fingerprint only. - `MAX_CACHE_KEY_LENGTH = 64`. Printable ASCII is `/^[\x20-\x7E]+$/`. +- **Do not trim filesystem paths.** A path may legitimately end in whitespace. Treat a whitespace-only path as absent; otherwise use it verbatim. - Keep the `EnhancedCachePlugin` named export and the default export as aliases. - Env var names, exact: `OPENCODE_PROMPT_CACHE_KEY`, `OPENCODE_STICKY_SESSION_ID`, `OPENCODE_CONTEXT_CACHE_SCOPE`, `OPENCODE_CONTEXT_CACHE_DEBUG`, `OPENCODE_CONTEXT_CACHE_LOG`. +- **Scope precedence:** parse scope first. `session` is a hard opt-out that beats an explicit override, because it is the safety valve for providers with lookup-key semantics and must not be defeatable by a stale env var. An unrecognised scope value warns once and falls back to `worktree`. +- **Config precedence:** env vars beat the plugin `options` object from `opencode.jsonc`, which beats defaults. - Commit messages: no `Co-Authored-By` agent attribution. Use a plain dash, never an em dash, in all prose and code comments. ## File Structure @@ -28,33 +32,36 @@ | File | Responsibility | |---|---| | `plugins/opencode-context-cache.mjs` | Everything shipped: pure helpers + factory. Rewritten. | -| `package.json` | npm-installable identity, `test` scripts. New. | +| `package.json` | npm-installable identity, explicit `test` scripts. New. | | `.gitignore` | log file, `node_modules`. New. | -| `test/cache-key.test.mjs` | `resolveCacheKey`, `selectScopePath`, override bounds. | -| `test/apply-cache-key.test.mjs` | `applyCacheKey` provenance and immutability. | -| `test/logger.test.mjs` | log path, write failure, `warnOnce` dedup. | -| `test/plugin-hook.test.mjs` | the real factory and the hook it returns. | -| `test/integration/lifecycle.test.mjs` | opt-in, runs a real `opencode serve`. | -| `test/integration/probe-plugin.mjs` | fixture plugin that records its `PluginInput`. | -| `.github/workflows/test.yml` | CI: `npm test` on Node 20 and 22. | -| `README.md` | Rewritten, honest claims, breaking-change notice. | +| `test/unit/cache-key.test.mjs` | resolution, scope parsing, override bounds. | +| `test/unit/apply-cache-key.test.mjs` | provenance, per-field outcomes, immutability. | +| `test/unit/logger.test.mjs` | log path, write and mkdir failure, `warnOnce`, redaction. | +| `test/unit/plugin-hook.test.mjs` | the real factory and the hook it returns. | +| `test/integration/probe-plugin.mjs` | fixture recording `PluginInput`. | +| `test/integration/plugin-input-contract.test.mjs` | opt-in, runs a real `opencode serve`. | +| `.github/workflows/test.yml` | CI on Node 20 and 22. | +| `README.md`, `CHANGELOG.md` | Rewritten / new. | --- -### Task 1: Scaffolding and cache key resolution +### Task 1: Scaffolding and a loadable, inert plugin + +Delivers the resolution layer and a plugin that loads, resolves a key, logs it, and deliberately does nothing with it yet. Applying the key arrives in Task 4. **Files:** -- Create: `package.json` -- Create: `.gitignore` +- Create: `package.json`, `.gitignore` - Create: `plugins/opencode-context-cache.mjs` (replacing the existing file wholesale) -- Test: `test/cache-key.test.mjs` +- Test: `test/unit/cache-key.test.mjs` **Interfaces:** - Consumes: nothing. -- Produces: `sha256(value) -> string`, `isSafeOverride(value) -> boolean`, `selectScopePath({scope, worktree, directory}) -> string`, `resolveCacheKey({env, worktree, directory, user, host}) -> {raw, value, source, hashed, sensitive} | null`, and the constants `PROMPT_CACHE_KEY_ENV_VAR`, `STICKY_SESSION_ID_ENV_VAR`, `SCOPE_ENV_VAR`, `DEBUG_ENV_VAR`, `LOG_PATH_ENV_VAR`, `MAX_CACHE_KEY_LENGTH`. +- Produces: constants `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`; `sha256(v) -> string`; `fingerprint(v) -> string`; `isSafeOverride(v) -> boolean`; `parseScope(raw) -> {scope, unknown}`; `selectScopePath({scope, worktree, directory}) -> string`; `resolveCacheKey({env, options, worktree, directory, user, host}) -> {raw, value, source, hashed, sensitive, deprecated, unknownScope} | null`; `getUsername({env, readUserInfo}) -> string`; `safeHostname({readHostname}) -> string`; `createLogger(...)`; `OpenCodeContextCachePlugin`, `EnhancedCachePlugin`, default. - [ ] **Step 1: Create `package.json`** +Test files are listed explicitly. A glob is not portable to Windows `cmd.exe`, and on Node 24 an unmatched quoted glob reports zero tests and exits 0 - a silently green CI run. + ```json { "name": "opencode-context-cache", @@ -68,11 +75,12 @@ "files": [ "plugins/", "README.md", + "CHANGELOG.md", "LICENSE" ], "scripts": { - "test": "node --test test/*.test.mjs", - "test:integration": "node --test test/integration/*.test.mjs" + "test": "node --test test/unit/cache-key.test.mjs test/unit/apply-cache-key.test.mjs test/unit/logger.test.mjs test/unit/plugin-hook.test.mjs", + "test:integration": "node --test test/integration/plugin-input-contract.test.mjs" }, "keywords": ["opencode", "opencode-plugin", "prompt-cache"], "license": "MIT", @@ -92,7 +100,7 @@ context-cache.log - [ ] **Step 3: Write the failing test** -Create `test/cache-key.test.mjs`: +Create `test/unit/cache-key.test.mjs`: ```js import { test } from "node:test"; @@ -104,13 +112,16 @@ import { PROMPT_CACHE_KEY_ENV_VAR, SCOPE_ENV_VAR, STICKY_SESSION_ID_ENV_VAR, + getUsername, isSafeOverride, + parseScope, resolveCacheKey, + safeHostname, selectScopePath, sha256, -} from "../plugins/opencode-context-cache.mjs"; +} from "../../plugins/opencode-context-cache.mjs"; -const BASE = { user: "andrea", host: "moonveil", worktree: "/srv/repo", directory: "/srv/repo/pkg/a" }; +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", () => { @@ -125,21 +136,36 @@ test("isSafeOverride bounds length and character set", () => { assert.equal(isSafeOverride("café"), false); }); -test("selectScopePath prefers worktree, guards against a degenerate root", () => { +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.raw, "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", () => { @@ -150,18 +176,25 @@ test("prompt cache key env beats the deprecated sticky session env", () => { assert.equal(r.value, "first"); }); -test("deprecated sticky session env is still honoured", () => { +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.source, STICKY_SESSION_ID_ENV_VAR); + assert.equal(r.deprecated, true); }); -test("an unsafe override is hashed rather than sent as-is", () => { +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, 64); + 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", () => { @@ -170,52 +203,81 @@ test("whitespace-only env values are ignored", () => { }); test("generated key is the sha256 of user@host:worktree", () => { - const r = resolveCacheKey({ ...BASE, env: {} }); + 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 env can narrow to the directory", () => { +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 yields no key so core's default stands", () => { +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 explicit override still wins over scope session", () => { - const r = resolveCacheKey({ - ...BASE, - env: { [SCOPE_ENV_VAR]: "session", [PROMPT_CACHE_KEY_ENV_VAR]: "team-key" }, - }); - assert.equal(r.value, "team-key"); +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({ user: "a", host: "b", worktree: "", directory: "", env: {} }), null); + assert.equal(resolveCacheKey({ ...BASE, worktree: "", directory: "" }), null); }); test("key is deterministic and varies with user, host and path", () => { - const a = resolveCacheKey({ ...BASE, env: {} }); - assert.equal(a.value, resolveCacheKey({ ...BASE, env: {} }).value); - assert.notEqual(a.value, resolveCacheKey({ ...BASE, user: "other", env: {} }).value); - assert.notEqual(a.value, resolveCacheKey({ ...BASE, host: "other", env: {} }).value); - assert.notEqual(a.value, resolveCacheKey({ ...BASE, worktree: "/srv/other", env: {} }).value); + 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"); }); ``` - [ ] **Step 4: Run the test to verify it fails** Run: `npm test` -Expected: FAIL. The existing `plugins/opencode-context-cache.mjs` exports none of these names, so the import throws `SyntaxError: The requested module ... does not provide an export named 'resolveCacheKey'`. +Expected: FAIL at module link time with `SyntaxError: The requested module '../../plugins/opencode-context-cache.mjs' does not provide an export named 'MAX_CACHE_KEY_LENGTH'`. Node reports the *first* missing binding in the import list, not `resolveCacheKey`. -- [ ] **Step 5: Replace `plugins/opencode-context-cache.mjs` with the resolution layer** +- [ ] **Step 5: Replace `plugins/opencode-context-cache.mjs`** -Delete the entire existing contents. The `DebugLogger`, `CacheKeyResolver`, `CacheKeyApplier` and `ContextCachePluginRuntime` classes and the module-level singletons all go. Write: +Delete the entire existing contents: the `DebugLogger`, `CacheKeyResolver`, `CacheKeyApplier` and `ContextCachePluginRuntime` classes, and the module-level singletons. Write: ```js /** @@ -224,7 +286,7 @@ Delete the entire existing contents. The `DebugLogger`, `CacheKeyResolver`, `Cac * 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. * - * It sets exactly one thing: the prompt cache key field that opencode core has + * 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. */ @@ -243,21 +305,39 @@ export const LOG_PATH_ENV_VAR = "OPENCODE_CONTEXT_CACHE_LOG"; /** OpenAI is reported to cap prompt_cache_key at 64 characters; a sha256 hex digest is exactly 64. */ export const MAX_CACHE_KEY_LENGTH = 64; +export const SCOPES = ["worktree", "directory", "session"]; + const PRINTABLE_ASCII = /^[\x20-\x7E]+$/; export function sha256(value) { return createHash("sha256").update(value, "utf8").digest("hex"); } -function trimmedEnv(env, name) { +export function fingerprint(value) { + return sha256(value).slice(0, 8); +} + +function readEnv(env, name) { const value = env?.[name]; return typeof value === "string" ? value.trim() : ""; } +/** Paths are used verbatim: only a whitespace-only path counts as absent. */ +function usablePath(value) { + return typeof value === "string" && value.trim() !== "" ? value : ""; +} + export function isSafeOverride(value) { return value.length <= MAX_CACHE_KEY_LENGTH && PRINTABLE_ASCII.test(value); } +export 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 }; +} + /** * Mirrors core's own project-path guard: * vcs === "git" && worktree !== "/" ? worktree : directory @@ -265,44 +345,174 @@ export function isSafeOverride(value) { * machine onto a single key. */ export function selectScopePath({ scope, worktree, directory }) { - const tree = typeof worktree === "string" ? worktree.trim() : ""; - const dir = typeof directory === "string" ? directory.trim() : ""; + const tree = usablePath(worktree); + const dir = usablePath(directory); if (scope === "session") return ""; if (scope === "directory") return dir; - if (tree && tree !== "/") return tree; + if (tree && tree.trim() !== "/") return tree; return dir; } -export function resolveCacheKey({ env = {}, worktree, directory, user, host } = {}) { - for (const name of [PROMPT_CACHE_KEY_ENV_VAR, STICKY_SESSION_ID_ENV_VAR]) { - const raw = trimmedEnv(env, name); - if (!raw) continue; +export 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( + readEnv(env, SCOPE_ENV_VAR) || (typeof options?.scope === "string" ? options.scope : ""), + ); + 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: name, hashed: !safe, sensitive: true }; + return { raw, value: safe ? raw : sha256(raw), source, hashed: !safe, sensitive: true, deprecated, unknownScope }; } - const scope = trimmedEnv(env, SCOPE_ENV_VAR).toLowerCase() || "worktree"; const path = selectScopePath({ scope, worktree, directory }); if (!path) return null; const raw = `${user}@${host}:${path}`; - return { raw, value: sha256(raw), source: `user@host:${scope}`, hashed: true, sensitive: false }; + return { + raw, + value: sha256(raw), + source: `user@host:${scope}`, + hashed: true, + sensitive: false, + deprecated: false, + unknownScope, + }; } + +export 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"; +} + +export function safeHostname({ readHostname = hostname } = {}) { + try { + return readHostname() || "unknown-host"; + } catch { + return "unknown-host"; + } +} + +export function defaultLogPath(env = {}, home = homedir()) { + const explicit = readEnv(env, LOG_PATH_ENV_VAR); + if (explicit) return explicit; + const stateHome = readEnv(env, "XDG_STATE_HOME") || join(home, ".local", "state"); + return join(stateHome, "opencode", "context-cache.log"); +} + +function safeJson(value) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** Minimal debug-only logger. The operator warning channel arrives in Task 3. */ +export 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); + let fileUsable = true; + let dirReady = false; + + function emit(message) { + try { + warn(`[context-cache] ${message}`); + } catch { + // A failing warning sink must never escape into the request path. + } + } + + return { + 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}: ${error?.message ?? error}; debug logging disabled`); + } + }, + }; +} + +export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { + const env = process.env; + const logger = createLogger({ env }); + const resolved = resolveCacheKey({ + env, + options, + worktree: input?.worktree, + directory: input?.directory, + user: getUsername({ env }), + host: safeHostname(), + }); + + if (!resolved) logger.debug("no stable cache key resolved; leaving opencode's session default in place"); + 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}`, + ); + } + + return { + // Applying the key is wired in Task 4. This keeps the plugin loadable. + "chat.params": async () => {}, + }; +}; + +/** Kept so existing configs importing the old name keep working. */ +export const EnhancedCachePlugin = OpenCodeContextCachePlugin; + +export default OpenCodeContextCachePlugin; ``` - [ ] **Step 6: Run the test to verify it passes** Run: `npm test` -Expected: PASS, 14 tests. +Expected: PASS, 20 tests. + +- [ ] **Step 7: Verify the plugin is still loadable** + +Run: `node -e "import('./plugins/opencode-context-cache.mjs').then(async m => { const h = await m.default({directory:'/tmp',worktree:'/tmp'}); console.log(typeof h['chat.params']); })"` +Expected: `function` -- [ ] **Step 7: Commit** +- [ ] **Step 8: Commit** ```bash -git add package.json .gitignore plugins/opencode-context-cache.mjs test/cache-key.test.mjs +git add package.json .gitignore plugins/opencode-context-cache.mjs test/unit/cache-key.test.mjs git commit -m "feat: resolve a worktree-scoped prompt cache key Replaces the process.cwd() key with one derived from PluginInput, bounds -explicit overrides to what a provider will accept, and adds a scope knob." +explicit overrides to what a provider will accept, and makes scope=session +a hard opt-out. The plugin loads and is inert; applying the key follows." ``` --- @@ -310,86 +520,108 @@ explicit overrides to what a provider will accept, and adds a scope knob." ### Task 2: Provenance-checked application **Files:** -- Modify: `plugins/opencode-context-cache.mjs` (append) -- Test: `test/apply-cache-key.test.mjs` +- Modify: `plugins/opencode-context-cache.mjs` (append, above the factory) +- Test: `test/unit/apply-cache-key.test.mjs` **Interfaces:** -- Consumes: nothing from Task 1 at runtime; shares the file. -- Produces: `CACHE_KEY_FIELDS: string[]`, `stripSesPrefix(sessionID) -> string`, `applyCacheKey(output, value, sessionID) -> "applied" | "absent" | "foreign"`. +- Consumes: nothing at runtime. +- Produces: `CACHE_KEY_FIELDS: string[]`, `stripSesPrefix(sessionID) -> string`, `applyCacheKey(output, value, sessionID) -> {appliedFields: string[], foreignFields: string[], reason: "invalid-options"|"missing-session"|"no-fields"|null}`. + +A three-value return cannot express "applied one field and found another foreign", so the result is a record. Only `reason === "no-fields"` and a non-empty `foreignFields` warrant an operator warning; `invalid-options` and `missing-session` are debug-only, per the spec's error table. - [ ] **Step 1: Write the failing test** -Create `test/apply-cache-key.test.mjs`: +Create `test/unit/apply-cache-key.test.mjs`: ```js import { test } from "node:test"; import assert from "node:assert/strict"; -import { applyCacheKey, stripSesPrefix } from "../plugins/opencode-context-cache.mjs"; +import { applyCacheKey, stripSesPrefix } from "../../plugins/opencode-context-cache.mjs"; const SESSION = "ses_" + "a".repeat(64); +const STRIPPED = "a".repeat(64); const KEY = "stable-key"; -test("strips the ses_ prefix only from a full 64-hex session id", () => { - assert.equal(stripSesPrefix(SESSION), "a".repeat(64)); +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 } }; - assert.equal(applyCacheKey(output, KEY, SESSION), "applied"); + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r, { appliedFields: ["promptCacheKey"], foreignFields: [], 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 } }; - assert.equal(applyCacheKey(output, KEY, SESSION), "applied"); + 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: "a".repeat(64) } }; - assert.equal(applyCacheKey(output, KEY, SESSION), "applied"); + 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", () => { +test("leaves a value this plugin did not set and reports it", () => { const output = { options: { promptCacheKey: "someone-elses-key" } }; - assert.equal(applyCacheKey(output, KEY, SESSION), "foreign"); + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r, { appliedFields: [], foreignFields: ["promptCacheKey"], 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("treats a present-but-undefined field as foreign, not as core's", () => { const output = { options: { promptCacheKey: undefined } }; - assert.equal(applyCacheKey(output, KEY, SESSION), "foreign"); + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.foreignFields, ["promptCacheKey"]); assert.equal(output.options.promptCacheKey, undefined); }); -test("adds nothing when no cache key field is present", () => { +test("reports no-fields distinctly when core placed nothing", () => { const output = { options: { store: false } }; - assert.equal(applyCacheKey(output, KEY, SESSION), "absent"); + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r, { appliedFields: [], foreignFields: [], reason: "no-fields" }); assert.deepEqual(output.options, { store: false }); }); -test("does not throw and reports absent when options are missing", () => { - assert.equal(applyCacheKey({}, KEY, SESSION), "absent"); - assert.equal(applyCacheKey(undefined, KEY, SESSION), "absent"); - assert.equal(applyCacheKey({ options: null }, KEY, SESSION), "absent"); +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("cannot prove provenance without a session id", () => { +test("reports missing-session distinctly and changes nothing", () => { const output = { options: { promptCacheKey: SESSION } }; - assert.equal(applyCacheKey(output, KEY, undefined), "absent"); - assert.equal(output.options.promptCacheKey, SESSION); -}); - -test("replaces the core-owned field and leaves a foreign sibling alone", () => { - const output = { options: { promptCacheKey: SESSION, prompt_cache_key: "theirs" } }; - assert.equal(applyCacheKey(output, KEY, SESSION), "applied"); - assert.equal(output.options.promptCacheKey, KEY); - assert.equal(output.options.prompt_cache_key, "theirs"); + 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", () => { @@ -406,7 +638,9 @@ test("replaces options rather than mutating the object it was handed", () => { Run: `npm test` Expected: FAIL with `does not provide an export named 'applyCacheKey'`. -- [ ] **Step 3: Append the application layer to the plugin file** +- [ ] **Step 3: Append the application layer** + +Insert immediately before `export const OpenCodeContextCachePlugin`: ```js /** The two spellings opencode core uses, depending on provider. */ @@ -428,63 +662,74 @@ export function stripSesPrefix(sessionID) { */ export function applyCacheKey(output, value, sessionID) { const options = output?.options; - if (!options || typeof options !== "object") return "absent"; - if (typeof sessionID !== "string" || sessionID === "") return "absent"; + if (!options || typeof options !== "object") { + return { appliedFields: [], foreignFields: [], reason: "invalid-options" }; + } + if (typeof sessionID !== "string" || sessionID === "") { + return { appliedFields: [], foreignFields: [], reason: "missing-session" }; + } const stripped = stripSesPrefix(sessionID); + const appliedFields = []; + const foreignFields = []; const replacements = {}; - let sawForeign = false; for (const field of CACHE_KEY_FIELDS) { if (!(field in options)) continue; const current = options[field]; - if (current === sessionID || current === stripped) replacements[field] = value; - else sawForeign = true; + if (current === sessionID || current === stripped) { + replacements[field] = value; + appliedFields.push(field); + } else { + foreignFields.push(field); + } } - if (Object.keys(replacements).length === 0) return sawForeign ? "foreign" : "absent"; - - output.options = { ...options, ...replacements }; - return "applied"; + if (appliedFields.length === 0 && foreignFields.length === 0) { + return { appliedFields, foreignFields, reason: "no-fields" }; + } + if (appliedFields.length > 0) output.options = { ...options, ...replacements }; + return { appliedFields, foreignFields, reason: null }; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `npm test` -Expected: PASS, 25 tests total. +Expected: PASS, 32 tests total. - [ ] **Step 5: Commit** ```bash -git add plugins/opencode-context-cache.mjs test/apply-cache-key.test.mjs +git add plugins/opencode-context-cache.mjs test/unit/apply-cache-key.test.mjs git commit -m "feat: replace the cache key only when it is core's own default Field presence does not prove provenance; matching opencode's session ID -does, and it leaves deliberate operator and plugin settings untouched." +does. Reports applied and foreign fields separately so a mixed conflict +is visible rather than silently half-applied." ``` --- -### Task 3: Logging and the operator warning channel +### Task 3: The operator warning channel **Files:** -- Modify: `plugins/opencode-context-cache.mjs` (append) -- Test: `test/logger.test.mjs` +- Modify: `plugins/opencode-context-cache.mjs` (extend `createLogger`) +- Test: `test/unit/logger.test.mjs` **Interfaces:** -- Consumes: `sha256` from Task 1. -- Produces: `defaultLogPath(env, home) -> string`, `fingerprint(value) -> string`, `createLogger({env, filePath, write, warn}) -> {enabled, path, debug(...args), warnOnce(key, message) -> boolean}`. +- Consumes: `createLogger` from Task 1. +- Produces: `createLogger(...)` additionally exposing `warnOnce(key, message) -> boolean`. - [ ] **Step 1: Write the failing test** -Create `test/logger.test.mjs`: +Create `test/unit/logger.test.mjs`: ```js -import { test } from "node:test"; +import { after, test } from "node:test"; import assert from "node:assert/strict"; import { join } from "node:path"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { @@ -493,7 +738,17 @@ import { createLogger, defaultLogPath, fingerprint, -} from "../plugins/opencode-context-cache.mjs"; +} from "../../plugins/opencode-context-cache.mjs"; + +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"); @@ -504,10 +759,11 @@ test("default log path honours XDG_STATE_HOME, else falls back under home", () = assert.equal(defaultLogPath({}, "/home/u"), "/home/u/.local/state/opencode/context-cache.log"); }); -test("fingerprint is a short, stable, non-reversible tag", () => { +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", () => { @@ -519,8 +775,7 @@ test("debug logging is off unless explicitly enabled", () => { }); test("debug logging writes one single-line entry when enabled", () => { - const dir = mkdtempSync(join(tmpdir(), "ctx-cache-")); - const path = join(dir, "nested", "context-cache.log"); + 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"); @@ -533,7 +788,7 @@ test("an unwritable log warns exactly once and never throws", () => { const warnings = []; const logger = createLogger({ env: { [DEBUG_ENV_VAR]: "true" }, - filePath: "/unused", + filePath: join(tempDir(), "x.log"), write: () => { throw new Error("EACCES"); }, warn: (m) => warnings.push(m), }); @@ -544,9 +799,37 @@ test("an unwritable log warns exactly once and never throws", () => { 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"); }, + }); + logger.debug("boom"); + assert.equal(logger.warnOnce("k", "m"), true); +}); + 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"); assert.equal(logger.warnOnce("absent:openai", "first"), true); assert.equal(logger.warnOnce("absent:openai", "again"), false); assert.equal(logger.warnOnce("absent:anthropic", "other"), true); @@ -557,142 +840,95 @@ test("warnOnce deduplicates by key and ignores the debug flag", () => { - [ ] **Step 2: Run the test to verify it fails** Run: `npm test` -Expected: FAIL with `does not provide an export named 'createLogger'`. +Expected: FAIL. `logger.warnOnce is not a function`. -- [ ] **Step 3: Append the logging layer** +- [ ] **Step 3: Add `warnOnce` to `createLogger`** -```js -export function fingerprint(value) { - return sha256(value).slice(0, 8); -} - -export function defaultLogPath(env = {}, home = homedir()) { - const explicit = trimmedEnv(env, LOG_PATH_ENV_VAR); - if (explicit) return explicit; - const stateHome = trimmedEnv(env, "XDG_STATE_HOME") || join(home, ".local", "state"); - return join(stateHome, "opencode", "context-cache.log"); -} - -/** - * Two channels. `debug` is opt-in and file-backed. `warnOnce` is always on and - * deduplicated: a compatibility failure must be visible without the operator - * having first guessed to turn debug logging on. - */ -export 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 fileUsable = true; - - function emit(message) { - warn(`[context-cache] ${message}`); - } - - return { - 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"); - const line = `[${new Date().toISOString()}] [pid:${process.pid}] [context-cache] ${body}\n`; - try { - mkdirSync(dirname(path), { recursive: true }); - write(path, line, "utf8"); - } catch (error) { - fileUsable = false; - emit(`cannot write debug log at ${path}: ${error?.message ?? error}; debug logging disabled`); - } - }, +Inside `createLogger`, add `const warned = new Set();` beside the other state, and add this property to the returned object after `debug`: +```js + /** + * 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; warned.add(key); emit(message); return true; }, - }; -} - -function safeJson(value) { - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} ``` - [ ] **Step 4: Run the test to verify it passes** Run: `npm test` -Expected: PASS, 32 tests total. +Expected: PASS, 41 tests total. - [ ] **Step 5: Commit** ```bash -git add plugins/opencode-context-cache.mjs test/logger.test.mjs -git commit -m "feat: split operator warnings from the opt-in debug log +git add plugins/opencode-context-cache.mjs test/unit/logger.test.mjs +git commit -m "feat: add an always-on deduplicated operator warning channel -Compatibility failures now surface without the debug flag, deduplicated, -and the log moves out of the plugin directory into the XDG state dir." +Compatibility failures must not depend on the operator having already +enabled debug logging. The sink is wrapped so a failing stderr cannot +escape into the request path." ``` --- -### Task 4: Plugin factory and hook wiring +### Task 4: Wire application and warnings into the hook **Files:** -- Modify: `plugins/opencode-context-cache.mjs` (append) -- Test: `test/plugin-hook.test.mjs` +- Modify: `plugins/opencode-context-cache.mjs` (replace the factory's hook) +- Test: `test/unit/plugin-hook.test.mjs` **Interfaces:** -- Consumes: `resolveCacheKey`, `applyCacheKey`, `createLogger`, `fingerprint` from Tasks 1-3. -- Produces: `getUsername(env) -> string`, `safeHostname() -> string`, `OpenCodeContextCachePlugin(pluginInput, options?) -> Promise<{"chat.params": fn}>`, plus `EnhancedCachePlugin` and default aliases. +- Consumes: everything from Tasks 1-3. +- Produces: a `chat.params` hook that applies the key and reports outcomes. - [ ] **Step 1: Write the failing test** -Create `test/plugin-hook.test.mjs`: +Create `test/unit/plugin-hook.test.mjs`: ```js import { test } from "node:test"; import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import OpenCodeContextCacheDefault, { + DEBUG_ENV_VAR, EnhancedCachePlugin, OpenCodeContextCachePlugin, PROMPT_CACHE_KEY_ENV_VAR, SCOPE_ENV_VAR, - resolveCacheKey, + STICKY_SESSION_ID_ENV_VAR, getUsername, safeHostname, -} from "../plugins/opencode-context-cache.mjs"; +} from "../../plugins/opencode-context-cache.mjs"; const SESSION = "ses_" + "b".repeat(64); +const digest = (v) => createHash("sha256").update(v, "utf8").digest("hex"); -function hookInput(extra = {}) { - return { - sessionID: SESSION, - agent: "build", - model: { providerID: "openai", modelID: "gpt-5", headers: { "x-existing": "keep" } }, - provider: { info: { id: "openai" } }, - ...extra, - }; -} +/** Every plugin-owned env var, so an ambient value cannot silently change a result. */ +const OWNED = [PROMPT_CACHE_KEY_ENV_VAR, STICKY_SESSION_ID_ENV_VAR, SCOPE_ENV_VAR, DEBUG_ENV_VAR]; -function withEnv(vars, run) { +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)) { - saved[k] = process.env[k]; + if (!(k in saved)) saved[k] = process.env[k]; if (v === undefined) delete process.env[k]; else process.env[k] = v; } try { - return run(); + // 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]; @@ -701,60 +937,73 @@ function withEnv(vars, run) { } } +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("factory returns a chat.params hook", async () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); - assert.equal(typeof hooks["chat.params"], "function"); -}); - -test("the hook applies exactly the key the resolver would produce", async () => { - await withEnv({ [PROMPT_CACHE_KEY_ENV_VAR]: undefined, [SCOPE_ENV_VAR]: undefined }, async () => { - const input = { directory: "/srv/repo/pkg/a", worktree: "/srv/repo" }; - const expected = resolveCacheKey({ - env: process.env, - directory: input.directory, - worktree: input.worktree, - user: getUsername(process.env), - host: safeHostname(), - }); - const hooks = await OpenCodeContextCachePlugin(input); +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.value); + assert.equal(output.options.promptCacheKey, expected); }); }); test("the hook never writes conversation headers", async () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); - 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}`); - } + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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" }); + 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 () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); - const output = { options: { promptCacheKey: "operator-choice" } }; - await hooks["chat.params"](hookInput(), output); - assert.equal(output.options.promptCacheKey, "operator-choice"); + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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 () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); - const output = { options: { store: false } }; - await hooks["chat.params"](hookInput(), output); - assert.deepEqual(output.options, { store: false }); + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + const output = { options: { store: false } }; + await hooks["chat.params"](hookInput(), output); + assert.deepEqual(output.options, { store: false }); + }); }); -test("the hook is inert when no key could be resolved", async () => { +test("the hook is inert when scope disables the key", async () => { await withEnv({ [SCOPE_ENV_VAR]: "session" }, async () => { const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); const output = { options: { promptCacheKey: SESSION } }; @@ -763,77 +1012,164 @@ test("the hook is inert when no key could be resolved", async () => { }); }); +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" }); + 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 () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); - 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 } }); - await hooks["chat.params"](hookInput({ sessionID: undefined }), { options: { promptCacheKey: SESSION } }); + await withEnv({}, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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 () => { - const output = (key) => ({ options: { promptCacheKey: key } }); - const a = await OpenCodeContextCachePlugin({ directory: "/srv/a/sub", worktree: "/srv/a" }); - const b = await OpenCodeContextCachePlugin({ directory: "/srv/b/sub", worktree: "/srv/b" }); - const outA = output(SESSION); - const outB = output(SESSION); - await a["chat.params"](hookInput(), outA); - await b["chat.params"](hookInput(), outB); - assert.notEqual(outA.options.promptCacheKey, outB.options.promptCacheKey); + await withEnv({}, async () => { + const a = await OpenCodeContextCachePlugin({ directory: "/srv/a/sub", worktree: "/srv/a" }); + const b = await OpenCodeContextCachePlugin({ directory: "/srv/b/sub", worktree: "/srv/b" }); + 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(`x@y:${process.cwd()}`)); + }); }); test("a nested directory shares the key of its worktree root", async () => { - const root = await OpenCodeContextCachePlugin({ directory: "/srv/a", worktree: "/srv/a" }); - const nested = await OpenCodeContextCachePlugin({ directory: "/srv/a/pkg/deep", worktree: "/srv/a" }); - const outRoot = { options: { promptCacheKey: SESSION } }; - const outNested = { options: { promptCacheKey: SESSION } }; - await root["chat.params"](hookInput(), outRoot); - await nested["chat.params"](hookInput(), outNested); - assert.equal(outRoot.options.promptCacheKey, outNested.options.promptCacheKey); + await withEnv({}, async () => { + const root = await OpenCodeContextCachePlugin({ directory: "/srv/a", worktree: "/srv/a" }); + const nested = await OpenCodeContextCachePlugin({ directory: "/srv/a/pkg/deep", worktree: "/srv/a" }); + const outRoot = { options: { promptCacheKey: SESSION } }; + const outNested = { options: { promptCacheKey: SESSION } }; + await root["chat.params"](hookInput(), outRoot); + await nested["chat.params"](hookInput(), outNested); + assert.equal(outRoot.options.promptCacheKey, outNested.options.promptCacheKey); + }); +}); + +test("a missing cache key field warns once per provider, with debug off", 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: {} }); + await hooks["chat.params"](hookInput(), { options: {} }); + await hooks["chat.params"](hookInput({ model: { providerID: "anthropic" } }), { options: {} }); + assert.equal(warnings.length, 2, "one per provider, not one per request"); + assert.match(warnings[0], /openai/); + assert.match(warnings[1], /anthropic/); + }); +}); + +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("malformed options and a missing session id produce no operator warning", 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: null }); + await hooks["chat.params"](hookInput({ sessionID: undefined }), { options: { promptCacheKey: SESSION } }); + assert.deepEqual(warnings, [], "these are debug-only states, not compatibility failures"); + }); +}); + +test("the deprecated sticky env warns once and its raw value is never logged", async () => { + await withEnv({ [STICKY_SESSION_ID_ENV_VAR]: "secret-tenant-key" }, async () => { + const warnings = []; + await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /deprecated/i); + assert.match(warnings[0], new RegExp(STICKY_SESSION_ID_ENV_VAR)); + for (const line of warnings) { + assert.equal(line.includes("secret-tenant-key"), false, "raw override must never be logged"); + } + }); +}); + +test("an unrecognised scope warns once", async () => { + await withEnv({ [SCOPE_ENV_VAR]: "sessions" }, async () => { + const warnings = []; + await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /sessions/); + assert.match(warnings[0], /worktree/); + }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `npm test` -Expected: FAIL with `does not provide an export named 'getUsername'`. - -- [ ] **Step 3: Append the factory** +Expected: FAIL. The first assertion to break is `the hook applies the exact digest of user@host:worktree`, because the Task 1 hook is a deliberate no-op. -```js -export function getUsername(env = process.env) { - try { - const info = userInfo(); - 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"; -} +- [ ] **Step 3: Replace the factory** -export function safeHostname() { - try { - return hostname() || "unknown-host"; - } catch { - return "unknown-host"; - } -} +Replace the whole `OpenCodeContextCachePlugin` definition with: -export const OpenCodeContextCachePlugin = async (input = {}) => { +```js +export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { const env = process.env; - const logger = createLogger({ env }); + const logger = createLogger({ env, warn: typeof options?.warn === "function" ? options.warn : undefined }); const resolved = resolveCacheKey({ env, + options, worktree: input?.worktree, directory: input?.directory, - user: getUsername(env), + user: getUsername({ env }), host: safeHostname(), }); - if (!resolved) { - logger.debug("no stable cache key resolved; leaving opencode's session default in place"); - } else { + 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.`, + ); + } + + if (!resolved) logger.debug("no stable cache key resolved; leaving opencode's session default in place"); + else { logger.debug( `cache key source=${resolved.source} hashed=${resolved.hashed}`, // Never log the raw value of an operator-supplied override: it may carry @@ -845,73 +1181,71 @@ export const OpenCodeContextCachePlugin = async (input = {}) => { return { "chat.params": async (hookInput, output) => { if (!resolved) return; - const provider = hookInput?.model?.providerID ?? hookInput?.provider?.info?.id ?? "unknown"; + // Everything, including reading the provider label off possibly hostile + // input, sits inside the try. A cache optimization must never be able to + // fail the user's request. + let provider = "unknown"; try { - const outcome = applyCacheKey(output, resolved.value, hookInput?.sessionID); - if (outcome === "applied") { - logger.debug(`applied cache key for provider=${provider}`); - return; + provider = hookInput?.model?.providerID ?? hookInput?.provider?.info?.id ?? "unknown"; + const { appliedFields, foreignFields, reason } = applyCacheKey(output, resolved.value, hookInput?.sessionID); + + 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 (outcome === "foreign") { + if (reason === "no-fields") { logger.warnOnce( - `foreign:${provider}`, - `provider ${provider} already carries a prompt cache key this plugin did not set; leaving it unchanged`, + `absent:${provider}`, + `provider ${provider} exposes no prompt cache key field, so none was applied. ` + + "This is expected for providers that do not support one; if it used to work, " + + "opencode may have renamed the field.", ); return; } - logger.warnOnce( - `absent:${provider}`, - `provider ${provider} exposes no prompt cache key field, so none was applied. ` + - "This is expected for providers that do not support one; if this provider used to work, " + - "opencode may have renamed the field.", + logger.debug( + `provider=${provider} applied=[${appliedFields.join(",")}] ` + + `foreign=[${foreignFields.join(",")}] reason=${reason ?? "none"}`, ); } catch (error) { - // A cache optimization must never fail the user's request. logger.warnOnce(`error:${provider}`, `unexpected error applying cache key: ${error?.stack ?? error}`); } }, }; }; - -/** Kept so existing configs importing the old name keep working. */ -export const EnhancedCachePlugin = OpenCodeContextCachePlugin; - -export default OpenCodeContextCachePlugin; ``` - [ ] **Step 4: Run the test to verify it passes** Run: `npm test` -Expected: PASS, 42 tests total. +Expected: PASS, 57 tests total. -- [ ] **Step 5: Verify no header writing survives anywhere in the file** +- [ ] **Step 5: Verify no header writing survives** Run: `grep -nE "x-session-id|session_id|conversation_id|model\.headers|x-session-affinity" plugins/opencode-context-cache.mjs` -Expected: no output. If anything matches, remove it. +Expected: no output. - [ ] **Step 6: Commit** ```bash -git add plugins/opencode-context-cache.mjs test/plugin-hook.test.mjs -git commit -m "feat: resolve the key once per plugin instance from PluginInput +git add plugins/opencode-context-cache.mjs test/unit/plugin-hook.test.mjs +git commit -m "feat: apply the resolved cache key and report outcomes -Drops all conversation-identity header writes and the module-level -singletons, so two projects served by one process no longer share a key." +Wires provenance-checked application into chat.params, warns once per +provider on a missing or foreign field, and keeps debug-only states out +of the operator channel." ``` --- -### Task 5: Opt-in integration test against a real opencode +### Task 5: Opt-in opencode contract probe -**Files:** -- Create: `test/integration/probe-plugin.mjs` -- Create: `test/integration/lifecycle.test.mjs` - -**Interfaces:** -- Consumes: nothing from the plugin under test; it asserts the opencode contract the plugin depends on. -- Produces: nothing consumed by later tasks. +This is a **contract probe, not a red-green task**: it asserts facts about opencode that the design depends on and that no product change of ours can affect. It may be green the moment it is written. It exists as a compatibility gate: the installed plugin types are 1.18.21 while the binary is 1.18.25, so this design was verified against compiled behavior rather than a published contract. Run it before upgrading opencode. -This is the compatibility gate. The installed plugin types are 1.18.21 while the binary is 1.18.25, so the behavior this design rests on is verified against compiled code, not a published contract. Run this before upgrading opencode. +**Files:** +- Create: `test/integration/probe-plugin.mjs`, `test/integration/plugin-input-contract.test.mjs` - [ ] **Step 1: Create the probe fixture** @@ -942,51 +1276,50 @@ export const ProbePlugin = async (input) => { export default ProbePlugin; ``` -- [ ] **Step 2: Write the failing test** +- [ ] **Step 2: Write the contract test** -Create `test/integration/lifecycle.test.mjs`: +Create `test/integration/plugin-input-contract.test.mjs`. Each test gets its own temp root, its own probe file and an ephemeral port; startup is polled rather than slept on; shutdown is awaited in a `finally` and escalates to `SIGKILL`. ```js -import { after, test } from "node:test"; +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, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, 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"; -const HERE = dirname(fileURLToPath(import.meta.url)); - -function findOpencode() { - const candidates = [ - process.env.OPENCODE_BIN, - join(process.env.HOME ?? "", ".opencode", "bin", "opencode"), - ].filter(Boolean); - return candidates.find((p) => existsSync(p)) ?? null; -} +import { resolveCacheKey, getUsername, safeHostname } from "../../plugins/opencode-context-cache.mjs"; -const BIN = findOpencode(); +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"; -const root = mkdtempSync(join(tmpdir(), "ctx-cache-it-")); -const probeOut = join(root, "probe.jsonl"); -const servers = []; - -after(() => { - for (const s of servers) s.kill("SIGTERM"); -}); +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(name) { +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", + }; execFileSync("git", ["init", "-q", dir]); - execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", "init"], { - env: { - ...process.env, - GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@e", - GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@e", - }, - }); + execFileSync("git", ["-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"), @@ -995,91 +1328,143 @@ function makeProject(name) { return dir; } -async function serveAndProbe(port, directories, cwd) { +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 = 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: probeOut }, - stdio: "ignore", + env: { ...process.env, CONTEXT_CACHE_PROBE_OUT: out }, + stdio: ["ignore", "ignore", "pipe"], }); - servers.push(child); - await new Promise((r) => setTimeout(r, 8000)); - for (const dir of directories) { - await fetch(`http://127.0.0.1:${port}/config`, { - headers: { "x-opencode-directory": encodeURIComponent(dir) }, - }).catch(() => {}); + 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(1000); + 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 }); } - await new Promise((r) => setTimeout(r, 3000)); - child.kill("SIGTERM"); - return readFileSync(probeOut, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); } test("one server process gives each project its own PluginInput", { skip }, async () => { - const a = makeProject("alpha"); - const b = makeProject("beta"); - // Start the server from a directory that is neither project, so any - // implementation reading process.cwd() is demonstrably wrong. - const records = await serveAndProbe(47901, [a, b], root); - - const forA = records.find((r) => r.worktree === a); - const forB = records.find((r) => r.worktree === b); - - assert.ok(forA, "expected a plugin invocation for project alpha"); - assert.ok(forB, "expected a plugin invocation for project beta"); - assert.notEqual(forA.worktree, forB.worktree); - assert.equal(forA.cwd, forB.cwd, "both invocations share one process cwd"); - assert.notEqual(forA.cwd, forA.worktree, "process.cwd() is not the project path"); + const root = 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); + + const forA = records.filter((r) => r.worktree === a); + const forB = records.filter((r) => r.worktree === b); + assert.equal(forA.length, 1, "expected exactly one plugin invocation for alpha"); + assert.equal(forB.length, 1, "expected exactly one 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. + const keyFor = (r) => + resolveCacheKey({ + env: {}, worktree: r.worktree, directory: r.directory, + user: getUsername({ env: process.env }), host: safeHostname(), + }).value; + assert.notEqual(keyFor(forA[0]), keyFor(forB[0])); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); -test("PluginInput still exposes worktree as the VCS root", { skip }, async () => { - const project = makeProject("gamma"); - const nested = join(project, "pkg", "deep"); - const records = await serveAndProbe(47902, [nested], nested); - const record = records.find((r) => r.directory === nested); - - assert.ok(record, "expected a plugin invocation for the nested directory"); - assert.equal(record.hasWorktree, true, "PluginInput.worktree must exist"); - assert.equal(record.worktree, project, "worktree must be the git root, not the cwd"); - assert.equal(record.vcs, "git"); +test("worktree is the VCS root, and a nested session shares the root's key", { skip }, async () => { + const root = 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); + + const atRoot = records.find((r) => r.directory === project); + const atNested = records.find((r) => 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"); + + const keyFor = (r) => + resolveCacheKey({ + env: {}, worktree: r.worktree, directory: r.directory, + user: getUsername({ env: process.env }), host: safeHostname(), + }).value; + assert.equal(keyFor(atRoot), keyFor(atNested), "a nested session must reuse the worktree key"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); ``` - [ ] **Step 3: Run the integration suite** Run: `npm run test:integration` -Expected: PASS with 2 tests when an opencode binary is present; both reported as skipped otherwise. +Expected: PASS with 2 tests when an opencode binary is present; both reported as skipped otherwise. It may be green on the first run - that is correct for a contract probe. -- [ ] **Step 4: Confirm the unit suite did not pick these up** +- [ ] **Step 4: Confirm the unit suite is unaffected** Run: `npm test` -Expected: still 42 tests. `test/*.test.mjs` must not match `test/integration/`. +Expected: still 57 tests. The `test` script names files explicitly, so integration cannot leak in. - [ ] **Step 5: Commit** ```bash git add test/integration -git commit -m "test: add an opt-in opencode lifecycle compatibility gate +git commit -m "test: add an opt-in opencode contract probe -Asserts the two contracts this design rests on - one plugin instance per -project, and worktree as the VCS root - against the real binary. Skips -when none is installed, so CI stays green." +Asserts the two facts this design rests on - one plugin instance per +project, and worktree as the VCS root - against the real binary, and +checks the resolver turns them into distinct keys. Skips when no binary +is installed, so CI stays green." ``` --- -### Task 6: CI and README +### Task 6: CI, README, changelog **Files:** -- Create: `.github/workflows/test.yml` +- Create: `.github/workflows/test.yml`, `CHANGELOG.md` - Modify: `README.md` (rewrite) -**Interfaces:** -- Consumes: the `test` script from Task 1. -- Produces: nothing. - - [ ] **Step 1: Create the CI workflow** -Create `.github/workflows/test.yml`: - ```yaml name: test @@ -1104,48 +1489,98 @@ jobs: No install step: the project has no dependencies. -- [ ] **Step 2: Rewrite `README.md`** +- [ ] **Step 2: Create `CHANGELOG.md`** + +```markdown +# 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. + +### 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. +- `prompt_cache_key` (deepinfra, cerebras) is now handled; previously only the + camelCase spelling was written, so those providers were unaffected. +- The key is replaced only when it still holds opencode's own session-ID + default, so an explicit operator setting 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. +- The debug log moved out of the plugin directory to + `$XDG_STATE_HOME/opencode/context-cache.log`. + +### Added + +- `OPENCODE_CONTEXT_CACHE_SCOPE` (`worktree` default, `directory`, `session`). + `session` is a full opt-out. +- `OPENCODE_CONTEXT_CACHE_LOG` to relocate the debug log. +- Always-on, deduplicated operator warnings for compatibility failures. +- A test suite and CI. +``` + +- [ ] **Step 3: Rewrite `README.md`** Replace the file. Required content, in order: -1. **Title and one-paragraph summary.** State plainly that the plugin sets a prompt cache key stable across sessions in one git worktree, replacing opencode's per-session default. -2. **A "Breaking change in 0.2.0" section, near the top.** State that the plugin no longer writes `x-session-id`, `conversation_id` or `session_id`; that opencode core already sends `x-session-affinity` and `X-Session-Id` derived from the real session ID; and that a gateway relying on the underscore names must be reconfigured to read core's headers. Give the reason in one sentence: those header names identify a conversation, and a project-stable value in them is wrong, because on opencode's OpenAI/Codex path `x-session-affinity` keys a WebSocket pool whose `busy` and `fallback` state would then be shared by every concurrent session in the project. -3. **How it works.** The three-line version: core sets the cache key to the session ID; this plugin replaces that value, and only that value, with `sha256(user@host:)`. +1. **Title and one-paragraph summary.** The plugin sets a prompt cache key stable across sessions in one git worktree, replacing opencode's per-session default. +2. **A "Breaking change in 0.2.0" section near the top**, summarising the changelog entry above and linking to `CHANGELOG.md`. +3. **How it works.** Core sets the cache key to the session ID; this plugin replaces that value, and only that value, with `sha256(user@host:)`. 4. **Install.** Both routes: npm identifier in the `plugin` array, and copying the single file. Keep the existing warning that the `plugin` entry is required. -5. **Configuration.** A table of all five env vars with defaults, plus `OPENCODE_CONTEXT_CACHE_SCOPE` values (`worktree` default, `directory`, `session`). -6. **Provider support.** Honest: this sets OpenAI-family `promptCacheKey` / `prompt_cache_key`. Anthropic uses `cache_control` breakpoints and ignores a cache key, so the plugin is inert there and says so once in the log. Remove every "works with ALL providers" claim. +5. **Configuration.** A table of all five env vars with defaults, plus the `opencode.jsonc` options form (`["opencode-context-cache", { "scope": "directory" }]`) and the precedence rule: env beats options beats defaults, except `scope: session`, which disables the key outright. +6. **Provider support.** Honest: this sets OpenAI-family `promptCacheKey` / `prompt_cache_key`. Anthropic uses `cache_control` breakpoints and ignores a cache key, so the plugin is inert there and says so once **on stderr**. Remove every "works with ALL providers" claim. 7. **Hashing.** Describe as keeping the local username, hostname and path off the wire. Do not call it privacy: the pre-image space is small enough to enumerate. -8. **Observed impact.** Keep the 97.99% figure but label it explicitly as a single anecdotal run on one provider with no controlled baseline. -9. **Troubleshooting.** Debug flag, log location, and what the two operator warnings mean. +8. **Observed impact.** Keep the 97.99% figure, labelled explicitly as a single anecdotal run on one provider with no controlled baseline. +9. **Troubleshooting.** Debug flag, log location, and what each operator warning means. Delete: the `isSha256Hex` digest-detection bullet, the five-level precedence list (it is three levels now), and every reference to setting session headers. -- [ ] **Step 3: Verify no stale claims survive** +- [ ] **Step 4: Verify no stale claims survive** -Run: `grep -niE "all providers|privacy|sticky session header|conversation_id|x-session-id" README.md` -Expected: matches only inside the "Breaking change" section, where those names are named in order to say they were removed. +Run: `grep -niE "all providers|privacy|sticky session header" README.md` +Expected: no output. -- [ ] **Step 4: Run the full suite one more time** +Run: `grep -niE "conversation_id|x-session-id" README.md` +Expected: matches only inside the "Breaking change" section. + +- [ ] **Step 5: Run everything one last time** Run: `npm test && npm run test:integration` -Expected: 42 unit tests pass; integration passes or skips. +Expected: 57 unit tests pass; integration passes or skips. -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```bash -git add .github/workflows/test.yml README.md -git commit -m "docs: rewrite README for the new behavior, add CI +git add .github/workflows/test.yml README.md CHANGELOG.md +git commit -m "docs: rewrite README, add changelog and CI -Documents the header removal as a breaking change, drops the all-providers -and privacy claims, and labels the cache hit figure as a single run." +Documents the header removal as a breaking change with migration +guidance, drops the all-providers and privacy claims, and labels the +cache hit figure as a single uncontrolled run." ``` --- ## Self-Review -**Spec coverage.** Section 3.1 shape -> Task 1 Step 5. 3.2 resolution, scope, override bounds -> Task 1. 3.3 provenance and replacement -> Task 2. 3.4 hook wiring -> Task 4. 3.5 logging and warning channel -> Task 3. 3.6 error handling table -> Tasks 2 and 4 (every row has a test). Section 4 unit tests -> Tasks 1-4; hook-level tests -> Task 4; integration -> Task 5. Section 5 deliverables -> all six tasks. No gaps. +**Spec coverage.** 3.1 shape -> Task 1 Step 5. 3.2 resolution, scope, override bounds -> Task 1. 3.3 provenance and replacement -> Task 2. 3.4 hook wiring -> Task 4. 3.5 logging and warning channel -> Tasks 1 and 3. 3.6 error handling -> every row now has a test, listed below. Section 4 unit tests -> Tasks 1-4; hook-level -> Task 4; integration -> Task 5. Section 5 deliverables -> all six tasks, plus `CHANGELOG.md`, which section 6 of the spec requires for the upstream disclosure and the first draft omitted. + +**Spec 3.6 error table, row by row.** `hostname()` throws -> Task 1, `safeHostname falls back`. `userInfo()` throws -> Task 1, `getUsername falls back`. Both paths empty -> Task 1, `no usable path yields null`. Options absent/not object -> Task 2 `invalid-options`, Task 4 no-warning test. Neither field present -> Task 2 `no-fields`, Task 4 dedup warning test. Foreign value -> Task 2, Task 4 mixed-conflict test. Value `undefined` -> Task 2. Missing `sessionID` -> Task 2 `missing-session`, Task 4 no-change and no-warning tests. Unsafe override -> Task 1, both overlong and non-printable. Unwritable log -> Task 3, both write and mkdir failure. + +**Placeholder scan.** Every code step carries complete code. Task 6 Step 3 specifies README content as required sections; each item states what it must say and what must be deleted, with two grep gates in Step 4. -**Placeholder scan.** Every code step carries complete code. Task 6 Step 2 specifies README content as a numbered list of required sections rather than full prose; that is a documentation step, and each item states exactly what it must say and what must be deleted, with a grep gate in Step 3 to verify. +**Type consistency.** `resolveCacheKey` returns `{raw, value, source, hashed, sensitive, deprecated, unknownScope}` in Task 1 and Task 4 reads exactly those. `applyCacheKey(output, value, sessionID)` returns `{appliedFields, foreignFields, reason}` in Task 2 and is destructured for exactly those in Task 4. `createLogger` exposes `enabled`, `path`, `debug` from Task 1 and gains `warnOnce` in Task 3; Task 4 uses only those four. `getUsername({env, readUserInfo})` and `safeHostname({readHostname})` take option bags in Task 1 and are called that way in Tasks 4 and 5. `readEnv`, `usablePath` and `safeJson` are module-private, defined once each in Task 1. -**Type consistency.** `resolveCacheKey` returns `{raw, value, source, hashed, sensitive}` in Task 1 and is destructured for exactly those fields in Task 4. `applyCacheKey(output, value, sessionID)` takes the output object, not `options`, in both Task 2 and Task 4. `createLogger` exposes `enabled`, `path`, `debug`, `warnOnce` in Task 3 and Task 4 uses only those. `trimmedEnv` and `safeJson` are module-private and defined once each, in Tasks 1 and 3 respectively. +**Loadability at every commit.** Task 1 ships an inert but valid plugin; Tasks 2 and 3 only add exports; Task 4 replaces the hook body. Task 1 Step 7 checks this explicitly. diff --git a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md index 5f83541..15f2616 100644 --- a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md +++ b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md @@ -1,7 +1,7 @@ # Design: stable prompt cache key, without conversation-identity headers Date: 2026-08-30 -Status: approved; revised after Codex adversarial review (see section 8) +Status: approved; revised after two Codex adversarial reviews (see section 8) Branch: `rework-cache-key-and-headers` ## 1. Problem @@ -241,11 +241,19 @@ spec overclaimed by calling it "never a correctness bug". Two qualifications: and this design cannot claim universal safety across every backend and relay implementing the field. -Mitigation: scope is configurable via `OPENCODE_CONTEXT_CACHE_SCOPE` -(`worktree` | `directory` | `session`), defaulting to `worktree`. Operators +Mitigation: scope is configurable via `OPENCODE_CONTEXT_CACHE_SCOPE`, or the +`scope` key of the plugin's `options` object in `opencode.jsonc`, taking +`worktree` | `directory` | `session` and defaulting to `worktree`. Operators running many concurrent divergent sessions, or a provider with lookup-key -semantics, can narrow it without patching the plugin. `session` resolves to -`null` so core's own per-session default stands untouched. +semantics, can narrow it without patching the plugin. + +`session` resolves to `null` so core's own per-session default stands +untouched, and **it is parsed before the explicit overrides, so it beats them**. +It is the safety valve for a provider whose cache key carries stronger +semantics than routing, and a safety valve a forgotten stale +`OPENCODE_PROMPT_CACHE_KEY` can silently defeat is not one. An unrecognised +scope value warns once and falls back to `worktree` rather than silently +widening scope. Hashing is retained for the auto-generated key only, on the honest rationale that it keeps the local username, hostname and home directory layout from @@ -254,9 +262,19 @@ reaching a third-party gateway. ### 3.3 Applying the key ``` -applyCacheKey(output, key, sessionID) -> "applied" | "absent" | "foreign" +applyCacheKey(output, key, sessionID) + -> { appliedFields: string[], + foreignFields: string[], + reason: "invalid-options" | "missing-session" | "no-fields" | null } ``` +A three-value return cannot express "replaced one field and found the other +foreign", and collapsing malformed options, a missing session ID and a genuinely +absent field into one value makes the operator warning lie about which happened. +The result is therefore a record: only `reason === "no-fields"` and a non-empty +`foreignFields` warrant an operator warning; `invalid-options` and +`missing-session` are debug-only. + Replace a cache-key field **only when its current value is provably the one core just put there**. Core's default is the session ID: @@ -272,9 +290,12 @@ so the provenance test is exact: const isCoreDefault = (v) => v === sessionID || v === stripSesPrefix(sessionID); ``` -For each of `promptCacheKey` and `prompt_cache_key`: if absent, skip. If present -and `isCoreDefault`, replace. If present and anything else, leave it alone and -report `foreign`. +For each of `promptCacheKey` and `prompt_cache_key` independently: if absent, +skip. If present and `isCoreDefault`, replace and record it in `appliedFields`. +If present and anything else, leave it alone and record it in `foreignFields`. +Per-field accounting matters: a request carrying core's value in one spelling and +a third party's in the other is a real conflict, and reporting only an aggregate +would hide it behind the successful half. An earlier draft used bare presence (`"promptCacheKey" in options`) as the signal. That is wrong, and the adversarial review was right to reject it: @@ -558,3 +579,48 @@ seven-point attack list. Recorded here so a later round does not re-derive it. - *Provider-specific cache scope with a prompt-version component.* Declined as over-engineering for this plugin's purpose, and it reintroduces the provider table that 3.3 exists to avoid. The scope env var covers the real need. + +### 8.1 Second review: the implementation plan + +Codex reviewed the plan (job `task-mtfmc8hc-gsxi9e`, effort high). It confirmed +the first round's findings were folded in, and found that three of them were +folded in *nominally* rather than correctly. Folded in: + +- **The tri-state return could not represent the states this spec distinguishes.** + Malformed options, a missing session ID and a genuinely absent field all + returned `"absent"`, so the operator warning claimed a provider field had + disappeared when the real cause was something else. Section 3.3 now returns a + record. This is the same class of defect as the original presence check: an + API too narrow to carry the distinction the design depends on. +- **A mixed core/foreign conflict was hidden**, and the plan's test blessed it. + Per-field accounting added. +- **`scope: session` did not actually opt out**, because explicit overrides were + parsed first, contradicting this spec's own unqualified claim. Resolved in + 3.2 by parsing scope first. +- **"The hook never throws" was not implemented**: the provider label was read + outside the `try`, and the warning sink itself could throw, including from + inside the catch handler. +- **The promised deprecation notice for `OPENCODE_STICKY_SESSION_ID` existed + only in prose**, with no code and no test. +- **Tasks 1-3 each committed a product that would not load.** The plan now + requires every commit to leave a loadable plugin, with an explicit check. +- **`node --test test/*.test.mjs` can pass with zero tests.** Codex verified on + Node 24 that an unmatched quoted glob exits 0 having run nothing, and the glob + does not expand on Windows at all. Test files are now listed explicitly. +- Smaller: paths were being trimmed (a path may legitimately end in whitespace); + `withEnv` restored the environment at the first `await` rather than after the + body; temp directories were never cleaned up; the integration suite used fixed + ports, fixed sleeps, a shared output file, and never awaited process exit; and + the plan's claim that every row of the 3.6 error table had a test was false. + +Also folded in from that round: the plugin `options` argument, which opencode +really does pass as the second parameter (`J(Z, $.options)`), is now honoured +with env taking precedence over it; and `CHANGELOG.md` is a required deliverable +so the breaking header removal is disclosed somewhere durable rather than only +in a PR description. + +Not taken: rewriting the integration probe to drive a live provider. It asserts +the opencode-side contract plus the key our resolver derives from it, which is +the part that can break under an opencode upgrade; asserting provider-side cache +behavior needs credentials and a controlled baseline, and belongs to the +measurement work in section 7, not to a compatibility gate. From f045730d744de142bdae7e69011dacfe5bdc6d9e Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 18:57:47 +0900 Subject: [PATCH 05/19] feat: resolve a worktree-scoped prompt cache key Replaces the process.cwd() key with one derived from PluginInput, bounds explicit overrides to what a provider will accept, and makes scope=session a hard opt-out. The plugin loads and is inert; applying the key follows. Adds a pretest guard because node --test silently ignores a named file that does not exist and still exits 0, which would let a renamed suite vanish from CI unnoticed. --- .gitignore | 3 + package.json | 31 ++ plugins/opencode-context-cache.mjs | 448 +++++++++++------------------ scripts/check-test-files.mjs | 40 +++ test/unit/cache-key.test.mjs | 165 +++++++++++ 5 files changed, 400 insertions(+), 287 deletions(-) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 scripts/check-test-files.mjs create mode 100644 test/unit/cache-key.test.mjs 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/package.json b/package.json new file mode 100644 index 0000000..719f251 --- /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/cache-key.test.mjs", + "pretest:integration": "node scripts/check-test-files.mjs test:integration", + "test:integration": "node --test 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..1a8f49f 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -1,337 +1,211 @@ /** * 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"); +export const PROMPT_CACHE_KEY_ENV_VAR = "OPENCODE_PROMPT_CACHE_KEY"; +export const STICKY_SESSION_ID_ENV_VAR = "OPENCODE_STICKY_SESSION_ID"; +export const SCOPE_ENV_VAR = "OPENCODE_CONTEXT_CACHE_SCOPE"; +export const DEBUG_ENV_VAR = "OPENCODE_CONTEXT_CACHE_DEBUG"; +export const LOG_PATH_ENV_VAR = "OPENCODE_CONTEXT_CACHE_LOG"; -class DebugLogger { - constructor(logFilePath) { - this.logFilePath = logFilePath; - this.debugEnabled = null; - this.loggedInputStructure = false; - this.ensureLogDirectory(); - } +/** OpenAI is reported to cap prompt_cache_key at 64 characters; a sha256 hex digest is exactly 64. */ +export const MAX_CACHE_KEY_LENGTH = 64; - ensureLogDirectory() { - try { - const logDir = dirname(this.logFilePath); - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); - } - } catch { - // Ignore errors, fallback will use console. - } - } - - isEnabled() { - if (this.debugEnabled === null) { - this.debugEnabled = - process?.env?.[CACHE_DEBUG_ENV_VAR] === "1" || - process?.env?.[CACHE_DEBUG_ENV_VAR] === "true"; - } - return this.debugEnabled; - } - - toLogString(value) { - if (typeof value !== "object" || value === null) { - return String(value); - } - - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } - - log(...args) { - if (!this.isEnabled()) return; - - const timestamp = new Date().toISOString(); - const pid = process.pid; - const message = args.map((arg) => this.toLogString(arg)).join(" "); - - // 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`; - - 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); - } - } +export const SCOPES = ["worktree", "directory", "session"]; - logInputStructureOnce(input) { - if (this.loggedInputStructure) return; - this.loggedInputStructure = true; +const PRINTABLE_ASCII = /^[\x20-\x7E]+$/; - 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); - } +export function sha256(value) { + return createHash("sha256").update(value, "utf8").digest("hex"); } -class CacheKeyResolver { - constructor(logger) { - this.logger = logger; - } - - sha256(input) { - return createHash("sha256").update(input, "utf8").digest("hex"); - } +export function fingerprint(value) { + return sha256(value).slice(0, 8); +} - 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 readEnv(env, name) { + const value = env?.[name]; + return typeof value === "string" ? value.trim() : ""; +} - getTrimmedEnv(name) { - const value = process?.env?.[name]; - return typeof value === "string" ? value.trim() : ""; - } +/** Paths are used verbatim: only a whitespace-only path counts as absent. */ +function usablePath(value) { + return typeof value === "string" && value.trim() !== "" ? value : ""; +} - getUsername() { - try { - const ui = userInfo(); - if (ui && ui.username) { - return ui.username; - } - } catch { - // userInfo may fail in restricted environments. - } +export function isSafeOverride(value) { + return value.length <= MAX_CACHE_KEY_LENGTH && PRINTABLE_ASCII.test(value); +} - return ( - process?.env?.USER || - process?.env?.USERNAME || - process?.env?.LOGNAME || - "unknown" - ); - } +export 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 }; +} - getUserHostDirectoryKey() { - try { - const user = this.getUsername(); - const host = hostname(); - const cwd = process.cwd(); - return `${user}@${host}:${cwd}`; - } catch { - return null; - } - } +/** + * Mirrors core's own project-path guard: + * vcs === "git" && worktree !== "/" ? worktree : directory + * A degenerate "/" worktree would otherwise collapse every project on the + * machine onto a single key. + */ +export 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; +} - getSessionIdFromHeaders(input) { - const headers = - input?.model?.headers && typeof input.model.headers === "object" - ? input.model.headers - : {}; +export 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( + readEnv(env, SCOPE_ENV_VAR) || (typeof options?.scope === "string" ? options.scope : ""), + ); + if (scope === "session") return null; - const value = SESSION_ID_HEADER_NAMES.map((key) => headers[key]) - .find((v) => typeof v === "string" && v.trim()) - ?.trim?.(); + 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 !== ""); - return value || null; + 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 }; } - resolveCacheKey(input) { - let rawKey = null; - let source = null; - let alreadyHashed = false; + const path = selectScopePath({ scope, worktree, directory }); + if (!path) return null; - // 1) Explicit env override. - const promptCacheKey = this.getTrimmedEnv(PROMPT_CACHE_KEY_ENV_VAR); - if (promptCacheKey) { - rawKey = promptCacheKey; - source = PROMPT_CACHE_KEY_ENV_VAR; - } - - // 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; - } - } - - // 3) Preferred stable default. - if (!rawKey) { - const userHostDirKey = this.getUserHostDirectoryKey(); - if (userHostDirKey) { - rawKey = userHostDirKey; - source = "user@host:directory"; - } - } - - // 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); - } - } - - // 5) OpenCode session fallback. - if (!rawKey) { - const sessionID = typeof input?.sessionID === "string" ? input.sessionID : ""; - if (sessionID) { - rawKey = sessionID; - source = "opencode sessionID"; - } - } - - if (!rawKey) { - this.logger.log("No stable cache key found"); - return null; - } - - const hashedKey = alreadyHashed ? rawKey : this.sha256(rawKey); - - if (alreadyHashed) { - this.logger.log("Cache key already looks hashed; skipping sha256"); - } - - this.logger.log(`Using cache key from ${source}`); - this.logger.log(` Raw: ${rawKey}`); - this.logger.log(` Hash: ${hashedKey}`); + const raw = `${user}@${host}:${path}`; + return { + raw, + value: sha256(raw), + source: `user@host:${scope}`, + hashed: true, + sensitive: false, + deprecated: false, + unknownScope, + }; +} - return { raw: rawKey, hashed: hashedKey }; +export 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"; } -class CacheKeyApplier { - constructor(logger) { - this.logger = logger; +export function safeHostname({ readHostname = hostname } = {}) { + try { + return readHostname() || "unknown-host"; + } catch { + return "unknown-host"; } +} - applyPromptCacheKey(output, cacheKey) { - const existingOutputOptions = - output?.options && typeof output.options === "object" ? output.options : {}; +export function defaultLogPath(env = {}, home = homedir()) { + const explicit = readEnv(env, LOG_PATH_ENV_VAR); + if (explicit) return explicit; + const stateHome = readEnv(env, "XDG_STATE_HOME") || join(home, ".local", "state"); + return join(stateHome, "opencode", "context-cache.log"); +} - output.options = { - ...existingOutputOptions, - promptCacheKey: cacheKey, - }; +function safeJson(value) { + try { + return JSON.stringify(value); + } catch { + return String(value); } +} - 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"; - } +export 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); + let fileUsable = true; + let dirReady = false; - this.logger.log("Set final cache key (hashed):", cacheKey); - return; + function emit(message) { + try { + warn(`[context-cache] ${message}`); + } catch { + // A failing warning sink must never escape into the request path. } - - 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); - } + return { + 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}: ${error?.message ?? error}; debug logging disabled`); + } + }, + }; } -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; - } - - this.keyApplier.apply(input, output, cacheKeyInfo.hashed); +export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { + const env = process.env; + const logger = createLogger({ env }); + const resolved = resolveCacheKey({ + env, + options, + worktree: input?.worktree, + directory: input?.directory, + user: getUsername({ env }), + host: safeHostname(), + }); + + if (!resolved) logger.debug("no stable cache key resolved; leaving opencode's session default in place"); + 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}`, + ); } -} - -const logger = new DebugLogger(LOG_FILE_PATH); -const keyResolver = new CacheKeyResolver(logger); -const keyApplier = new CacheKeyApplier(logger); -const runtime = new ContextCachePluginRuntime({ - logger, - keyResolver, - keyApplier, -}); - -export const OpenCodeContextCachePlugin = async () => { - runtime.initialize(); return { - "chat.params": async (input, output) => { - runtime.handleChatParams(input, output); - }, + // Applying the key is wired in Task 4. This keeps the plugin loadable. + "chat.params": async () => {}, }; }; -// Backward-compatible export alias. +/** Kept so existing configs importing the old name keep working. */ export const EnhancedCachePlugin = OpenCodeContextCachePlugin; 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/unit/cache-key.test.mjs b/test/unit/cache-key.test.mjs new file mode 100644 index 0000000..36caf23 --- /dev/null +++ b/test/unit/cache-key.test.mjs @@ -0,0 +1,165 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import { + MAX_CACHE_KEY_LENGTH, + PROMPT_CACHE_KEY_ENV_VAR, + SCOPE_ENV_VAR, + STICKY_SESSION_ID_ENV_VAR, + getUsername, + isSafeOverride, + parseScope, + resolveCacheKey, + safeHostname, + selectScopePath, + sha256, +} from "../../plugins/opencode-context-cache.mjs"; + +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"); +}); From 76c9e9294136d39db4b8f92e8b3d430f793eae99 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 18:58:25 +0900 Subject: [PATCH 06/19] feat: replace the cache key only when it is core's own default Field presence does not prove provenance; matching opencode's session ID does. Reports applied and foreign fields separately so a mixed conflict is visible rather than silently half-applied. --- package.json | 2 +- plugins/opencode-context-cache.mjs | 49 +++++++++++++++ test/unit/apply-cache-key.test.mjs | 97 ++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 test/unit/apply-cache-key.test.mjs diff --git a/package.json b/package.json index 719f251..e6f0d37 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ ], "scripts": { "pretest": "node scripts/check-test-files.mjs test", - "test": "node --test test/unit/cache-key.test.mjs", + "test": "node --test test/unit/cache-key.test.mjs test/unit/apply-cache-key.test.mjs", "pretest:integration": "node scripts/check-test-files.mjs test:integration", "test:integration": "node --test test/integration/plugin-input-contract.test.mjs" }, diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index 1a8f49f..393ef5e 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -177,6 +177,55 @@ export function createLogger({ env = {}, filePath, write = appendFileSync, warn }; } +/** The two spellings opencode core uses, depending on provider. */ +export const CACHE_KEY_FIELDS = ["promptCacheKey", "prompt_cache_key"]; + +const SES_PREFIXED = /^ses_[0-9a-f]{64}$/; + +/** Core sends the digest without the ses_ prefix on its own zen provider path. */ +export function stripSesPrefix(sessionID) { + return SES_PREFIXED.test(sessionID) ? sessionID.slice(4) : sessionID; +} + +/** + * 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. + */ +export function applyCacheKey(output, value, sessionID) { + const options = output?.options; + if (!options || typeof options !== "object") { + return { appliedFields: [], foreignFields: [], reason: "invalid-options" }; + } + if (typeof sessionID !== "string" || sessionID === "") { + return { appliedFields: [], foreignFields: [], reason: "missing-session" }; + } + + const stripped = stripSesPrefix(sessionID); + const appliedFields = []; + const foreignFields = []; + const replacements = {}; + + for (const field of CACHE_KEY_FIELDS) { + if (!(field in options)) continue; + const current = options[field]; + if (current === sessionID || current === stripped) { + replacements[field] = value; + appliedFields.push(field); + } else { + foreignFields.push(field); + } + } + + if (appliedFields.length === 0 && foreignFields.length === 0) { + return { appliedFields, foreignFields, reason: "no-fields" }; + } + if (appliedFields.length > 0) output.options = { ...options, ...replacements }; + return { appliedFields, foreignFields, reason: null }; +} + export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { const env = process.env; const logger = createLogger({ env }); diff --git a/test/unit/apply-cache-key.test.mjs b/test/unit/apply-cache-key.test.mjs new file mode 100644 index 0000000..c05e81d --- /dev/null +++ b/test/unit/apply-cache-key.test.mjs @@ -0,0 +1,97 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { applyCacheKey, stripSesPrefix } from "../../plugins/opencode-context-cache.mjs"; + +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: [], 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"], 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("treats a present-but-undefined field as foreign, not as core's", () => { + const output = { options: { promptCacheKey: undefined } }; + const r = applyCacheKey(output, KEY, SESSION); + assert.deepEqual(r.foreignFields, ["promptCacheKey"]); + assert.equal(output.options.promptCacheKey, undefined); +}); + +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: [], 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"); +}); From a52b4fef0d9cc7158ee52dd6a3656bfdce0e9851 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 18:59:01 +0900 Subject: [PATCH 07/19] feat: add an always-on deduplicated operator warning channel Compatibility failures must not depend on the operator having already enabled debug logging. The sink is wrapped so a failing stderr cannot escape into the request path. --- package.json | 2 +- plugins/opencode-context-cache.mjs | 13 ++++ test/unit/logger.test.mjs | 109 +++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 test/unit/logger.test.mjs diff --git a/package.json b/package.json index e6f0d37..d6781c9 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ ], "scripts": { "pretest": "node scripts/check-test-files.mjs test", - "test": "node --test test/unit/cache-key.test.mjs test/unit/apply-cache-key.test.mjs", + "test": "node --test test/unit/cache-key.test.mjs test/unit/apply-cache-key.test.mjs test/unit/logger.test.mjs", "pretest:integration": "node scripts/check-test-files.mjs test:integration", "test:integration": "node --test test/integration/plugin-input-contract.test.mjs" }, diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index 393ef5e..a075aae 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -143,6 +143,7 @@ export function createLogger({ env = {}, filePath, write = appendFileSync, 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 fileUsable = true; let dirReady = false; @@ -174,6 +175,18 @@ export function createLogger({ env = {}, filePath, write = appendFileSync, warn emit(`cannot write debug log at ${path}: ${error?.message ?? error}; debug logging disabled`); } }, + + /** + * 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; + warned.add(key); + emit(message); + return true; + }, }; } diff --git a/test/unit/logger.test.mjs b/test/unit/logger.test.mjs new file mode 100644 index 0000000..9245c82 --- /dev/null +++ b/test/unit/logger.test.mjs @@ -0,0 +1,109 @@ +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 { + DEBUG_ENV_VAR, + LOG_PATH_ENV_VAR, + createLogger, + defaultLogPath, + fingerprint, +} from "../../plugins/opencode-context-cache.mjs"; + +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"); }, + }); + logger.debug("boom"); + assert.equal(logger.warnOnce("k", "m"), true); +}); + +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"); + assert.equal(logger.warnOnce("absent:openai", "first"), true); + assert.equal(logger.warnOnce("absent:openai", "again"), false); + assert.equal(logger.warnOnce("absent:anthropic", "other"), true); + assert.deepEqual(warnings, ["[context-cache] first", "[context-cache] other"]); +}); From cbc569d9e316f0c341eefe43a485198d01ab3094 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:00:12 +0900 Subject: [PATCH 08/19] feat: apply the resolved cache key and report outcomes Wires provenance-checked application into chat.params, warns once per provider on a missing or foreign field, and keeps debug-only states out of the operator channel. --- package.json | 2 +- plugins/opencode-context-cache.mjs | 52 ++++++- test/unit/plugin-hook.test.mjs | 241 +++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 test/unit/plugin-hook.test.mjs diff --git a/package.json b/package.json index d6781c9..66222eb 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ ], "scripts": { "pretest": "node scripts/check-test-files.mjs test", - "test": "node --test test/unit/cache-key.test.mjs test/unit/apply-cache-key.test.mjs test/unit/logger.test.mjs", + "test": "node --test 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/plugin-input-contract.test.mjs" }, diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index a075aae..7c4b5d7 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -241,7 +241,7 @@ export function applyCacheKey(output, value, sessionID) { export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { const env = process.env; - const logger = createLogger({ env }); + const logger = createLogger({ env, warn: typeof options?.warn === "function" ? options.warn : undefined }); const resolved = resolveCacheKey({ env, options, @@ -251,6 +251,20 @@ export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { host: safeHostname(), }); + 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.`, + ); + } + if (!resolved) logger.debug("no stable cache key resolved; leaving opencode's session default in place"); else { logger.debug( @@ -262,8 +276,40 @@ export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { } return { - // Applying the key is wired in Task 4. This keeps the plugin loadable. - "chat.params": async () => {}, + "chat.params": async (hookInput, output) => { + if (!resolved) return; + // Everything, including reading the provider label off possibly hostile + // input, sits inside the try. A cache optimization must never be able to + // fail the user's request. + let provider = "unknown"; + try { + provider = hookInput?.model?.providerID ?? hookInput?.provider?.info?.id ?? "unknown"; + const { appliedFields, foreignFields, reason } = applyCacheKey(output, resolved.value, hookInput?.sessionID); + + 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 (reason === "no-fields") { + logger.warnOnce( + `absent:${provider}`, + `provider ${provider} exposes no prompt cache key field, so none was applied. ` + + "This is expected for providers that do not support one; if it used to work, " + + "opencode may have renamed the field.", + ); + return; + } + logger.debug( + `provider=${provider} applied=[${appliedFields.join(",")}] ` + + `foreign=[${foreignFields.join(",")}] reason=${reason ?? "none"}`, + ); + } catch (error) { + logger.warnOnce(`error:${provider}`, `unexpected error applying cache key: ${error?.stack ?? error}`); + } + }, }; }; diff --git a/test/unit/plugin-hook.test.mjs b/test/unit/plugin-hook.test.mjs new file mode 100644 index 0000000..5397a7f --- /dev/null +++ b/test/unit/plugin-hook.test.mjs @@ -0,0 +1,241 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import OpenCodeContextCacheDefault, { + DEBUG_ENV_VAR, + EnhancedCachePlugin, + OpenCodeContextCachePlugin, + PROMPT_CACHE_KEY_ENV_VAR, + SCOPE_ENV_VAR, + STICKY_SESSION_ID_ENV_VAR, + getUsername, + safeHostname, +} from "../../plugins/opencode-context-cache.mjs"; + +const SESSION = "ses_" + "b".repeat(64); +const digest = (v) => createHash("sha256").update(v, "utf8").digest("hex"); + +/** Every plugin-owned env var, so an ambient value cannot silently change a result. */ +const OWNED = [PROMPT_CACHE_KEY_ENV_VAR, STICKY_SESSION_ID_ENV_VAR, SCOPE_ENV_VAR, DEBUG_ENV_VAR]; + +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; + } + } +} + +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" }); + 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" }); + 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" }); + 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" }); + const output = { options: { store: false } }; + await hooks["chat.params"](hookInput(), output); + assert.deepEqual(output.options, { store: false }); + }); +}); + +test("the hook is inert when scope disables the key", async () => { + await withEnv({ [SCOPE_ENV_VAR]: "session" }, async () => { + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + const output = { options: { promptCacheKey: SESSION } }; + await hooks["chat.params"](hookInput(), output); + assert.equal(output.options.promptCacheKey, SESSION); + }); +}); + +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" }); + 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" }); + 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" }); + const b = await OpenCodeContextCachePlugin({ directory: "/srv/b/sub", worktree: "/srv/b" }); + 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" }); + const nested = await OpenCodeContextCachePlugin({ directory: "/srv/a/pkg/deep", worktree: "/srv/a" }); + const outRoot = { options: { promptCacheKey: SESSION } }; + const outNested = { options: { promptCacheKey: SESSION } }; + await root["chat.params"](hookInput(), outRoot); + await nested["chat.params"](hookInput(), outNested); + assert.equal(outRoot.options.promptCacheKey, outNested.options.promptCacheKey); + }); +}); + +test("a missing cache key field warns once per provider, with debug off", 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: {} }); + await hooks["chat.params"](hookInput(), { options: {} }); + await hooks["chat.params"](hookInput({ model: { providerID: "anthropic" } }), { options: {} }); + assert.equal(warnings.length, 2, "one per provider, not one per request"); + assert.match(warnings[0], /openai/); + assert.match(warnings[1], /anthropic/); + }); +}); + +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("malformed options and a missing session id produce no operator warning", 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: null }); + await hooks["chat.params"](hookInput({ sessionID: undefined }), { options: { promptCacheKey: SESSION } }); + assert.deepEqual(warnings, [], "these are debug-only states, not compatibility failures"); + }); +}); + +test("the deprecated sticky env warns once and its raw value is never logged", async () => { + await withEnv({ [STICKY_SESSION_ID_ENV_VAR]: "secret-tenant-key" }, async () => { + const warnings = []; + await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /deprecated/i); + assert.match(warnings[0], new RegExp(STICKY_SESSION_ID_ENV_VAR)); + for (const line of warnings) { + assert.equal(line.includes("secret-tenant-key"), false, "raw override must never be logged"); + } + }); +}); + +test("an unrecognised scope warns once", async () => { + await withEnv({ [SCOPE_ENV_VAR]: "sessions" }, async () => { + const warnings = []; + await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /sessions/); + assert.match(warnings[0], /worktree/); + }); +}); From 01a64b6aa0dca017b6754c4b1ec0237dbd8fbde5 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:01:11 +0900 Subject: [PATCH 09/19] test: add an opt-in opencode contract probe Asserts the two facts this design rests on - one plugin instance per project, and worktree as the VCS root - against the real binary, and checks the resolver turns them into distinct keys. Skips when no binary is installed, so CI stays green. --- .../plugin-input-contract.test.mjs | 166 ++++++++++++++++++ test/integration/probe-plugin.mjs | 22 +++ 2 files changed, 188 insertions(+) create mode 100644 test/integration/plugin-input-contract.test.mjs create mode 100644 test/integration/probe-plugin.mjs diff --git a/test/integration/plugin-input-contract.test.mjs b/test/integration/plugin-input-contract.test.mjs new file mode 100644 index 0000000..8785924 --- /dev/null +++ b/test/integration/plugin-input-contract.test.mjs @@ -0,0 +1,166 @@ +/** + * 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, 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 { resolveCacheKey, getUsername, safeHostname } from "../../plugins/opencode-context-cache.mjs"; + +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", + }; + execFileSync("git", ["init", "-q", dir]); + execFileSync("git", ["-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 = 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(1000); + 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 = 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); + + const forA = records.filter((r) => r.worktree === a); + const forB = records.filter((r) => r.worktree === b); + assert.equal(forA.length, 1, "expected exactly one plugin invocation for alpha"); + assert.equal(forB.length, 1, "expected exactly one 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 = 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); + + const atRoot = records.find((r) => r.directory === project); + const atNested = records.find((r) => 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..1c50380 --- /dev/null +++ b/test/integration/probe-plugin.mjs @@ -0,0 +1,22 @@ +import { appendFileSync } from "fs"; + +const OUT = process.env.CONTEXT_CACHE_PROBE_OUT; + +export const ProbePlugin = async (input) => { + if (OUT) { + appendFileSync( + OUT, + JSON.stringify({ + directory: input?.directory, + worktree: input?.worktree, + hasWorktree: input ? "worktree" in input : false, + vcs: input?.project?.vcs ?? null, + cwd: process.cwd(), + }) + "\n", + "utf8", + ); + } + return {}; +}; + +export default ProbePlugin; From 63a2ea04a98372d31860934434e5aaf309bcab57 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:02:29 +0900 Subject: [PATCH 10/19] docs: rewrite README, add changelog and CI Documents the header removal as a breaking change with migration guidance, drops the all-providers and privacy claims, and labels the cache hit figure as a single uncontrolled run. --- .github/workflows/test.yml | 20 +++ CHANGELOG.md | 56 +++++++ README.md | 330 +++++++++++++------------------------ 3 files changed, 195 insertions(+), 211 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a649a60 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# 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 `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..93af68e 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,175 @@ # 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**. Anything + else - your own setting, a model or agent option, another plugin's value - 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. +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. -1. Put plugin file in a stable local path (example: global plugin dir): +## Install -```bash -mkdir -p ~/.config/opencode/plugins -cp plugins/opencode-context-cache.mjs ~/.config/opencode/plugins/opencode-context-cache.mjs -``` - -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) - -Before enabling this plugin, cache hits were near zero in repeated sessions. +### By copying the file -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: - -- Input cache hit rate: `164736 / 168112 = 97.99%` -- Uncached input tokens: `168112 - 164736 = 3376` (`2.01%`) -- Cached input tokens reused: `164736` - -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`) - -## Exports - -- Default export: `OpenCodeContextCachePlugin` - -## Why this plugin exists +For the global config at `~/.config/opencode/opencode.jsonc`, a `./plugins/...` +path resolves relative to `~/.config/opencode/`. -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. +**The `plugin` entry is required either way.** Dropping the file into a plugins +directory does not load it. Restart opencode after editing the config. -## Features - -- 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 - -## OpenCode loading behavior - -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. +## Configuration -## Repository layout +| 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. | -- `plugins/opencode-context-cache.mjs`: main plugin implementation +The same settings can come from the config file: -## Cache key precedence +```jsonc +{ + "plugin": [["opencode-context-cache", { "scope": "directory" }]] +} +``` -The plugin resolves the raw cache key in this order: +**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. -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` +### Choosing a scope -Then it applies: +`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. -- SHA256 hashing for normal keys -- No re-hash if the selected key already looks like a SHA256 hex digest +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). -Result: +## Provider support -- The server receives only the hashed value. -- Sticky routing headers and `promptCacheKey` stay aligned. +This sets the OpenAI-family fields `promptCacheKey` and `prompt_cache_key`. It +applies wherever opencode itself sets one: OpenAI, Azure, xAI, Mistral, Venice, +DeepInfra, Cerebras, opencode's own provider, and anything you enable with +`setCacheKey: true`. -## How it works +It does **not** apply to Anthropic, which caches via `cache_control` breakpoints +on message content and ignores a cache key entirely. With an Anthropic provider +the plugin is inert and says so once on stderr. -The plugin registers the `chat.params` hook and: +## About the hashing -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 +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. -This keeps routing and prompt cache identity 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. -## Configuration +## Observed impact -OpenCode config flags (often required for expected cache behavior): +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. -- `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`. +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. -Minimal working `opencode.jsonc` example (required explicit plugin loading + cache flags): +## Troubleshooting -```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 - } - } - } - } - } -} -``` +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. -Do not omit the `plugin` field above in this setup. +Two warnings go to stderr regardless of the debug flag, once each: -Environment variables: +- **"exposes no prompt cache key field"** - opencode placed no cache key field + for this provider. Expected for Anthropic and anything else that does not use + one. If it used to work and now does not, opencode may have renamed the field. +- **"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. -- `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). From 81668acfbfce210dbbe443910adabf4a8867f600 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:10:07 +0900 Subject: [PATCH 11/19] fix: export only the plugin factory, or opencode refuses to load it opencode walks Object.values(module) and throws 'Plugin export is not a function' on any non-function export, then invokes every distinct function export as a plugin factory. Exporting the helpers for testability made the plugin unloadable while all 57 tests stayed green; a real opencode run surfaced it. Helpers now hang off the factory as a frozen property, and export-shape.test.mjs reproduces the loader check so this cannot regress. --- package.json | 2 +- plugins/opencode-context-cache.mjs | 90 ++++++++++++++----- .../plugin-input-contract.test.mjs | 4 +- test/unit/apply-cache-key.test.mjs | 4 +- test/unit/cache-key.test.mjs | 8 +- test/unit/export-shape.test.mjs | 80 +++++++++++++++++ test/unit/logger.test.mjs | 10 +-- test/unit/plugin-hook.test.mjs | 7 +- 8 files changed, 169 insertions(+), 36 deletions(-) create mode 100644 test/unit/export-shape.test.mjs diff --git a/package.json b/package.json index 66222eb..6499a00 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ ], "scripts": { "pretest": "node scripts/check-test-files.mjs test", - "test": "node --test test/unit/cache-key.test.mjs test/unit/apply-cache-key.test.mjs test/unit/logger.test.mjs test/unit/plugin-hook.test.mjs", + "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/plugin-input-contract.test.mjs" }, diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index 7c4b5d7..2722e44 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -14,24 +14,24 @@ import { dirname, join } from "path"; import { appendFileSync, mkdirSync } from "fs"; import { createHash } from "crypto"; -export const PROMPT_CACHE_KEY_ENV_VAR = "OPENCODE_PROMPT_CACHE_KEY"; -export const STICKY_SESSION_ID_ENV_VAR = "OPENCODE_STICKY_SESSION_ID"; -export const SCOPE_ENV_VAR = "OPENCODE_CONTEXT_CACHE_SCOPE"; -export const DEBUG_ENV_VAR = "OPENCODE_CONTEXT_CACHE_DEBUG"; -export const LOG_PATH_ENV_VAR = "OPENCODE_CONTEXT_CACHE_LOG"; +const PROMPT_CACHE_KEY_ENV_VAR = "OPENCODE_PROMPT_CACHE_KEY"; +const STICKY_SESSION_ID_ENV_VAR = "OPENCODE_STICKY_SESSION_ID"; +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"; /** OpenAI is reported to cap prompt_cache_key at 64 characters; a sha256 hex digest is exactly 64. */ -export const MAX_CACHE_KEY_LENGTH = 64; +const MAX_CACHE_KEY_LENGTH = 64; -export const SCOPES = ["worktree", "directory", "session"]; +const SCOPES = ["worktree", "directory", "session"]; const PRINTABLE_ASCII = /^[\x20-\x7E]+$/; -export function sha256(value) { +function sha256(value) { return createHash("sha256").update(value, "utf8").digest("hex"); } -export function fingerprint(value) { +function fingerprint(value) { return sha256(value).slice(0, 8); } @@ -45,11 +45,11 @@ function usablePath(value) { return typeof value === "string" && value.trim() !== "" ? value : ""; } -export function isSafeOverride(value) { +function isSafeOverride(value) { return value.length <= MAX_CACHE_KEY_LENGTH && PRINTABLE_ASCII.test(value); } -export function parseScope(raw) { +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 }; @@ -62,7 +62,7 @@ export function parseScope(raw) { * A degenerate "/" worktree would otherwise collapse every project on the * machine onto a single key. */ -export function selectScopePath({ scope, worktree, directory }) { +function selectScopePath({ scope, worktree, directory }) { const tree = usablePath(worktree); const dir = usablePath(directory); if (scope === "session") return ""; @@ -71,7 +71,7 @@ export function selectScopePath({ scope, worktree, directory }) { return dir; } -export function resolveCacheKey({ env = {}, options = {}, worktree, directory, user, host } = {}) { +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( @@ -106,7 +106,7 @@ export function resolveCacheKey({ env = {}, options = {}, worktree, directory, u }; } -export function getUsername({ env = process.env, readUserInfo = userInfo } = {}) { +function getUsername({ env = process.env, readUserInfo = userInfo } = {}) { try { const info = readUserInfo(); if (info?.username) return info.username; @@ -116,7 +116,7 @@ export function getUsername({ env = process.env, readUserInfo = userInfo } = {}) return env?.USER || env?.USERNAME || env?.LOGNAME || "unknown"; } -export function safeHostname({ readHostname = hostname } = {}) { +function safeHostname({ readHostname = hostname } = {}) { try { return readHostname() || "unknown-host"; } catch { @@ -124,7 +124,7 @@ export function safeHostname({ readHostname = hostname } = {}) { } } -export function defaultLogPath(env = {}, home = homedir()) { +function defaultLogPath(env = {}, home = homedir()) { const explicit = readEnv(env, LOG_PATH_ENV_VAR); if (explicit) return explicit; const stateHome = readEnv(env, "XDG_STATE_HOME") || join(home, ".local", "state"); @@ -139,7 +139,7 @@ function safeJson(value) { } } -export function createLogger({ env = {}, filePath, write = appendFileSync, warn = console.warn } = {}) { +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); @@ -191,12 +191,12 @@ export function createLogger({ env = {}, filePath, write = appendFileSync, warn } /** The two spellings opencode core uses, depending on provider. */ -export const CACHE_KEY_FIELDS = ["promptCacheKey", "prompt_cache_key"]; +const CACHE_KEY_FIELDS = ["promptCacheKey", "prompt_cache_key"]; const SES_PREFIXED = /^ses_[0-9a-f]{64}$/; /** Core sends the digest without the ses_ prefix on its own zen provider path. */ -export function stripSesPrefix(sessionID) { +function stripSesPrefix(sessionID) { return SES_PREFIXED.test(sessionID) ? sessionID.slice(4) : sessionID; } @@ -207,7 +207,7 @@ export function stripSesPrefix(sessionID) { * add it. Matching the session ID is exact provenance, and it inherits core's * whole provider table without duplicating it. */ -export function applyCacheKey(output, value, sessionID) { +function applyCacheKey(output, value, sessionID) { const options = output?.options; if (!options || typeof options !== "object") { return { appliedFields: [], foreignFields: [], reason: "invalid-options" }; @@ -239,7 +239,7 @@ export function applyCacheKey(output, value, sessionID) { return { appliedFields, foreignFields, reason: null }; } -export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { +const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { const env = process.env; const logger = createLogger({ env, warn: typeof options?.warn === "function" ? options.warn : undefined }); const resolved = resolveCacheKey({ @@ -313,7 +313,53 @@ export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { }; }; +/** + * 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, + parseScope, + selectScopePath, + resolveCacheKey, + getUsername, + safeHostname, + defaultLogPath, + createLogger, + stripSesPrefix, + applyCacheKey, +}); + /** Kept so existing configs importing the old name keep working. */ -export const EnhancedCachePlugin = OpenCodeContextCachePlugin; +const EnhancedCachePlugin = OpenCodeContextCachePlugin; +export { OpenCodeContextCachePlugin, EnhancedCachePlugin }; export default OpenCodeContextCachePlugin; diff --git a/test/integration/plugin-input-contract.test.mjs b/test/integration/plugin-input-contract.test.mjs index 8785924..a5fd9b6 100644 --- a/test/integration/plugin-input-contract.test.mjs +++ b/test/integration/plugin-input-contract.test.mjs @@ -18,7 +18,9 @@ import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { setTimeout as sleep } from "node:timers/promises"; -import { resolveCacheKey, getUsername, safeHostname } from "../../plugins/opencode-context-cache.mjs"; +import plugin from "../../plugins/opencode-context-cache.mjs"; + +const { resolveCacheKey, getUsername, safeHostname } = plugin.internals; const HERE = dirname(fileURLToPath(import.meta.url)); const BIN = diff --git a/test/unit/apply-cache-key.test.mjs b/test/unit/apply-cache-key.test.mjs index c05e81d..fd1468d 100644 --- a/test/unit/apply-cache-key.test.mjs +++ b/test/unit/apply-cache-key.test.mjs @@ -1,7 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { applyCacheKey, stripSesPrefix } from "../../plugins/opencode-context-cache.mjs"; +import plugin from "../../plugins/opencode-context-cache.mjs"; + +const { applyCacheKey, stripSesPrefix } = plugin.internals; const SESSION = "ses_" + "a".repeat(64); const STRIPPED = "a".repeat(64); diff --git a/test/unit/cache-key.test.mjs b/test/unit/cache-key.test.mjs index 36caf23..26541f5 100644 --- a/test/unit/cache-key.test.mjs +++ b/test/unit/cache-key.test.mjs @@ -2,7 +2,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { +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, @@ -14,7 +18,7 @@ import { safeHostname, selectScopePath, sha256, -} from "../../plugins/opencode-context-cache.mjs"; +} = 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"); 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 index 9245c82..a3be0db 100644 --- a/test/unit/logger.test.mjs +++ b/test/unit/logger.test.mjs @@ -4,13 +4,9 @@ import { join } from "node:path"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { - DEBUG_ENV_VAR, - LOG_PATH_ENV_VAR, - createLogger, - defaultLogPath, - fingerprint, -} from "../../plugins/opencode-context-cache.mjs"; +import plugin from "../../plugins/opencode-context-cache.mjs"; + +const { DEBUG_ENV_VAR, LOG_PATH_ENV_VAR, createLogger, defaultLogPath, fingerprint } = plugin.internals; const temps = []; function tempDir() { diff --git a/test/unit/plugin-hook.test.mjs b/test/unit/plugin-hook.test.mjs index 5397a7f..8a2eac9 100644 --- a/test/unit/plugin-hook.test.mjs +++ b/test/unit/plugin-hook.test.mjs @@ -3,15 +3,18 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import OpenCodeContextCacheDefault, { - DEBUG_ENV_VAR, EnhancedCachePlugin, OpenCodeContextCachePlugin, +} from "../../plugins/opencode-context-cache.mjs"; + +const { + DEBUG_ENV_VAR, PROMPT_CACHE_KEY_ENV_VAR, SCOPE_ENV_VAR, STICKY_SESSION_ID_ENV_VAR, getUsername, safeHostname, -} from "../../plugins/opencode-context-cache.mjs"; +} = OpenCodeContextCachePlugin.internals; const SESSION = "ses_" + "b".repeat(64); const digest = (v) => createHash("sha256").update(v, "utf8").digest("hex"); From 3cdc5284cf4b0402592ae3b22a3f28d6aedd2ad0 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:14:12 +0900 Subject: [PATCH 12/19] fix: honour the never-throw invariant and surface upstream shape changes Silent-failure review found the hook could still throw: every use of the provider label is a template literal, so a non-string providerID threw on ToString, and the catch handler repeated the mistake. homedir() was the one syscall of three left unguarded, and as a default parameter it ran on every createLogger call, so a container without HOME failed the plugin load outright rather than degrading to inert. Also inverts the warning policy. invalid-options and missing-session were debug-only, so four separate upstream renames would leave the plugin permanently inert with no signal at default settings; a test asserted that silence. They now warn accurately, an unset field is reported as empty rather than as someone else's key, warnings mirror into the debug log, and the error dedup key includes the error so a second unrelated failure is not suppressed forever. --- plugins/opencode-context-cache.mjs | 292 +++++++++++++----- .../plugin-input-contract.test.mjs | 2 +- test/unit/apply-cache-key.test.mjs | 20 +- test/unit/cache-key.test.mjs | 34 ++ test/unit/logger.test.mjs | 42 ++- test/unit/plugin-hook.test.mjs | 89 +++++- 6 files changed, 376 insertions(+), 103 deletions(-) diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index 2722e44..6bf6744 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -124,13 +124,54 @@ function safeHostname({ readHostname = hostname } = {}) { } } -function defaultLogPath(env = {}, home = homedir()) { +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 ""; + } +} + +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, ".local", "state"); + const stateHome = readEnv(env, "XDG_STATE_HOME") || join(home ?? safeHomedir(), ".local", "state"); return join(stateHome, "opencode", "context-cache.log"); } +/** + * 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.` + ); +} + +/** 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"; + } +} + function safeJson(value) { try { return JSON.stringify(value); @@ -150,8 +191,12 @@ function createLogger({ env = {}, filePath, write = appendFileSync, warn = conso function emit(message) { try { warn(`[context-cache] ${message}`); + return true; } catch { - // A failing warning sink must never escape into the request path. + // 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; } } @@ -172,7 +217,10 @@ function createLogger({ env = {}, filePath, write = appendFileSync, warn = conso write(path, `[${new Date().toISOString()}] [pid:${process.pid}] [context-cache] ${body}\n`, "utf8"); } catch (error) { fileUsable = false; - emit(`cannot write debug log at ${path}: ${error?.message ?? error}; debug logging disabled`); + emit( + `cannot write debug log at ${path}: ${describeError(error)}; debug logging disabled ` + + "for this process. Restart opencode after fixing it to re-enable.", + ); } }, @@ -183,8 +231,13 @@ function createLogger({ env = {}, filePath, write = appendFileSync, warn = conso */ warnOnce(key, message) { if (warned.has(key)) return false; + if (!emit(message)) return false; warned.add(key); - emit(message); + // 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. + this.debug(`WARN ${message}`); return true; }, }; @@ -210,15 +263,16 @@ function stripSesPrefix(sessionID) { function applyCacheKey(output, value, sessionID) { const options = output?.options; if (!options || typeof options !== "object") { - return { appliedFields: [], foreignFields: [], reason: "invalid-options" }; + return { appliedFields: [], foreignFields: [], emptyFields: [], reason: "invalid-options" }; } if (typeof sessionID !== "string" || sessionID === "") { - return { appliedFields: [], foreignFields: [], reason: "missing-session" }; + return { appliedFields: [], foreignFields: [], emptyFields: [], reason: "missing-session" }; } const stripped = stripSesPrefix(sessionID); const appliedFields = []; const foreignFields = []; + const emptyFields = []; const replacements = {}; for (const field of CACHE_KEY_FIELDS) { @@ -227,90 +281,173 @@ function applyCacheKey(output, value, sessionID) { 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); } } - if (appliedFields.length === 0 && foreignFields.length === 0) { - return { appliedFields, foreignFields, reason: "no-fields" }; + 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, reason: null }; + return { appliedFields, foreignFields, emptyFields, reason: null }; } const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { - const env = process.env; - const logger = createLogger({ env, warn: typeof options?.warn === "function" ? options.warn : undefined }); - const resolved = resolveCacheKey({ - env, - options, - worktree: input?.worktree, - directory: input?.directory, - user: getUsername({ env }), - host: safeHostname(), - }); - - 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.`, - ); - } - - if (!resolved) logger.debug("no stable cache key resolved; leaving opencode's session default in place"); - 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}`, + // 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( + readEnv(env, SCOPE_ENV_VAR) || (typeof options?.scope === "string" ? options.scope : ""), ); - } + 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}`, + ); + } - return { - "chat.params": async (hookInput, output) => { - if (!resolved) return; - // Everything, including reading the provider label off possibly hostile - // input, sits inside the try. A cache optimization must never be able to - // fail the user's request. - let provider = "unknown"; - try { - provider = hookInput?.model?.providerID ?? hookInput?.provider?.info?.id ?? "unknown"; - const { appliedFields, foreignFields, reason } = applyCacheKey(output, resolved.value, hookInput?.sessionID); - - 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.`, + 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, ); - } - if (reason === "no-fields") { - logger.warnOnce( - `absent:${provider}`, - `provider ${provider} exposes no prompt cache key field, so none was applied. ` + - "This is expected for providers that do not support one; if it used to work, " + - "opencode may have renamed the field.", + + // 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") { + logger.warnOnce( + `absent:${provider}`, + `provider ${provider} exposes no prompt cache key field, so none was applied. ` + + "This is expected for providers that do 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"}`, ); - return; + } 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. + } } - logger.debug( - `provider=${provider} applied=[${appliedFields.join(",")}] ` + - `foreign=[${foreignFields.join(",")}] reason=${reason ?? "none"}`, - ); - } catch (error) { - logger.warnOnce(`error:${provider}`, `unexpected error applying cache key: ${error?.stack ?? error}`); - } - }, - }; + }, + }; + } catch (error) { + try { + console.warn(`[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 () => {} }; + } }; /** @@ -352,7 +489,10 @@ OpenCodeContextCachePlugin.internals = Object.freeze({ resolveCacheKey, getUsername, safeHostname, + safeHomedir, + identityWarning, defaultLogPath, + describeError, createLogger, stripSesPrefix, applyCacheKey, diff --git a/test/integration/plugin-input-contract.test.mjs b/test/integration/plugin-input-contract.test.mjs index a5fd9b6..6310bef 100644 --- a/test/integration/plugin-input-contract.test.mjs +++ b/test/integration/plugin-input-contract.test.mjs @@ -105,7 +105,7 @@ async function probe(directories, cwd) { }); assert.ok(res.ok, `instance request for ${dir} failed with ${res.status}`); } - await sleep(1000); + await sleep(0); const raw = existsSync(out) ? readFileSync(out, "utf8").trim() : ""; return raw ? raw.split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []; } finally { diff --git a/test/unit/apply-cache-key.test.mjs b/test/unit/apply-cache-key.test.mjs index fd1468d..b8ba704 100644 --- a/test/unit/apply-cache-key.test.mjs +++ b/test/unit/apply-cache-key.test.mjs @@ -19,7 +19,7 @@ test("strips the ses_ prefix only from a full lowercase 64-hex session id", () = 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: [], reason: null }); + assert.deepEqual(r, { appliedFields: ["promptCacheKey"], foreignFields: [], emptyFields: [], reason: null }); assert.equal(output.options.promptCacheKey, KEY); assert.equal(output.options.store, false); }); @@ -48,7 +48,7 @@ test("replaces both fields when both hold core's default", () => { 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"], reason: null }); + assert.deepEqual(r, { appliedFields: [], foreignFields: ["promptCacheKey"], emptyFields: [], reason: null }); assert.equal(output.options.promptCacheKey, "someone-elses-key"); }); @@ -61,17 +61,21 @@ test("reports a foreign snake_case sibling alongside an applied camelCase field" assert.equal(output.options.prompt_cache_key, "theirs"); }); -test("treats a present-but-undefined field as foreign, not as core's", () => { - const output = { options: { promptCacheKey: undefined } }; - const r = applyCacheKey(output, KEY, SESSION); - assert.deepEqual(r.foreignFields, ["promptCacheKey"]); - assert.equal(output.options.promptCacheKey, undefined); +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: [], reason: "no-fields" }); + assert.deepEqual(r, { appliedFields: [], foreignFields: [], emptyFields: [], reason: "no-fields" }); assert.deepEqual(output.options, { store: false }); }); diff --git a/test/unit/cache-key.test.mjs b/test/unit/cache-key.test.mjs index 26541f5..83cdeac 100644 --- a/test/unit/cache-key.test.mjs +++ b/test/unit/cache-key.test.mjs @@ -18,6 +18,7 @@ const { safeHostname, selectScopePath, sha256, + identityWarning, } = plugin.internals; const BASE = { user: "andrea", host: "moonveil", worktree: "/srv/repo", directory: "/srv/repo/pkg/a", env: {} }; @@ -167,3 +168,36 @@ test("safeHostname falls back when hostname throws or is empty", () => { 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); +}); diff --git a/test/unit/logger.test.mjs b/test/unit/logger.test.mjs index a3be0db..f10cf9c 100644 --- a/test/unit/logger.test.mjs +++ b/test/unit/logger.test.mjs @@ -6,7 +6,8 @@ import { tmpdir } from "node:os"; import plugin from "../../plugins/opencode-context-cache.mjs"; -const { DEBUG_ENV_VAR, LOG_PATH_ENV_VAR, createLogger, defaultLogPath, fingerprint } = plugin.internals; +const { DEBUG_ENV_VAR, LOG_PATH_ENV_VAR, createLogger, defaultLogPath, fingerprint, safeHomedir } = + plugin.internals; const temps = []; function tempDir() { @@ -90,8 +91,43 @@ test("a throwing warn sink cannot escape", () => { write: () => { throw new Error("EACCES"); }, warn: () => { throw new Error("stderr is gone"); }, }); - logger.debug("boom"); - assert.equal(logger.warnOnce("k", "m"), true); + 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", () => { diff --git a/test/unit/plugin-hook.test.mjs b/test/unit/plugin-hook.test.mjs index 8a2eac9..9bcc3f4 100644 --- a/test/unit/plugin-hook.test.mjs +++ b/test/unit/plugin-hook.test.mjs @@ -201,7 +201,11 @@ test("a foreign key warns once, and a mixed conflict is not hidden", async () => }); }); -test("malformed options and a missing session id produce no operator warning", async () => { +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( @@ -210,35 +214,90 @@ test("malformed options and a missing session id produce no operator warning", a ); await hooks["chat.params"](hookInput(), { options: null }); await hooks["chat.params"](hookInput({ sessionID: undefined }), { options: { promptCacheKey: SESSION } }); - assert.deepEqual(warnings, [], "these are debug-only states, not compatibility failures"); + 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("the deprecated sticky env warns once and its raw value is never logged", async () => { - await withEnv({ [STICKY_SESSION_ID_ENV_VAR]: "secret-tenant-key" }, async () => { +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( + 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], /deprecated/i); - assert.match(warnings[0], new RegExp(STICKY_SESSION_ID_ENV_VAR)); - for (const line of warnings) { - assert.equal(line.includes("secret-tenant-key"), false, "raw override must never be logged"); + 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" }); + 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("an unrecognised scope warns once", async () => { - await withEnv({ [SCOPE_ENV_VAR]: "sessions" }, async () => { +test("a second, unrelated error on one provider is not suppressed by the first", async () => { + await withEnv({}, async () => { const warnings = []; - await OpenCodeContextCachePlugin( + const hooks = await OpenCodeContextCachePlugin( { directory: "/srv/repo", worktree: "/srv/repo" }, { warn: (m) => warnings.push(m) }, ); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /sessions/); - assert.match(warnings[0], /worktree/); + 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 hostileOptions = { + get scope() { throw new Error("config blew up"); }, + warn: () => {}, + }; + 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"); + const output = { options: { promptCacheKey: SESSION } }; + await hooks["chat.params"](hookInput(), output); + assert.equal(output.options.promptCacheKey, SESSION, "an inert plugin changes nothing"); }); }); From bdba8f9fcf98a572c715c53fec08f6ab1d8a0e2d Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:14:47 +0900 Subject: [PATCH 13/19] refactor: single source of truth for the scope setting The factory and resolveCacheKey each assembled the scope input, so a third source would have needed updating in two places. --- plugins/opencode-context-cache.mjs | 14 ++++++++------ test/unit/cache-key.test.mjs | 9 +++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index 6bf6744..bd7d4f2 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -49,6 +49,11 @@ function isSafeOverride(value) { return value.length <= MAX_CACHE_KEY_LENGTH && PRINTABLE_ASCII.test(value); } +/** 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 : ""); +} + function parseScope(raw) { const value = typeof raw === "string" ? raw.trim().toLowerCase() : ""; if (value === "") return { scope: "worktree", unknown: null }; @@ -74,9 +79,7 @@ function selectScopePath({ scope, worktree, directory }) { 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( - readEnv(env, SCOPE_ENV_VAR) || (typeof options?.scope === "string" ? options.scope : ""), - ); + const { scope, unknown: unknownScope } = parseScope(scopeSetting(env, options)); if (scope === "session") return null; const explicit = [ @@ -307,9 +310,7 @@ const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { const logger = createLogger({ env, warn: typeof options?.warn === "function" ? options.warn : undefined }); const user = getUsername({ env }); const host = safeHostname(); - const { scope } = parseScope( - readEnv(env, SCOPE_ENV_VAR) || (typeof options?.scope === "string" ? options.scope : ""), - ); + const { scope } = parseScope(scopeSetting(env, options)); const resolved = resolveCacheKey({ env, options, @@ -484,6 +485,7 @@ OpenCodeContextCachePlugin.internals = Object.freeze({ sha256, fingerprint, isSafeOverride, + scopeSetting, parseScope, selectScopePath, resolveCacheKey, diff --git a/test/unit/cache-key.test.mjs b/test/unit/cache-key.test.mjs index 83cdeac..73f2e38 100644 --- a/test/unit/cache-key.test.mjs +++ b/test/unit/cache-key.test.mjs @@ -19,6 +19,7 @@ const { selectScopePath, sha256, identityWarning, + scopeSetting, } = plugin.internals; const BASE = { user: "andrea", host: "moonveil", worktree: "/srv/repo", directory: "/srv/repo/pkg/a", env: {} }; @@ -201,3 +202,11 @@ test("a placeholder identity is reachable from the real fallback paths", () => { 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), ""); +}); From 095ae22eb0ac185d56fb7e0c4fef6a89bf2a8c4e Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:15:46 +0900 Subject: [PATCH 14/19] docs: realign the spec with what shipped Records opencode's plugin export contract, which no review caught and only a real run surfaced, and corrects the error table: invalid-options and missing-session are operator warnings, not debug-only. --- ...2026-08-30-cache-key-and-headers-design.md | 79 ++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md index 15f2616..637def9 100644 --- a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md +++ b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md @@ -177,11 +177,48 @@ advertised "digest detection to avoid double-hashing" can never fire. The plugin remains a **single self-contained `.mjs` file**. Upstream's install path is "copy this one file into your plugins directory"; splitting into a -`src/` tree would break it. The file exports its pure functions as named -exports so tests import them directly. +`src/` tree would break it. -All state is constructed inside the plugin factory. No module-level mutable -state. +**The file must export exactly one value: the plugin factory.** This is a hard +constraint imposed by opencode's loader, discovered by running the plugin under +a real opencode rather than by any review or test. For a file-path plugin, +opencode walks `Object.values(module)`: + +```js +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){ const seen = new Set(), out = []; + for (const x of Object.values(m)) { + if (seen.has(x)) continue; seen.add(x); + const f = Gy(x); + if (!f) throw TypeError("Plugin export is not a function"); + out.push(f); } + return out } +``` + +Two consequences. A single non-function export - one exported constant - makes +opencode refuse the **entire plugin**. And every distinct exported *function* is +then invoked as a plugin factory with `(PluginInput, options)`, so an exported +`sha256` would be called as `sha256(pluginInput, options)` and its return value +treated as a hooks object. The `{ server }` module shape does not help here; that +path is only taken for npm-package plugins. + +Helpers therefore hang off the factory as a frozen `internals` property, which +`Object.values` does not see, and tests reach them there. The three exports +(`OpenCodeContextCachePlugin`, `EnhancedCachePlugin`, `default`) are deliberately +the same function object, which the loader's `Set` deduplicates into one plugin. +`test/unit/export-shape.test.mjs` reproduces the check above so this cannot +regress. + +Note what happened here: exporting the helpers was itself the fix for an earlier +review finding about testability. It made the plugin unloadable while all 57 +tests stayed green, because tests import a module the way the test needs it, not +the way the host does. + +All state is constructed inside the plugin factory, and the factory body is +wrapped so that an unexpected startup failure yields an inert plugin rather than +a rejected promise that fails the load. No module-level mutable state. ### 3.2 Key resolution (pure) @@ -265,15 +302,33 @@ reaching a third-party gateway. applyCacheKey(output, key, sessionID) -> { appliedFields: string[], foreignFields: string[], + emptyFields: string[], reason: "invalid-options" | "missing-session" | "no-fields" | null } ``` A three-value return cannot express "replaced one field and found the other foreign", and collapsing malformed options, a missing session ID and a genuinely absent field into one value makes the operator warning lie about which happened. -The result is therefore a record: only `reason === "no-fields"` and a non-empty -`foreignFields` warrant an operator warning; `invalid-options` and -`missing-session` are debug-only. +The result is therefore a record, and **every distinguished state gets its own +accurate warning**. + +An earlier revision of this spec drew the wrong conclusion here: it made +`invalid-options` and `missing-session` debug-only. That is backwards. Those two +states cannot occur against a correct opencode, so when they do occur the shape +upstream has changed and the plugin is permanently inert - prompt caching has +silently reverted to a per-session key, the exact regression this plugin exists +to prevent. Meanwhile `no-fields` warns, and it is the *benign* case (an +Anthropic user, working as designed). Loud on the expected, silent on the +unprecedented. + +The original finding was that a coarse return made the message *lie about which +state occurred*. The fix for that is to distinguish the states, which the record +does. Silence was never the required consequence. + +`emptyFields` exists for the same reason: a field present but `undefined` or +`null` was not set by a third party, and telling the operator that "something +else set your key" sends them hunting for a conflicting plugin that does not +exist. Replace a cache-key field **only when its current value is provably the one core just put there**. Core's default is the session ID: @@ -404,11 +459,15 @@ Per-request detail stays in the debug log. Nothing warns per request. | `hostname()` throws | fall back to `"unknown-host"`; key still stable per machine-user-path | | `userInfo()` throws | fall back to `USER`/`USERNAME`/`LOGNAME`, then `"unknown"` | | `worktree` and `directory` both empty | resolve to `null`; hook no-ops; core's session-ID default stands | -| `output.options` absent or not an object | no-op; debug log | +| `output.options` absent or not an object | no-op; one deduped warning naming a possible upstream shape change | | neither cache key field present | no-op; one deduped operator warning (`absent`) | | field present, value is not core's default | leave it; one deduped operator warning (`foreign`) | -| field present with value `undefined` | treated as not core's default -> `foreign`, left alone | -| `input.sessionID` missing | cannot prove provenance; no replacement; debug log | +| field present with value `undefined` or `null` | leave it; one deduped `empty` warning, distinct from `foreign` | +| `input.sessionID` missing | no replacement; one deduped warning naming a possible upstream rename | +| no path derivable from `PluginInput` | inert; one deduped warning (distinct from a `scope: session` opt-out, which is silent) | +| `user` or `host` fell back to a placeholder | key still set; one deduped warning that the key is not machine-unique | +| `providerID` is not a string | coerced to `"unknown"`; never interpolated raw | +| anything throws inside the factory | plugin loads inert rather than failing to load | | explicit override >64 chars or non-printable | hashed instead, substitution logged | | log file unwritable | one stderr warning, then file logging disabled | From cd2fe269063b07bddde35dbc4c08f26e5ae1d2ee Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:16:13 +0900 Subject: [PATCH 15/19] docs: document the full warning set in the README The fix wave added warnings for upstream shape changes, empty fields, placeholder identity and startup failures; the troubleshooting section still described only two. --- README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 93af68e..80bd4eb 100644 --- a/README.md +++ b/README.md @@ -146,14 +146,34 @@ 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. -Two warnings go to stderr regardless of the debug flag, once each: +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. + +Expected, informational: - **"exposes no prompt cache key field"** - opencode placed no cache key field - for this provider. Expected for Anthropic and anything else that does not use + for this provider. Normal for Anthropic and anything else that does not use one. If it used to work and now does not, opencode may have renamed the field. - **"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 + 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. + +These mean the plugin is inert and caching has reverted to a per-session key: + +- **"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. If nothing is logged at all, the plugin is not loaded: check the `plugin` entry in your config and restart. From 43893f0e28a03cb0d21d289550edb13eb7df051b Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:17:31 +0900 Subject: [PATCH 16/19] fix: harden the logger binding and field detection warnOnce reached the debug log through 'this', so a destructured 'const { warnOnce } = logger' would have thrown. Field detection used 'in', which sees the prototype chain, while the replacement spread copies only own properties. --- plugins/opencode-context-cache.mjs | 13 +++++++++---- test/unit/apply-cache-key.test.mjs | 17 +++++++++++++++++ test/unit/logger.test.mjs | 13 +++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index bd7d4f2..ca8d23f 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -203,7 +203,7 @@ function createLogger({ env = {}, filePath, write = appendFileSync, warn = conso } } - return { + const api = { enabled, path, debug(...args) { @@ -239,11 +239,14 @@ function createLogger({ env = {}, filePath, write = appendFileSync, warn = conso // 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. - this.debug(`WARN ${message}`); + // warnings missing. Called through `api`, not `this`, so a destructured + // `const { warnOnce } = logger` keeps working. + api.debug(`WARN ${message}`); return true; }, }; + + return api; } /** The two spellings opencode core uses, depending on provider. */ @@ -279,7 +282,9 @@ function applyCacheKey(output, value, sessionID) { const replacements = {}; for (const field of CACHE_KEY_FIELDS) { - if (!(field in options)) continue; + // 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; diff --git a/test/unit/apply-cache-key.test.mjs b/test/unit/apply-cache-key.test.mjs index b8ba704..0a77ab0 100644 --- a/test/unit/apply-cache-key.test.mjs +++ b/test/unit/apply-cache-key.test.mjs @@ -101,3 +101,20 @@ test("replaces options rather than mutating the object it was handed", () => { 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; + } +}); diff --git a/test/unit/logger.test.mjs b/test/unit/logger.test.mjs index f10cf9c..814a58c 100644 --- a/test/unit/logger.test.mjs +++ b/test/unit/logger.test.mjs @@ -139,3 +139,16 @@ test("warnOnce deduplicates by key and ignores the debug flag", () => { assert.equal(logger.warnOnce("absent:anthropic", "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/); +}); From f127ebb0b26ad4c76389182029bd0defb8e000fd Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:27:58 +0900 Subject: [PATCH 17/19] test: close the gaps mutation testing found, and add a binary contract gate Six mutations survived the previous suite: config options never reached the resolver, the override-redaction assertion was structurally incapable of failing, no test enabled debug through the factory, the hook's early return was unverified, and the root/nested test passed against a dead hook. All six now die. Adds test/integration/opencode-contract.test.mjs, which asserts the opencode facts this design rests on against the binary itself, so the snippets the spec hand-copied are now executable and an upgrade that renames one fails loudly instead of leaving the plugin quietly inert. Also isolates the integration suite from a developer's global git config, realpaths temp roots for macOS, and polls for probe records instead of sleeping. --- README.md | 14 +- package.json | 2 +- plugins/opencode-context-cache.mjs | 40 ++++- test/integration/opencode-contract.test.mjs | 99 +++++++++++ .../plugin-input-contract.test.mjs | 40 +++-- test/integration/probe-plugin.mjs | 42 +++-- test/unit/apply-cache-key.test.mjs | 57 ++++++ test/unit/cache-key.test.mjs | 7 + test/unit/logger.test.mjs | 35 +++- test/unit/plugin-hook.test.mjs | 168 ++++++++++++++++-- 10 files changed, 451 insertions(+), 53 deletions(-) create mode 100644 test/integration/opencode-contract.test.mjs diff --git a/README.md b/README.md index 80bd4eb..e62b8f2 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,17 @@ See [CHANGELOG.md](CHANGELOG.md) for the full list. 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**. Anything - else - your own setting, a model or agent option, another plugin's value - is - left untouched. +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. + +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`. 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 diff --git a/package.json b/package.json index 6499a00..7ff0359 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "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/plugin-input-contract.test.mjs" + "test:integration": "node --test test/integration/opencode-contract.test.mjs test/integration/plugin-input-contract.test.mjs" }, "keywords": [ "opencode", diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index ca8d23f..8a31bc0 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -27,6 +27,14 @@ const SCOPES = ["worktree", "directory", "session"]; const PRINTABLE_ASCII = /^[\x20-\x7E]+$/; +/** + * 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; + function sha256(value) { return createHash("sha256").update(value, "utf8").digest("hex"); } @@ -62,10 +70,14 @@ function parseScope(raw) { } /** - * Mirrors core's own project-path guard: - * vcs === "git" && worktree !== "/" ? worktree : directory - * A degenerate "/" worktree would otherwise collapse every project on the - * machine onto a single key. + * 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); @@ -188,6 +200,7 @@ function createLogger({ env = {}, filePath, write = appendFileSync, warn = conso const enabled = flag === "1" || flag === "true"; const path = filePath ?? defaultLogPath(env); const warned = new Set(); + let overflowed = false; let fileUsable = true; let dirReady = false; @@ -234,6 +247,19 @@ function createLogger({ env = {}, filePath, write = appendFileSync, warn = conso */ 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 (!emit(message)) return false; warned.add(key); // The always-on channel writes to stderr, which under opencode's TUI can @@ -448,7 +474,10 @@ const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { }; } catch (error) { try { - console.warn(`[context-cache] disabled by an unexpected startup error: ${describeError(error)}`); + // 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. } @@ -500,6 +529,7 @@ OpenCodeContextCachePlugin.internals = Object.freeze({ identityWarning, defaultLogPath, describeError, + WARNING_KEY_LIMIT, createLogger, stripSesPrefix, applyCacheKey, 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 index 6310bef..5bb0bee 100644 --- a/test/integration/plugin-input-contract.test.mjs +++ b/test/integration/plugin-input-contract.test.mjs @@ -12,7 +12,7 @@ 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, rmSync, writeFileSync } from "node:fs"; +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"; @@ -50,8 +50,12 @@ function makeProject(root, name) { GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@e", }; - execFileSync("git", ["init", "-q", dir]); - execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", "init"], { env: gitEnv }); + // 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"), @@ -73,7 +77,7 @@ async function stop(child) { /** Boot one server, ask it for each directory, and return the probe records. */ async function probe(directories, cwd) { - const root = mkdtempSync(join(tmpdir(), "ctx-cache-it-")); + const root = realpathSync(mkdtempSync(join(tmpdir(), "ctx-cache-it-"))); const out = join(root, "probe.jsonl"); const port = await freePort(); const stderr = []; @@ -124,7 +128,7 @@ const keyFor = (record) => }).value; test("one server process gives each project its own PluginInput", { skip }, async () => { - const root = mkdtempSync(join(tmpdir(), "ctx-cache-proj-")); + const root = realpathSync(mkdtempSync(join(tmpdir(), "ctx-cache-proj-"))); try { const a = makeProject(root, "alpha"); const b = makeProject(root, "beta"); @@ -132,10 +136,17 @@ test("one server process gives each project its own PluginInput", { skip }, asyn // reading process.cwd() is demonstrably wrong. const records = await probe([a, b], root); - const forA = records.filter((r) => r.worktree === a); - const forB = records.filter((r) => r.worktree === b); - assert.equal(forA.length, 1, "expected exactly one plugin invocation for alpha"); - assert.equal(forB.length, 1, "expected exactly one plugin invocation for beta"); + 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"); @@ -148,14 +159,19 @@ test("one server process gives each project its own PluginInput", { skip }, asyn }); test("worktree is the VCS root, and a nested session shares the root's key", { skip }, async () => { - const root = mkdtempSync(join(tmpdir(), "ctx-cache-proj-")); + 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); - const atRoot = records.find((r) => r.directory === project); - const atNested = records.find((r) => r.directory === nested); + 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"); diff --git a/test/integration/probe-plugin.mjs b/test/integration/probe-plugin.mjs index 1c50380..8e39b38 100644 --- a/test/integration/probe-plugin.mjs +++ b/test/integration/probe-plugin.mjs @@ -2,21 +2,35 @@ 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) => { - if (OUT) { - appendFileSync( - OUT, - JSON.stringify({ - directory: input?.directory, - worktree: input?.worktree, - hasWorktree: input ? "worktree" in input : false, - vcs: input?.project?.vcs ?? null, - cwd: process.cwd(), - }) + "\n", - "utf8", - ); - } - return {}; + 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 index 0a77ab0..16c0607 100644 --- a/test/unit/apply-cache-key.test.mjs +++ b/test/unit/apply-cache-key.test.mjs @@ -118,3 +118,60 @@ test("a polluted Object.prototype is not mistaken for a field opencode set", () 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 index 73f2e38..f490523 100644 --- a/test/unit/cache-key.test.mjs +++ b/test/unit/cache-key.test.mjs @@ -210,3 +210,10 @@ test("scopeSetting is the single source of truth for where scope comes from", () 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/logger.test.mjs b/test/unit/logger.test.mjs index 814a58c..49f767a 100644 --- a/test/unit/logger.test.mjs +++ b/test/unit/logger.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import plugin from "../../plugins/opencode-context-cache.mjs"; -const { DEBUG_ENV_VAR, LOG_PATH_ENV_VAR, createLogger, defaultLogPath, fingerprint, safeHomedir } = +const { DEBUG_ENV_VAR, LOG_PATH_ENV_VAR, WARNING_KEY_LIMIT, createLogger, defaultLogPath, fingerprint, safeHomedir } = plugin.internals; const temps = []; @@ -152,3 +152,36 @@ test("warnOnce still works when detached from the logger object", () => { 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 index 9bcc3f4..fe9544c 100644 --- a/test/unit/plugin-hook.test.mjs +++ b/test/unit/plugin-hook.test.mjs @@ -1,6 +1,9 @@ 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, @@ -9,6 +12,7 @@ import OpenCodeContextCacheDefault, { const { DEBUG_ENV_VAR, + LOG_PATH_ENV_VAR, PROMPT_CACHE_KEY_ENV_VAR, SCOPE_ENV_VAR, STICKY_SESSION_ID_ENV_VAR, @@ -19,8 +23,18 @@ const { const SESSION = "ses_" + "b".repeat(64); const digest = (v) => createHash("sha256").update(v, "utf8").digest("hex"); -/** Every plugin-owned env var, so an ambient value cannot silently change a result. */ -const OWNED = [PROMPT_CACHE_KEY_ENV_VAR, STICKY_SESSION_ID_ENV_VAR, SCOPE_ENV_VAR, DEBUG_ENV_VAR]; +/** + * 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 = {}; @@ -45,6 +59,11 @@ async function withEnv(vars, run) { } } +/** Tests assert on collected warnings, so nothing should reach the real stderr. */ +function quiet() { + return { warn: () => {} }; +} + function hookInput(extra = {}) { return { sessionID: SESSION, @@ -73,7 +92,7 @@ test("the hook applies the exact digest of user@host:worktree", async () => { test("the hook never writes conversation headers", async () => { await withEnv({}, async () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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 } }); @@ -86,7 +105,7 @@ test("the hook never writes conversation headers", async () => { 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" }); + 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"); @@ -95,7 +114,7 @@ test("the hook tolerates a model with no headers object at all", async () => { test("the hook leaves a key it did not set", async () => { await withEnv({}, async () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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"); @@ -104,25 +123,32 @@ test("the hook leaves a key it did not set", async () => { test("the hook adds nothing when core placed no field", async () => { await withEnv({}, async () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + 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 when scope disables the key", async () => { +test("the hook is inert and silent when scope disables the key", async () => { await withEnv({ [SCOPE_ENV_VAR]: "session" }, async () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); + const warnings = []; + const hooks = await OpenCodeContextCachePlugin( + { directory: "/srv/repo", worktree: "/srv/repo" }, + { warn: (m) => warnings.push(m) }, + ); const output = { options: { promptCacheKey: SESSION } }; - await hooks["chat.params"](hookInput(), output); + 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" }); + 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"); @@ -131,7 +157,7 @@ test("the hook changes nothing when the session id is missing", async () => { 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" }); + 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 } }); @@ -143,8 +169,8 @@ test("the hook does not throw on malformed input or output", async () => { 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" }); - const b = await OpenCodeContextCachePlugin({ directory: "/srv/b/sub", worktree: "/srv/b" }); + 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); @@ -160,12 +186,13 @@ test("two worktrees yield different keys, independent of process.cwd()", async ( 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" }); - const nested = await OpenCodeContextCachePlugin({ directory: "/srv/a/pkg/deep", worktree: "/srv/a" }); + 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); }); }); @@ -254,7 +281,7 @@ 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" }); + const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }, quiet()); const hostile = [ Object.create(null), Symbol("provider"), @@ -290,14 +317,121 @@ test("a second, unrelated error on one provider is not suppressed by the first", 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: () => {}, + 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) }, + ); + await hooks["chat.params"]({ sessionID: SESSION, provider: { info: { id: "via-info" } } }, { options: {} }); + await hooks["chat.params"]({ sessionID: SESSION }, { options: {} }); + 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"); + }); +}); From 37dc742828817632105f27e89cf72a44c40a3ca5 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 19:51:36 +0900 Subject: [PATCH 18/19] chore: keep the SDD process docs on the fork, not in this PR The spec and implementation plan are this fork's development record, not something upstream's tree needs to carry. Everything an outside reader needs - the evidence for each defect, the alternatives considered and rejected, and the known limitation - is in the pull request description. They remain on the fork's main branch for anyone who wants the full design and review history. --- .../plans/2026-08-30-cache-key-and-headers.md | 1586 ----------------- ...2026-08-30-cache-key-and-headers-design.md | 685 ------- 2 files changed, 2271 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-30-cache-key-and-headers.md delete mode 100644 docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md diff --git a/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md b/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md deleted file mode 100644 index 382ecba..0000000 --- a/docs/superpowers/plans/2026-08-30-cache-key-and-headers.md +++ /dev/null @@ -1,1586 +0,0 @@ -# OpenCode Context Cache Rework Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the plugin's session-header writing and `process.cwd()`-derived cache key with a single, provenance-checked prompt cache key scoped to the git worktree. - -**Architecture:** One self-contained ESM file exporting pure helpers plus a plugin factory. `resolveCacheKey` computes the key once per factory invocation from `PluginInput`; `applyCacheKey` replaces a cache-key field in `output.options` only when its current value is provably opencode's own session-ID default. No conversation headers are written. No module-level mutable state. - -**Tech Stack:** Node ESM (`.mjs`), `node:test`, `node:crypto`, zero runtime and zero dev dependencies. - -**Spec:** `docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md` - -## Global Constraints - -- Single shipped file: `plugins/opencode-context-cache.mjs`. Do not split into `src/`; upstream's install path is copying that one file. -- **Every commit must leave a loadable plugin.** After each task, `plugins/opencode-context-cache.mjs` must still export `OpenCodeContextCachePlugin`, `EnhancedCachePlugin` and a default, and that factory must return an object with a `chat.params` function. An intermediate commit may ship a plugin that does nothing; it may never ship one opencode cannot load. -- Zero dependencies, runtime and dev. Tests run on `node --test` with no install step. -- Node `>=20`. -- **The hook must never throw.** The entire hook body, including provider-label extraction, lives inside one `try`. Logging and warning sinks are themselves wrapped so a failing `console.warn` cannot escape. -- Never write `x-session-id`, `conversation_id`, `session_id`, `X-Session-Id`, or `x-session-affinity`. Never touch `input.model.headers`. -- Never log the raw value of an operator-supplied override; log its source and an 8-character fingerprint only. -- `MAX_CACHE_KEY_LENGTH = 64`. Printable ASCII is `/^[\x20-\x7E]+$/`. -- **Do not trim filesystem paths.** A path may legitimately end in whitespace. Treat a whitespace-only path as absent; otherwise use it verbatim. -- Keep the `EnhancedCachePlugin` named export and the default export as aliases. -- Env var names, exact: `OPENCODE_PROMPT_CACHE_KEY`, `OPENCODE_STICKY_SESSION_ID`, `OPENCODE_CONTEXT_CACHE_SCOPE`, `OPENCODE_CONTEXT_CACHE_DEBUG`, `OPENCODE_CONTEXT_CACHE_LOG`. -- **Scope precedence:** parse scope first. `session` is a hard opt-out that beats an explicit override, because it is the safety valve for providers with lookup-key semantics and must not be defeatable by a stale env var. An unrecognised scope value warns once and falls back to `worktree`. -- **Config precedence:** env vars beat the plugin `options` object from `opencode.jsonc`, which beats defaults. -- Commit messages: no `Co-Authored-By` agent attribution. Use a plain dash, never an em dash, in all prose and code comments. - -## File Structure - -| File | Responsibility | -|---|---| -| `plugins/opencode-context-cache.mjs` | Everything shipped: pure helpers + factory. Rewritten. | -| `package.json` | npm-installable identity, explicit `test` scripts. New. | -| `.gitignore` | log file, `node_modules`. New. | -| `test/unit/cache-key.test.mjs` | resolution, scope parsing, override bounds. | -| `test/unit/apply-cache-key.test.mjs` | provenance, per-field outcomes, immutability. | -| `test/unit/logger.test.mjs` | log path, write and mkdir failure, `warnOnce`, redaction. | -| `test/unit/plugin-hook.test.mjs` | the real factory and the hook it returns. | -| `test/integration/probe-plugin.mjs` | fixture recording `PluginInput`. | -| `test/integration/plugin-input-contract.test.mjs` | opt-in, runs a real `opencode serve`. | -| `.github/workflows/test.yml` | CI on Node 20 and 22. | -| `README.md`, `CHANGELOG.md` | Rewritten / new. | - ---- - -### Task 1: Scaffolding and a loadable, inert plugin - -Delivers the resolution layer and a plugin that loads, resolves a key, logs it, and deliberately does nothing with it yet. Applying the key arrives in Task 4. - -**Files:** -- Create: `package.json`, `.gitignore` -- Create: `plugins/opencode-context-cache.mjs` (replacing the existing file wholesale) -- Test: `test/unit/cache-key.test.mjs` - -**Interfaces:** -- Consumes: nothing. -- Produces: constants `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`; `sha256(v) -> string`; `fingerprint(v) -> string`; `isSafeOverride(v) -> boolean`; `parseScope(raw) -> {scope, unknown}`; `selectScopePath({scope, worktree, directory}) -> string`; `resolveCacheKey({env, options, worktree, directory, user, host}) -> {raw, value, source, hashed, sensitive, deprecated, unknownScope} | null`; `getUsername({env, readUserInfo}) -> string`; `safeHostname({readHostname}) -> string`; `createLogger(...)`; `OpenCodeContextCachePlugin`, `EnhancedCachePlugin`, default. - -- [ ] **Step 1: Create `package.json`** - -Test files are listed explicitly. A glob is not portable to Windows `cmd.exe`, and on Node 24 an unmatched quoted glob reports zero tests and exits 0 - a silently green CI run. - -```json -{ - "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": { - "test": "node --test test/unit/cache-key.test.mjs test/unit/apply-cache-key.test.mjs test/unit/logger.test.mjs test/unit/plugin-hook.test.mjs", - "test:integration": "node --test test/integration/plugin-input-contract.test.mjs" - }, - "keywords": ["opencode", "opencode-plugin", "prompt-cache"], - "license": "MIT", - "engines": { - "node": ">=20" - } -} -``` - -- [ ] **Step 2: Create `.gitignore`** - -```gitignore -node_modules/ -context-cache.log -*.log -``` - -- [ ] **Step 3: Write the failing test** - -Create `test/unit/cache-key.test.mjs`: - -```js -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; - -import { - MAX_CACHE_KEY_LENGTH, - PROMPT_CACHE_KEY_ENV_VAR, - SCOPE_ENV_VAR, - STICKY_SESSION_ID_ENV_VAR, - getUsername, - isSafeOverride, - parseScope, - resolveCacheKey, - safeHostname, - selectScopePath, - sha256, -} from "../../plugins/opencode-context-cache.mjs"; - -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"); -}); -``` - -- [ ] **Step 4: Run the test to verify it fails** - -Run: `npm test` -Expected: FAIL at module link time with `SyntaxError: The requested module '../../plugins/opencode-context-cache.mjs' does not provide an export named 'MAX_CACHE_KEY_LENGTH'`. Node reports the *first* missing binding in the import list, not `resolveCacheKey`. - -- [ ] **Step 5: Replace `plugins/opencode-context-cache.mjs`** - -Delete the entire existing contents: the `DebugLogger`, `CacheKeyResolver`, `CacheKeyApplier` and `ContextCachePluginRuntime` classes, and the module-level singletons. Write: - -```js -/** - * opencode plugin: OpenCode Context Cache - * - * 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. - * - * 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, homedir, userInfo } from "os"; -import { dirname, join } from "path"; -import { appendFileSync, mkdirSync } from "fs"; -import { createHash } from "crypto"; - -export const PROMPT_CACHE_KEY_ENV_VAR = "OPENCODE_PROMPT_CACHE_KEY"; -export const STICKY_SESSION_ID_ENV_VAR = "OPENCODE_STICKY_SESSION_ID"; -export const SCOPE_ENV_VAR = "OPENCODE_CONTEXT_CACHE_SCOPE"; -export const DEBUG_ENV_VAR = "OPENCODE_CONTEXT_CACHE_DEBUG"; -export const LOG_PATH_ENV_VAR = "OPENCODE_CONTEXT_CACHE_LOG"; - -/** OpenAI is reported to cap prompt_cache_key at 64 characters; a sha256 hex digest is exactly 64. */ -export const MAX_CACHE_KEY_LENGTH = 64; - -export const SCOPES = ["worktree", "directory", "session"]; - -const PRINTABLE_ASCII = /^[\x20-\x7E]+$/; - -export function sha256(value) { - return createHash("sha256").update(value, "utf8").digest("hex"); -} - -export function fingerprint(value) { - return sha256(value).slice(0, 8); -} - -function readEnv(env, name) { - const value = env?.[name]; - return typeof value === "string" ? value.trim() : ""; -} - -/** Paths are used verbatim: only a whitespace-only path counts as absent. */ -function usablePath(value) { - return typeof value === "string" && value.trim() !== "" ? value : ""; -} - -export function isSafeOverride(value) { - return value.length <= MAX_CACHE_KEY_LENGTH && PRINTABLE_ASCII.test(value); -} - -export 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 }; -} - -/** - * Mirrors core's own project-path guard: - * vcs === "git" && worktree !== "/" ? worktree : directory - * A degenerate "/" worktree would otherwise collapse every project on the - * machine onto a single key. - */ -export 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; -} - -export 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( - readEnv(env, SCOPE_ENV_VAR) || (typeof options?.scope === "string" ? options.scope : ""), - ); - 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 }; - } - - const path = selectScopePath({ scope, worktree, directory }); - if (!path) return null; - - const raw = `${user}@${host}:${path}`; - return { - raw, - value: sha256(raw), - source: `user@host:${scope}`, - hashed: true, - sensitive: false, - deprecated: false, - unknownScope, - }; -} - -export 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"; -} - -export function safeHostname({ readHostname = hostname } = {}) { - try { - return readHostname() || "unknown-host"; - } catch { - return "unknown-host"; - } -} - -export function defaultLogPath(env = {}, home = homedir()) { - const explicit = readEnv(env, LOG_PATH_ENV_VAR); - if (explicit) return explicit; - const stateHome = readEnv(env, "XDG_STATE_HOME") || join(home, ".local", "state"); - return join(stateHome, "opencode", "context-cache.log"); -} - -function safeJson(value) { - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -/** Minimal debug-only logger. The operator warning channel arrives in Task 3. */ -export 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); - let fileUsable = true; - let dirReady = false; - - function emit(message) { - try { - warn(`[context-cache] ${message}`); - } catch { - // A failing warning sink must never escape into the request path. - } - } - - return { - 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}: ${error?.message ?? error}; debug logging disabled`); - } - }, - }; -} - -export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { - const env = process.env; - const logger = createLogger({ env }); - const resolved = resolveCacheKey({ - env, - options, - worktree: input?.worktree, - directory: input?.directory, - user: getUsername({ env }), - host: safeHostname(), - }); - - if (!resolved) logger.debug("no stable cache key resolved; leaving opencode's session default in place"); - 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}`, - ); - } - - return { - // Applying the key is wired in Task 4. This keeps the plugin loadable. - "chat.params": async () => {}, - }; -}; - -/** Kept so existing configs importing the old name keep working. */ -export const EnhancedCachePlugin = OpenCodeContextCachePlugin; - -export default OpenCodeContextCachePlugin; -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `npm test` -Expected: PASS, 20 tests. - -- [ ] **Step 7: Verify the plugin is still loadable** - -Run: `node -e "import('./plugins/opencode-context-cache.mjs').then(async m => { const h = await m.default({directory:'/tmp',worktree:'/tmp'}); console.log(typeof h['chat.params']); })"` -Expected: `function` - -- [ ] **Step 8: Commit** - -```bash -git add package.json .gitignore plugins/opencode-context-cache.mjs test/unit/cache-key.test.mjs -git commit -m "feat: resolve a worktree-scoped prompt cache key - -Replaces the process.cwd() key with one derived from PluginInput, bounds -explicit overrides to what a provider will accept, and makes scope=session -a hard opt-out. The plugin loads and is inert; applying the key follows." -``` - ---- - -### Task 2: Provenance-checked application - -**Files:** -- Modify: `plugins/opencode-context-cache.mjs` (append, above the factory) -- Test: `test/unit/apply-cache-key.test.mjs` - -**Interfaces:** -- Consumes: nothing at runtime. -- Produces: `CACHE_KEY_FIELDS: string[]`, `stripSesPrefix(sessionID) -> string`, `applyCacheKey(output, value, sessionID) -> {appliedFields: string[], foreignFields: string[], reason: "invalid-options"|"missing-session"|"no-fields"|null}`. - -A three-value return cannot express "applied one field and found another foreign", so the result is a record. Only `reason === "no-fields"` and a non-empty `foreignFields` warrant an operator warning; `invalid-options` and `missing-session` are debug-only, per the spec's error table. - -- [ ] **Step 1: Write the failing test** - -Create `test/unit/apply-cache-key.test.mjs`: - -```js -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { applyCacheKey, stripSesPrefix } from "../../plugins/opencode-context-cache.mjs"; - -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: [], 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"], 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("treats a present-but-undefined field as foreign, not as core's", () => { - const output = { options: { promptCacheKey: undefined } }; - const r = applyCacheKey(output, KEY, SESSION); - assert.deepEqual(r.foreignFields, ["promptCacheKey"]); - assert.equal(output.options.promptCacheKey, undefined); -}); - -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: [], 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"); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `npm test` -Expected: FAIL with `does not provide an export named 'applyCacheKey'`. - -- [ ] **Step 3: Append the application layer** - -Insert immediately before `export const OpenCodeContextCachePlugin`: - -```js -/** The two spellings opencode core uses, depending on provider. */ -export const CACHE_KEY_FIELDS = ["promptCacheKey", "prompt_cache_key"]; - -const SES_PREFIXED = /^ses_[0-9a-f]{64}$/; - -/** Core sends the digest without the ses_ prefix on its own zen provider path. */ -export function stripSesPrefix(sessionID) { - return SES_PREFIXED.test(sessionID) ? sessionID.slice(4) : sessionID; -} - -/** - * 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. - */ -export function applyCacheKey(output, value, sessionID) { - const options = output?.options; - if (!options || typeof options !== "object") { - return { appliedFields: [], foreignFields: [], reason: "invalid-options" }; - } - if (typeof sessionID !== "string" || sessionID === "") { - return { appliedFields: [], foreignFields: [], reason: "missing-session" }; - } - - const stripped = stripSesPrefix(sessionID); - const appliedFields = []; - const foreignFields = []; - const replacements = {}; - - for (const field of CACHE_KEY_FIELDS) { - if (!(field in options)) continue; - const current = options[field]; - if (current === sessionID || current === stripped) { - replacements[field] = value; - appliedFields.push(field); - } else { - foreignFields.push(field); - } - } - - if (appliedFields.length === 0 && foreignFields.length === 0) { - return { appliedFields, foreignFields, reason: "no-fields" }; - } - if (appliedFields.length > 0) output.options = { ...options, ...replacements }; - return { appliedFields, foreignFields, reason: null }; -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `npm test` -Expected: PASS, 32 tests total. - -- [ ] **Step 5: Commit** - -```bash -git add plugins/opencode-context-cache.mjs test/unit/apply-cache-key.test.mjs -git commit -m "feat: replace the cache key only when it is core's own default - -Field presence does not prove provenance; matching opencode's session ID -does. Reports applied and foreign fields separately so a mixed conflict -is visible rather than silently half-applied." -``` - ---- - -### Task 3: The operator warning channel - -**Files:** -- Modify: `plugins/opencode-context-cache.mjs` (extend `createLogger`) -- Test: `test/unit/logger.test.mjs` - -**Interfaces:** -- Consumes: `createLogger` from Task 1. -- Produces: `createLogger(...)` additionally exposing `warnOnce(key, message) -> boolean`. - -- [ ] **Step 1: Write the failing test** - -Create `test/unit/logger.test.mjs`: - -```js -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 { - DEBUG_ENV_VAR, - LOG_PATH_ENV_VAR, - createLogger, - defaultLogPath, - fingerprint, -} from "../../plugins/opencode-context-cache.mjs"; - -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"); }, - }); - logger.debug("boom"); - assert.equal(logger.warnOnce("k", "m"), true); -}); - -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"); - assert.equal(logger.warnOnce("absent:openai", "first"), true); - assert.equal(logger.warnOnce("absent:openai", "again"), false); - assert.equal(logger.warnOnce("absent:anthropic", "other"), true); - assert.deepEqual(warnings, ["[context-cache] first", "[context-cache] other"]); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `npm test` -Expected: FAIL. `logger.warnOnce is not a function`. - -- [ ] **Step 3: Add `warnOnce` to `createLogger`** - -Inside `createLogger`, add `const warned = new Set();` beside the other state, and add this property to the returned object after `debug`: - -```js - /** - * 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; - warned.add(key); - emit(message); - return true; - }, -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `npm test` -Expected: PASS, 41 tests total. - -- [ ] **Step 5: Commit** - -```bash -git add plugins/opencode-context-cache.mjs test/unit/logger.test.mjs -git commit -m "feat: add an always-on deduplicated operator warning channel - -Compatibility failures must not depend on the operator having already -enabled debug logging. The sink is wrapped so a failing stderr cannot -escape into the request path." -``` - ---- - -### Task 4: Wire application and warnings into the hook - -**Files:** -- Modify: `plugins/opencode-context-cache.mjs` (replace the factory's hook) -- Test: `test/unit/plugin-hook.test.mjs` - -**Interfaces:** -- Consumes: everything from Tasks 1-3. -- Produces: a `chat.params` hook that applies the key and reports outcomes. - -- [ ] **Step 1: Write the failing test** - -Create `test/unit/plugin-hook.test.mjs`: - -```js -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; - -import OpenCodeContextCacheDefault, { - DEBUG_ENV_VAR, - EnhancedCachePlugin, - OpenCodeContextCachePlugin, - PROMPT_CACHE_KEY_ENV_VAR, - SCOPE_ENV_VAR, - STICKY_SESSION_ID_ENV_VAR, - getUsername, - safeHostname, -} from "../../plugins/opencode-context-cache.mjs"; - -const SESSION = "ses_" + "b".repeat(64); -const digest = (v) => createHash("sha256").update(v, "utf8").digest("hex"); - -/** Every plugin-owned env var, so an ambient value cannot silently change a result. */ -const OWNED = [PROMPT_CACHE_KEY_ENV_VAR, STICKY_SESSION_ID_ENV_VAR, SCOPE_ENV_VAR, DEBUG_ENV_VAR]; - -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; - } - } -} - -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" }); - 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" }); - 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" }); - 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" }); - const output = { options: { store: false } }; - await hooks["chat.params"](hookInput(), output); - assert.deepEqual(output.options, { store: false }); - }); -}); - -test("the hook is inert when scope disables the key", async () => { - await withEnv({ [SCOPE_ENV_VAR]: "session" }, async () => { - const hooks = await OpenCodeContextCachePlugin({ directory: "/srv/repo", worktree: "/srv/repo" }); - const output = { options: { promptCacheKey: SESSION } }; - await hooks["chat.params"](hookInput(), output); - assert.equal(output.options.promptCacheKey, SESSION); - }); -}); - -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" }); - 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" }); - 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" }); - const b = await OpenCodeContextCachePlugin({ directory: "/srv/b/sub", worktree: "/srv/b" }); - 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(`x@y:${process.cwd()}`)); - }); -}); - -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" }); - const nested = await OpenCodeContextCachePlugin({ directory: "/srv/a/pkg/deep", worktree: "/srv/a" }); - const outRoot = { options: { promptCacheKey: SESSION } }; - const outNested = { options: { promptCacheKey: SESSION } }; - await root["chat.params"](hookInput(), outRoot); - await nested["chat.params"](hookInput(), outNested); - assert.equal(outRoot.options.promptCacheKey, outNested.options.promptCacheKey); - }); -}); - -test("a missing cache key field warns once per provider, with debug off", 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: {} }); - await hooks["chat.params"](hookInput(), { options: {} }); - await hooks["chat.params"](hookInput({ model: { providerID: "anthropic" } }), { options: {} }); - assert.equal(warnings.length, 2, "one per provider, not one per request"); - assert.match(warnings[0], /openai/); - assert.match(warnings[1], /anthropic/); - }); -}); - -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("malformed options and a missing session id produce no operator warning", 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: null }); - await hooks["chat.params"](hookInput({ sessionID: undefined }), { options: { promptCacheKey: SESSION } }); - assert.deepEqual(warnings, [], "these are debug-only states, not compatibility failures"); - }); -}); - -test("the deprecated sticky env warns once and its raw value is never logged", async () => { - await withEnv({ [STICKY_SESSION_ID_ENV_VAR]: "secret-tenant-key" }, async () => { - const warnings = []; - await OpenCodeContextCachePlugin( - { directory: "/srv/repo", worktree: "/srv/repo" }, - { warn: (m) => warnings.push(m) }, - ); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /deprecated/i); - assert.match(warnings[0], new RegExp(STICKY_SESSION_ID_ENV_VAR)); - for (const line of warnings) { - assert.equal(line.includes("secret-tenant-key"), false, "raw override must never be logged"); - } - }); -}); - -test("an unrecognised scope warns once", async () => { - await withEnv({ [SCOPE_ENV_VAR]: "sessions" }, async () => { - const warnings = []; - await OpenCodeContextCachePlugin( - { directory: "/srv/repo", worktree: "/srv/repo" }, - { warn: (m) => warnings.push(m) }, - ); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /sessions/); - assert.match(warnings[0], /worktree/); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `npm test` -Expected: FAIL. The first assertion to break is `the hook applies the exact digest of user@host:worktree`, because the Task 1 hook is a deliberate no-op. - -- [ ] **Step 3: Replace the factory** - -Replace the whole `OpenCodeContextCachePlugin` definition with: - -```js -export const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { - const env = process.env; - const logger = createLogger({ env, warn: typeof options?.warn === "function" ? options.warn : undefined }); - const resolved = resolveCacheKey({ - env, - options, - worktree: input?.worktree, - directory: input?.directory, - user: getUsername({ env }), - host: safeHostname(), - }); - - 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.`, - ); - } - - if (!resolved) logger.debug("no stable cache key resolved; leaving opencode's session default in place"); - 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}`, - ); - } - - return { - "chat.params": async (hookInput, output) => { - if (!resolved) return; - // Everything, including reading the provider label off possibly hostile - // input, sits inside the try. A cache optimization must never be able to - // fail the user's request. - let provider = "unknown"; - try { - provider = hookInput?.model?.providerID ?? hookInput?.provider?.info?.id ?? "unknown"; - const { appliedFields, foreignFields, reason } = applyCacheKey(output, resolved.value, hookInput?.sessionID); - - 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 (reason === "no-fields") { - logger.warnOnce( - `absent:${provider}`, - `provider ${provider} exposes no prompt cache key field, so none was applied. ` + - "This is expected for providers that do not support one; if it used to work, " + - "opencode may have renamed the field.", - ); - return; - } - logger.debug( - `provider=${provider} applied=[${appliedFields.join(",")}] ` + - `foreign=[${foreignFields.join(",")}] reason=${reason ?? "none"}`, - ); - } catch (error) { - logger.warnOnce(`error:${provider}`, `unexpected error applying cache key: ${error?.stack ?? error}`); - } - }, - }; -}; -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `npm test` -Expected: PASS, 57 tests total. - -- [ ] **Step 5: Verify no header writing survives** - -Run: `grep -nE "x-session-id|session_id|conversation_id|model\.headers|x-session-affinity" plugins/opencode-context-cache.mjs` -Expected: no output. - -- [ ] **Step 6: Commit** - -```bash -git add plugins/opencode-context-cache.mjs test/unit/plugin-hook.test.mjs -git commit -m "feat: apply the resolved cache key and report outcomes - -Wires provenance-checked application into chat.params, warns once per -provider on a missing or foreign field, and keeps debug-only states out -of the operator channel." -``` - ---- - -### Task 5: Opt-in opencode contract probe - -This is a **contract probe, not a red-green task**: it asserts facts about opencode that the design depends on and that no product change of ours can affect. It may be green the moment it is written. It exists as a compatibility gate: the installed plugin types are 1.18.21 while the binary is 1.18.25, so this design was verified against compiled behavior rather than a published contract. Run it before upgrading opencode. - -**Files:** -- Create: `test/integration/probe-plugin.mjs`, `test/integration/plugin-input-contract.test.mjs` - -- [ ] **Step 1: Create the probe fixture** - -Create `test/integration/probe-plugin.mjs`: - -```js -import { appendFileSync } from "fs"; - -const OUT = process.env.CONTEXT_CACHE_PROBE_OUT; - -export const ProbePlugin = async (input) => { - if (OUT) { - appendFileSync( - OUT, - JSON.stringify({ - directory: input?.directory, - worktree: input?.worktree, - hasWorktree: input ? "worktree" in input : false, - vcs: input?.project?.vcs ?? null, - cwd: process.cwd(), - }) + "\n", - "utf8", - ); - } - return {}; -}; - -export default ProbePlugin; -``` - -- [ ] **Step 2: Write the contract test** - -Create `test/integration/plugin-input-contract.test.mjs`. Each test gets its own temp root, its own probe file and an ephemeral port; startup is polled rather than slept on; shutdown is awaited in a `finally` and escalates to `SIGKILL`. - -```js -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, 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 { resolveCacheKey, getUsername, safeHostname } from "../../plugins/opencode-context-cache.mjs"; - -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", - }; - execFileSync("git", ["init", "-q", dir]); - execFileSync("git", ["-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 = 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(1000); - 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 }); - } -} - -test("one server process gives each project its own PluginInput", { skip }, async () => { - const root = 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); - - const forA = records.filter((r) => r.worktree === a); - const forB = records.filter((r) => r.worktree === b); - assert.equal(forA.length, 1, "expected exactly one plugin invocation for alpha"); - assert.equal(forB.length, 1, "expected exactly one 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. - const keyFor = (r) => - resolveCacheKey({ - env: {}, worktree: r.worktree, directory: r.directory, - user: getUsername({ env: process.env }), host: safeHostname(), - }).value; - 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 = 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); - - const atRoot = records.find((r) => r.directory === project); - const atNested = records.find((r) => 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"); - - const keyFor = (r) => - resolveCacheKey({ - env: {}, worktree: r.worktree, directory: r.directory, - user: getUsername({ env: process.env }), host: safeHostname(), - }).value; - assert.equal(keyFor(atRoot), keyFor(atNested), "a nested session must reuse the worktree key"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); -``` - -- [ ] **Step 3: Run the integration suite** - -Run: `npm run test:integration` -Expected: PASS with 2 tests when an opencode binary is present; both reported as skipped otherwise. It may be green on the first run - that is correct for a contract probe. - -- [ ] **Step 4: Confirm the unit suite is unaffected** - -Run: `npm test` -Expected: still 57 tests. The `test` script names files explicitly, so integration cannot leak in. - -- [ ] **Step 5: Commit** - -```bash -git add test/integration -git commit -m "test: add an opt-in opencode contract probe - -Asserts the two facts this design rests on - one plugin instance per -project, and worktree as the VCS root - against the real binary, and -checks the resolver turns them into distinct keys. Skips when no binary -is installed, so CI stays green." -``` - ---- - -### Task 6: CI, README, changelog - -**Files:** -- Create: `.github/workflows/test.yml`, `CHANGELOG.md` -- Modify: `README.md` (rewrite) - -- [ ] **Step 1: Create the CI workflow** - -```yaml -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 }} - - run: npm test -``` - -No install step: the project has no dependencies. - -- [ ] **Step 2: Create `CHANGELOG.md`** - -```markdown -# 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. - -### 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. -- `prompt_cache_key` (deepinfra, cerebras) is now handled; previously only the - camelCase spelling was written, so those providers were unaffected. -- The key is replaced only when it still holds opencode's own session-ID - default, so an explicit operator setting 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. -- The debug log moved out of the plugin directory to - `$XDG_STATE_HOME/opencode/context-cache.log`. - -### Added - -- `OPENCODE_CONTEXT_CACHE_SCOPE` (`worktree` default, `directory`, `session`). - `session` is a full opt-out. -- `OPENCODE_CONTEXT_CACHE_LOG` to relocate the debug log. -- Always-on, deduplicated operator warnings for compatibility failures. -- A test suite and CI. -``` - -- [ ] **Step 3: Rewrite `README.md`** - -Replace the file. Required content, in order: - -1. **Title and one-paragraph summary.** The plugin sets a prompt cache key stable across sessions in one git worktree, replacing opencode's per-session default. -2. **A "Breaking change in 0.2.0" section near the top**, summarising the changelog entry above and linking to `CHANGELOG.md`. -3. **How it works.** Core sets the cache key to the session ID; this plugin replaces that value, and only that value, with `sha256(user@host:)`. -4. **Install.** Both routes: npm identifier in the `plugin` array, and copying the single file. Keep the existing warning that the `plugin` entry is required. -5. **Configuration.** A table of all five env vars with defaults, plus the `opencode.jsonc` options form (`["opencode-context-cache", { "scope": "directory" }]`) and the precedence rule: env beats options beats defaults, except `scope: session`, which disables the key outright. -6. **Provider support.** Honest: this sets OpenAI-family `promptCacheKey` / `prompt_cache_key`. Anthropic uses `cache_control` breakpoints and ignores a cache key, so the plugin is inert there and says so once **on stderr**. Remove every "works with ALL providers" claim. -7. **Hashing.** Describe as keeping the local username, hostname and path off the wire. Do not call it privacy: the pre-image space is small enough to enumerate. -8. **Observed impact.** Keep the 97.99% figure, labelled explicitly as a single anecdotal run on one provider with no controlled baseline. -9. **Troubleshooting.** Debug flag, log location, and what each operator warning means. - -Delete: the `isSha256Hex` digest-detection bullet, the five-level precedence list (it is three levels now), and every reference to setting session headers. - -- [ ] **Step 4: Verify no stale claims survive** - -Run: `grep -niE "all providers|privacy|sticky session header" README.md` -Expected: no output. - -Run: `grep -niE "conversation_id|x-session-id" README.md` -Expected: matches only inside the "Breaking change" section. - -- [ ] **Step 5: Run everything one last time** - -Run: `npm test && npm run test:integration` -Expected: 57 unit tests pass; integration passes or skips. - -- [ ] **Step 6: Commit** - -```bash -git add .github/workflows/test.yml README.md CHANGELOG.md -git commit -m "docs: rewrite README, add changelog and CI - -Documents the header removal as a breaking change with migration -guidance, drops the all-providers and privacy claims, and labels the -cache hit figure as a single uncontrolled run." -``` - ---- - -## Self-Review - -**Spec coverage.** 3.1 shape -> Task 1 Step 5. 3.2 resolution, scope, override bounds -> Task 1. 3.3 provenance and replacement -> Task 2. 3.4 hook wiring -> Task 4. 3.5 logging and warning channel -> Tasks 1 and 3. 3.6 error handling -> every row now has a test, listed below. Section 4 unit tests -> Tasks 1-4; hook-level -> Task 4; integration -> Task 5. Section 5 deliverables -> all six tasks, plus `CHANGELOG.md`, which section 6 of the spec requires for the upstream disclosure and the first draft omitted. - -**Spec 3.6 error table, row by row.** `hostname()` throws -> Task 1, `safeHostname falls back`. `userInfo()` throws -> Task 1, `getUsername falls back`. Both paths empty -> Task 1, `no usable path yields null`. Options absent/not object -> Task 2 `invalid-options`, Task 4 no-warning test. Neither field present -> Task 2 `no-fields`, Task 4 dedup warning test. Foreign value -> Task 2, Task 4 mixed-conflict test. Value `undefined` -> Task 2. Missing `sessionID` -> Task 2 `missing-session`, Task 4 no-change and no-warning tests. Unsafe override -> Task 1, both overlong and non-printable. Unwritable log -> Task 3, both write and mkdir failure. - -**Placeholder scan.** Every code step carries complete code. Task 6 Step 3 specifies README content as required sections; each item states what it must say and what must be deleted, with two grep gates in Step 4. - -**Type consistency.** `resolveCacheKey` returns `{raw, value, source, hashed, sensitive, deprecated, unknownScope}` in Task 1 and Task 4 reads exactly those. `applyCacheKey(output, value, sessionID)` returns `{appliedFields, foreignFields, reason}` in Task 2 and is destructured for exactly those in Task 4. `createLogger` exposes `enabled`, `path`, `debug` from Task 1 and gains `warnOnce` in Task 3; Task 4 uses only those four. `getUsername({env, readUserInfo})` and `safeHostname({readHostname})` take option bags in Task 1 and are called that way in Tasks 4 and 5. `readEnv`, `usablePath` and `safeJson` are module-private, defined once each in Task 1. - -**Loadability at every commit.** Task 1 ships an inert but valid plugin; Tasks 2 and 3 only add exports; Task 4 replaces the hook body. Task 1 Step 7 checks this explicitly. diff --git a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md b/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md deleted file mode 100644 index 637def9..0000000 --- a/docs/superpowers/specs/2026-08-30-cache-key-and-headers-design.md +++ /dev/null @@ -1,685 +0,0 @@ -# Design: stable prompt cache key, without conversation-identity headers - -Date: 2026-08-30 -Status: approved; revised after two Codex adversarial reviews (see section 8) -Branch: `rework-cache-key-and-headers` - -## 1. Problem - -opencode derives the upstream prompt cache key from the opencode session ID. -From the shipped binary (`~/.opencode/bin/opencode`): - -```js -if ($.providerOptions?.setCacheKey !== false) { - if ($.model.api.npm === "@ai-sdk/deepinfra" || $.model.api.npm === "@ai-sdk/cerebras") - Z.prompt_cache_key = $.sessionID; - else if ($.model.api.npm === "@ai-sdk/openai" || "@ai-sdk/azure" || "@ai-sdk/xai" - || "@ai-sdk/mistral" || "venice-ai-sdk-provider" || $.providerOptions?.setCacheKey === true) - Z.promptCacheKey = $.sessionID; -} -``` - -A session ID is new on every session, so every new session starts with a cold -prompt cache even when the prompt prefix (system prompt, AGENTS.md, tool -schemas) is byte-identical to the previous one. Pinning the key to something -stable per project is the correct fix, and is the premise this plugin was -forked for. - -## 2. What the current implementation gets wrong - -### 2.1 It conflates two identities that need opposite lifetimes - -The plugin derives one value and writes it to both the prompt cache key and to -three conversation-identity headers (`x-session-id`, `conversation_id`, -`session_id`). - -Those are not the same kind of identifier: - -- A prompt cache key is a **routing hint**. A stale or over-broad value can - only cause a cache miss. Sharing it widely is safe and is the entire win. -- A session/conversation ID keys **mutable server-side state**. Sharing it - across concurrent sessions is a correctness bug. - -One demonstrated consumer makes the cost concrete. On opencode's built-in -OpenAI/Codex path, `x-session-affinity` keys a WebSocket connection pool: - -```js -let N = A["x-session-affinity"] ?? A["session-id"]; -if (!N) return Z(H, O); -let V = `${N}:conversation`; -let D = Q.get(V) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 }; -if (D.fallback) return Z(H, O); -if (D.busy) return Z(H, O); -D.busy = true; -D.socket = await NA(D, ...); -``` - -On that path, pinning a conversation identity to a per-directory constant -would: - -1. Force every concurrent session in one project through a single socket. The - second concurrent request observes `busy` and silently drops to the slower - HTTP fallback path. -2. Let one oversized message in any session set the sticky `fallback` flag for - the whole directory (`MESSAGE_TOO_BIG_CLOSE_CODE` sets `D.fallback = true`), - degrading every other session sharing that key rather than only its own. - -This pool is **not** universal - it does not establish behavior for Azure, xAI, -Mistral, DeepInfra, Cerebras, or third-party relays. It is an existence proof -that the cost is real, not the whole argument. The general argument is that -these header names mean "this conversation", so a project-stable value is -semantically wrong in them whoever consumes it, and core already sends -`x-session-affinity` and `X-Session-Id` derived from the real session ID, which -makes the plugin's versions redundant where they are understood at all. - -**Decision: the plugin stops writing conversation-identity headers entirely.** -It sets only the prompt cache key. - -**This is a breaking change.** The current README advertises sticky-session -headers for relay/gateway use, including the non-standard `conversation_id` and -`session_id` names. A gateway parsing those underscore names loses them. This -must be called out in the README, the changelog and the upstream PR rather than -shipped quietly; the replacement guidance is that core's own -`x-session-affinity` / `X-Session-Id` already carry per-session identity. - -### 2.2 The headers it writes collide with core's - -Core assembles outbound headers as: - -```js -headers: { - ...providerID.startsWith("opencode") - ? { "x-opencode-session": e.sessionID, ... } - : { "x-session-affinity": e.sessionID, "X-Session-Id": e.sessionID, "User-Agent": _i }, - ...e.parentSessionID ? { "x-parent-session-id": e.parentSessionID } : {}, - ...e.model.headers, - ...g -} -``` - -Core writes `X-Session-Id`; the plugin writes `x-session-id`. In a JS object -spread these are distinct keys, so both survive into the request and only -collapse at the HTTP layer, yielding either a comma-joined value or -last-write-wins depending on the runtime. Resolved by 2.1 (we write no -headers), and recorded here so the removal is not re-litigated. - -### 2.3 It mutates shared provider state via the wrong hook - -The plugin writes to `input.model.headers` inside `chat.params`. That object is -the model entry from the provider registry, not per-request state. opencode -exposes a dedicated `chat.headers` hook whose output is spread *after* -`model.headers`, so `chat.params` header writes are also lower precedence than -core's own (opencode's built-in OpenAI plugin sets `session-id` from -`chat.headers`, which the plugin cannot override from where it sits). - -Resolved by 2.1. - -### 2.4 The cache key ignores the API and reads `process.cwd()` - -`PluginInput` provides the right values: - -```ts -type PluginInput = { client, project, directory: string, worktree: string, serverUrl, $ } -``` - -`getUserHostDirectoryKey()` calls `process.cwd()` instead. - -This was verified empirically rather than inferred. A probe plugin recording its -`PluginInput`, loaded into one `opencode serve` process started from -`/home/andrea` and then asked for two separate projects, produced: - -``` ---- invocation 1 --- --- invocation 2 --- - directory: .../probe directory: .../probe2 - worktree: .../probe worktree: .../probe2 - cwd: /home/andrea cwd: /home/andrea -``` - -So the factory is invoked once per project with correct per-project values, -while `process.cwd()` is the server's launch directory for both. Upstream -therefore computes the identical key `andrea@host:/home/andrea` for two -unrelated projects, collapsing them onto one cache identity. The same probe -confirms `worktree` is populated and is the VCS root, and that a session started -in a nested subdirectory reports that subdirectory as `directory` while still -reporting the repo root as `worktree`. - -### 2.5 Two of five documented precedence levels are unreachable - -`getUserHostDirectoryKey()` returns `null` only if `hostname()` or -`process.cwd()` throws. Levels 4 (model headers) and 5 (session ID) are -therefore dead, and `alreadyHashed` is only ever set in level 4, so the -advertised "digest detection to avoid double-hashing" can never fire. - -### 2.6 Overstated claims - -- "Works with ALL providers" - the mechanism is OpenAI-family only. Anthropic - caching uses `cache_control` breakpoints on content blocks and ignores a - cache key entirely. -- "SHA256 hashed cache key for privacy" - the pre-image is - `user@host:/absolute/path`. Given username and hostname, candidate paths are - trivially enumerable. This is obfuscation, not privacy. -- `97.99%` is a single anecdotal run with no stated baseline methodology. - -### 2.7 Minor - -- `ensureLogDirectory()` creates the dirname of a file inside `__dirname`, - which necessarily already exists. It is a no-op. -- The log file is written beside the plugin, so the README's own - `"./plugins/..."` install example writes it into the user's repository. The - repo ships no `.gitignore`. -- No `package.json`, so the plugin cannot be installed by npm identifier, which - is how opencode's `plugin` config array normally references plugins. -- No tests, no CI. - -## 3. Design - -### 3.1 Shape - -The plugin remains a **single self-contained `.mjs` file**. Upstream's install -path is "copy this one file into your plugins directory"; splitting into a -`src/` tree would break it. - -**The file must export exactly one value: the plugin factory.** This is a hard -constraint imposed by opencode's loader, discovered by running the plugin under -a real opencode rather than by any review or test. For a file-path plugin, -opencode walks `Object.values(module)`: - -```js -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){ const seen = new Set(), out = []; - for (const x of Object.values(m)) { - if (seen.has(x)) continue; seen.add(x); - const f = Gy(x); - if (!f) throw TypeError("Plugin export is not a function"); - out.push(f); } - return out } -``` - -Two consequences. A single non-function export - one exported constant - makes -opencode refuse the **entire plugin**. And every distinct exported *function* is -then invoked as a plugin factory with `(PluginInput, options)`, so an exported -`sha256` would be called as `sha256(pluginInput, options)` and its return value -treated as a hooks object. The `{ server }` module shape does not help here; that -path is only taken for npm-package plugins. - -Helpers therefore hang off the factory as a frozen `internals` property, which -`Object.values` does not see, and tests reach them there. The three exports -(`OpenCodeContextCachePlugin`, `EnhancedCachePlugin`, `default`) are deliberately -the same function object, which the loader's `Set` deduplicates into one plugin. -`test/unit/export-shape.test.mjs` reproduces the check above so this cannot -regress. - -Note what happened here: exporting the helpers was itself the fix for an earlier -review finding about testability. It made the plugin unloadable while all 57 -tests stayed green, because tests import a module the way the test needs it, not -the way the host does. - -All state is constructed inside the plugin factory, and the factory body is -wrapped so that an unexpected startup failure yields an inert plugin rather than -a rejected promise that fails the load. No module-level mutable state. - -### 3.2 Key resolution (pure) - -``` -resolveCacheKey({ env, worktree, directory, user, host }) - -> { raw, value, source } | null -``` - -`raw` is the pre-image, used only in debug logs. `value` is what is sent -upstream: equal to `raw` for explicit overrides, and `sha256(raw)` for the -generated key. - -Precedence: - -| # | Source | Hashed? | -|---|--------|---------| -| 1 | `OPENCODE_PROMPT_CACHE_KEY` | no, used verbatim | -| 2 | `OPENCODE_STICKY_SESSION_ID` (compat, logged as deprecated) | no, used verbatim | -| 3 | `user@host:` | sha256 | -| 4 | none available | returns `null`, plugin no-ops | - -Two changes from upstream: - -- **Explicit overrides are used verbatim when safe.** The operator chose that - string; they get that string. This deletes the `isSha256Hex` digest-sniffing - branch. "Safe" means at most 64 characters and printable ASCII: OpenAI is - reported to cap `prompt_cache_key` at 64 characters (not verified against a - live API here, so treated as a cheap defensive bound rather than an - established fact), and a sha256 hex digest is exactly 64. An override that - exceeds the bound or carries non-printable characters is hashed instead, and - the substitution is logged, so the plugin can never emit a value the provider - will reject. -- **Level 4 is reachable.** opencode can pass `worktree: ""` (observed in the - binary: `worktree:"",directory:j.directory??""`), so "no key available" is a - real state with a real test, not dead code. - -Scope is the **worktree**, falling back to `directory` when the worktree is -empty or `"/"`. That guard mirrors opencode's own, which picks a project path -with `e.vcs === "git" && e.worktree !== "/" ? e.worktree : e.directory` - a -degenerate `/` worktree would otherwise collapse every project on the machine -onto a single key, which is the exact bug class this change exists to fix. All sessions -inside one checkout share a key, which is where the reuse is: the system -prompt, AGENTS.md/CLAUDE.md and tool schemas are identical across -subdirectories. Separate git worktrees get separate keys, which is correct -since they hold different branches. - -An over-broad key is low-risk but not risk-free, and the earlier draft of this -spec overclaimed by calling it "never a correctness bug". Two qualifications: - -- For OpenAI, `prompt_cache_key` is a routing hint and exact prefix matching - protects correctness, so the failure mode is degraded hit rate rather than - wrong output. But concurrent agents in one worktree can hold unrelated system - prompts and tool sets, and OpenAI's own guidance is to split a busy group when - hit rate degrades. Cache thrash under concurrency is a real cost. -- DeepInfra documents `prompt_cache_key` as an explicit KV-cache lookup key and - suggests a per-session value. That is a stronger contract than "routing hint", - and this design cannot claim universal safety across every backend and relay - implementing the field. - -Mitigation: scope is configurable via `OPENCODE_CONTEXT_CACHE_SCOPE`, or the -`scope` key of the plugin's `options` object in `opencode.jsonc`, taking -`worktree` | `directory` | `session` and defaulting to `worktree`. Operators -running many concurrent divergent sessions, or a provider with lookup-key -semantics, can narrow it without patching the plugin. - -`session` resolves to `null` so core's own per-session default stands -untouched, and **it is parsed before the explicit overrides, so it beats them**. -It is the safety valve for a provider whose cache key carries stronger -semantics than routing, and a safety valve a forgotten stale -`OPENCODE_PROMPT_CACHE_KEY` can silently defeat is not one. An unrecognised -scope value warns once and falls back to `worktree` rather than silently -widening scope. - -Hashing is retained for the auto-generated key only, on the honest rationale -that it keeps the local username, hostname and home directory layout from -reaching a third-party gateway. - -### 3.3 Applying the key - -``` -applyCacheKey(output, key, sessionID) - -> { appliedFields: string[], - foreignFields: string[], - emptyFields: string[], - reason: "invalid-options" | "missing-session" | "no-fields" | null } -``` - -A three-value return cannot express "replaced one field and found the other -foreign", and collapsing malformed options, a missing session ID and a genuinely -absent field into one value makes the operator warning lie about which happened. -The result is therefore a record, and **every distinguished state gets its own -accurate warning**. - -An earlier revision of this spec drew the wrong conclusion here: it made -`invalid-options` and `missing-session` debug-only. That is backwards. Those two -states cannot occur against a correct opencode, so when they do occur the shape -upstream has changed and the plugin is permanently inert - prompt caching has -silently reverted to a per-session key, the exact regression this plugin exists -to prevent. Meanwhile `no-fields` warns, and it is the *benign* case (an -Anthropic user, working as designed). Loud on the expected, silent on the -unprecedented. - -The original finding was that a coarse return made the message *lie about which -state occurred*. The fix for that is to distinguish the states, which the record -does. Silence was never the required consequence. - -`emptyFields` exists for the same reason: a field present but `undefined` or -`null` was not set by a third party, and telling the operator that "something -else set your key" sends them hunting for a conflicting plugin that does not -exist. - -Replace a cache-key field **only when its current value is provably the one -core just put there**. Core's default is the session ID: - -```js -Z.prompt_cache_key = $.sessionID; // deepinfra, cerebras -Z.promptCacheKey = $.sessionID; // openai, azure, xai, mistral, venice, setCacheKey:true -Z.promptCacheKey = /^ses_[0-9a-f]{64}$/.test(id) ? id.slice(4) : id; // opencode zen path -``` - -so the provenance test is exact: - -```js -const isCoreDefault = (v) => v === sessionID || v === stripSesPrefix(sessionID); -``` - -For each of `promptCacheKey` and `prompt_cache_key` independently: if absent, -skip. If present and `isCoreDefault`, replace and record it in `appliedFields`. -If present and anything else, leave it alone and record it in `foreignFields`. -Per-field accounting matters: a request carrying core's value in one spelling and -a third party's in the other is a real conflict, and reporting only an aggregate -would hide it behind the successful half. - -An earlier draft used bare presence (`"promptCacheKey" in options`) as the -signal. That is wrong, and the adversarial review was right to reject it: -presence does not prove core set the value. Model, agent or variant options can -carry the field; a plugin ordered before this one can add it; a merge can leave -it present with value `undefined`. Overwriting on presence alone would defeat an -explicit operator setting and make behavior depend on plugin order. - -Matching against the session ID fixes that precisely, and keeps the property -that made the presence check attractive in the first place: no provider table to -duplicate and no drift as opencode adds providers. It inherits core's entire -opt-in decision tree, because we only ever replace core's own output. - -- `setCacheKey: false` -> core writes nothing -> nothing to match -> we skip. -- `setCacheKey: true` on a relay -> core writes the session ID -> we replace it. -- deepinfra/cerebras -> core writes `prompt_cache_key` -> we replace that name, - which upstream misses entirely by hardcoding the camelCase spelling. -- A user or plugin set their own key -> not the session ID -> untouched. - -**Replacement, not in-place mutation.** `output.options` is reassigned to a new -object rather than mutated: - -```js -output.options = { ...options, ...replacements }; -``` - -The review established that core builds a fresh options object per request via a -non-mutating merge, so in-place mutation would be safe today. Replacement is -kept anyway because it is free and stays correct if that ever changes: `_y()` -returns `Object.values(model.variants)[0]` directly on its fallthrough path, and -nothing in the plugin should be one refactor away from writing a cache key into -shared model config. That is the same bug class as upstream's `model.headers` -mutation, and it is not worth being clever about. - -This depends on core populating `output.options` before triggering the hook, -which is verified - `Plugin.trigger` passes the caller's output object straight -through to every hook and returns it unchanged: - -```js -J = y.fn("Plugin.trigger")(function*(W, K, U) { - if (!W) return U; - for (let z of (yield* c0.get(X)).hooks) { let M = z[W]; if (!M) continue; - yield* y.promise(async () => M(K, U)); } - return U; -}) -``` - -If that ever changes, the plugin degrades to doing nothing rather than to doing -something wrong. - -### 3.4 Hook wiring - -```js -export const OpenCodeContextCachePlugin = async ({ directory, worktree }) => { - const logger = createLogger({ env: process.env }); - const resolved = resolveCacheKey({ env: process.env, directory, worktree, - user: getUsername(), host: safeHostname() }); - // ... log resolution outcome once - return { - "chat.params": async (input, output) => { - if (!resolved) return; - const outcome = applyCacheKey(output, resolved.value, input?.sessionID); - report(outcome, input); // debug log always; one deduped operator warning, see 3.5 - }, - }; -}; -export const EnhancedCachePlugin = OpenCodeContextCachePlugin; // compat -export default OpenCodeContextCachePlugin; -``` - -The key is resolved once per plugin instance rather than per request: -`directory` and `worktree` are fixed for the life of an instance. - -### 3.5 Logging - -- Default path `${XDG_STATE_HOME:-~/.local/state}/opencode/context-cache.log`, - overridable via `OPENCODE_CONTEXT_CACHE_LOG`. -- Enabled by `OPENCODE_CONTEXT_CACHE_DEBUG` in `{1, true}`. -- `ensureLogDirectory` becomes real (`mkdir -p` on a directory that may not exist). -- On write failure: emit exactly one stderr warning naming the path and the - error, then disable file logging. Not silent, and not TUI-spamming. - -**Operator-visible warnings are a separate channel from the debug log.** The -earlier draft claimed compatibility failures would be "visible rather than -silent" while routing them through the debug-gated logger, which means silent by -default - the review was right to call that a silent failure. A future opencode -field rename could disable the plugin indefinitely with nobody noticing. - -So: a `console.warn` fires independently of `OPENCODE_CONTEXT_CACHE_DEBUG`, -**deduplicated to at most one per (plugin instance, provider, category)**, for: - -- `absent` - a key was resolved but neither cache-key field was present. Names - the provider and states that this is expected for providers that do not use a - prompt cache key, so an Anthropic user sees one informative line, once, and a - field rename is still surfaced. -- `foreign` - a field was present but held a value that was not core's default, - so it was left alone. Names what was found, so an operator can tell a - deliberate override from a conflict. - -Per-request detail stays in the debug log. Nothing warns per request. - -### 3.6 Error handling - -| Condition | Behavior | -|---|---| -| `hostname()` throws | fall back to `"unknown-host"`; key still stable per machine-user-path | -| `userInfo()` throws | fall back to `USER`/`USERNAME`/`LOGNAME`, then `"unknown"` | -| `worktree` and `directory` both empty | resolve to `null`; hook no-ops; core's session-ID default stands | -| `output.options` absent or not an object | no-op; one deduped warning naming a possible upstream shape change | -| neither cache key field present | no-op; one deduped operator warning (`absent`) | -| field present, value is not core's default | leave it; one deduped operator warning (`foreign`) | -| field present with value `undefined` or `null` | leave it; one deduped `empty` warning, distinct from `foreign` | -| `input.sessionID` missing | no replacement; one deduped warning naming a possible upstream rename | -| no path derivable from `PluginInput` | inert; one deduped warning (distinct from a `scope: session` opt-out, which is silent) | -| `user` or `host` fell back to a placeholder | key still set; one deduped warning that the key is not machine-unique | -| `providerID` is not a string | coerced to `"unknown"`; never interpolated raw | -| anything throws inside the factory | plugin loads inert rather than failing to load | -| explicit override >64 chars or non-printable | hashed instead, substitution logged | -| log file unwritable | one stderr warning, then file logging disabled | - -The plugin never throws out of the hook. A cache-key optimization must not be -able to fail a user's request. - -## 4. Testing - -`node --test`, zero devDependencies, so CI runs with no install step. - -The review's sharpest criticism of the first draft was that most listed tests -would pass an implementation whose hook never runs. Unit tests of the pure -helpers are necessary but not sufficient; the suite must drive the real exported -factory and the hook it returns. - -**`resolveCacheKey` (pure)** -- each precedence level selects the expected source -- `OPENCODE_PROMPT_CACHE_KEY` wins over `OPENCODE_STICKY_SESSION_ID` -- safe explicit overrides returned verbatim, not hashed -- an override >64 chars is hashed instead, and reports that it was -- an override with non-printable characters is hashed instead -- whitespace-only env values are ignored, not treated as a key -- auto key is sha256 of `user@host:path` -- worktree preferred; directory used when worktree is `""` or `"/"` - (mirrors core's own `e.vcs === "git" && e.worktree !== "/"` guard, so a - degenerate `/` worktree cannot collapse every project onto one key) -- `OPENCODE_CONTEXT_CACHE_SCOPE` of `directory` forces directory scope; - `session` resolves to `null` -- returns `null` when both paths are empty -- deterministic; differs across differing user, host, or path - -**`applyCacheKey` (pure, provenance)** -- replaces `promptCacheKey` when it equals `sessionID` -- replaces `prompt_cache_key` when it equals `sessionID` -- replaces a value equal to the `ses_`-stripped session ID (zen path) -- returns `foreign` and changes nothing when the value is a third party's key -- returns `foreign` and changes nothing when the value is `undefined` -- returns `absent` and adds nothing when neither field is present -- leaves unrelated options untouched -- does not mutate the object it was given (asserts a new object identity) - -**Hook-level tests, driving the real factory** - -These exist specifically to fail an implementation whose hook never runs or -wires the wrong key. - -- factory returns an object exposing `chat.params` -- invoking that hook on an options object seeded with `sessionID` yields exactly - the key `resolveCacheKey` would have produced for the same `PluginInput` - - binds the hook to the resolver, so a hook that no-ops or passes a wrong value - fails -- invoking it with a foreign value leaves the options untouched -- `input.model.headers` is deeply unchanged after the hook runs -- the hook does not throw when `output.options` is missing, when `input` is - missing, or when `sessionID` is absent -- two factory instances built with different worktrees produce different keys, - and neither depends on `process.cwd()` (asserted by running the factory from a - third, unrelated cwd - upstream returns the same key for both here) - -**Warning channel** -- `absent` and `foreign` each warn once and then stay quiet across repeated - hook invocations for the same provider -- warnings fire with `OPENCODE_CONTEXT_CACHE_DEBUG` unset -- the raw value of an explicit override never appears in any log line; only its - source and a short fingerprint do - -**Logger** -- disabled by default; writes when enabled -- an unwritable path produces exactly one warning and does not throw - -**Integration, opt-in (`test/integration/`)** - -Skipped automatically when no opencode binary is present, so CI stays green; -run locally and before an opencode upgrade as a compatibility gate. This is the -review's requested lifecycle test, and the harness is already proven: a probe -plugin recording its `PluginInput` under `opencode serve`. - -- one server process, started from an unrelated cwd, serving two projects: - asserts the factory is invoked once per project with that project's own - `directory`/`worktree`, and that the resulting keys differ -- a session started in a nested subdirectory of a repo produces the same key as - one started at the repo root -- asserts `PluginInput.worktree` is still populated and is the VCS root, which - is the contract the whole design rests on and the thing most likely to break - across an opencode upgrade - -Note the version skew this guards against: the installed plugin types are -1.18.21 while the binary is 1.18.25, so the compiled behavior this design was -verified against is not fully described by the shipped type definitions. - -## 5. Deliverables - -- rewritten `plugins/opencode-context-cache.mjs` -- `test/*.test.mjs` and `test/integration/*.test.mjs` (the latter self-skipping - when no opencode binary is present) -- `package.json` (`type: module`, `scripts.test`, `files`, exports) -- `.gitignore` (log file, `node_modules`) -- `.github/workflows/test.yml` -- README rewritten: drop "all providers" and "privacy" claims, reframe the - 97.99% figure as one anecdotal run, document the removal of header writing as - a breaking change with migration guidance, and document - `OPENCODE_CONTEXT_CACHE_SCOPE` and `OPENCODE_CONTEXT_CACHE_LOG` - -## 6. Upstream - -Two pull requests. The fork gets the change directly. Upstream gets a PR whose -body leads with the `x-session-affinity` connection-pool evidence, since it -asks the maintainer to accept the removal of an advertised feature. - -## 7. Explicitly out of scope - -- Anthropic `cache_control` breakpoint injection. That is a different mechanism - with a different hook surface and a different failure mode; folding it in - would destabilize this change. -- Publishing to npm. `package.json` makes it installable; the publish decision - is the maintainer's. - -## 8. Adversarial review record - -Codex reviewed this spec (job `task-mtflow5i-9oguil`, effort high) against a -seven-point attack list. Recorded here so a later round does not re-derive it. - -**Cleared.** - -- *Stale key lifetime.* Feared that resolving once per factory invocation goes - stale if one plugin instance serves several projects or a worktree is - retargeted. Codex found plugin state is created via `InstanceState.make`, keyed - by resolved directory; `/experimental/worktree` creates a new directory-backed - instance and `/experimental/worktree/reset` resets git state within the same - directory without retargeting. Independently confirmed by the probe in 2.4. - Factory-time resolution stands. The residual risk - reliance on compiled - behavior rather than a documented contract, with types at 1.18.21 and the - binary at 1.18.25 - is addressed by the opt-in integration test in section 4. -- *Shared options object.* Feared in-place mutation could leak into shared model - config. Codex established core builds a fresh options object per request via a - non-mutating merge. Replacement is retained anyway as a free hedge (3.3). -- *JSON injection via explicit overrides.* Not a risk; serialization escapes. - -**Accepted and folded in.** - -- *Presence does not prove provenance* - the strongest finding. Rewrote 3.3 to - match against `sessionID` instead of testing field presence. Codex proposed a - maintained provider table or a presence heuristic; the session-ID match is - better than both, and the finding is what made it visible. -- *Over-broad key claim overstated* - softened in 3.2, with the DeepInfra - lookup-key semantics and OpenAI cache-thrash concerns recorded, and - `OPENCODE_CONTEXT_CACHE_SCOPE` added so scope can be narrowed without a patch. -- *Header-removal evidence too narrow* - the WebSocket pool is opencode's - OpenAI/Codex path, not universal. 2.1 now presents it as an existence proof - and rests the argument on semantic mismatch plus redundancy with core's own - headers, and labels the removal a breaking change with migration guidance. -- *Unbounded verbatim overrides* - 64-character and printable-ASCII bound added, - falling back to hashing (3.2). -- *Raw override in debug logs* - only source plus a short fingerprint is logged - for explicit overrides (3.5). -- *"Visible rather than silent" routed through a debug-gated logger* - a - separate, deduplicated, always-on operator warning channel added (3.5). -- *Tests would pass a hook that never runs* - section 4 rewritten around - hook-level and integration tests. - -**Considered and not taken.** - -- *Populate conversation headers from per-request `sessionID` instead of - removing them.* Declined: that option was explicitly weighed and rejected - before this spec was written, and core already emits `x-session-affinity` and - `X-Session-Id` from the session ID, so the plugin's versions would duplicate - core for every consumer that understands them. -- *Provider-specific cache scope with a prompt-version component.* Declined as - over-engineering for this plugin's purpose, and it reintroduces the provider - table that 3.3 exists to avoid. The scope env var covers the real need. - -### 8.1 Second review: the implementation plan - -Codex reviewed the plan (job `task-mtfmc8hc-gsxi9e`, effort high). It confirmed -the first round's findings were folded in, and found that three of them were -folded in *nominally* rather than correctly. Folded in: - -- **The tri-state return could not represent the states this spec distinguishes.** - Malformed options, a missing session ID and a genuinely absent field all - returned `"absent"`, so the operator warning claimed a provider field had - disappeared when the real cause was something else. Section 3.3 now returns a - record. This is the same class of defect as the original presence check: an - API too narrow to carry the distinction the design depends on. -- **A mixed core/foreign conflict was hidden**, and the plan's test blessed it. - Per-field accounting added. -- **`scope: session` did not actually opt out**, because explicit overrides were - parsed first, contradicting this spec's own unqualified claim. Resolved in - 3.2 by parsing scope first. -- **"The hook never throws" was not implemented**: the provider label was read - outside the `try`, and the warning sink itself could throw, including from - inside the catch handler. -- **The promised deprecation notice for `OPENCODE_STICKY_SESSION_ID` existed - only in prose**, with no code and no test. -- **Tasks 1-3 each committed a product that would not load.** The plan now - requires every commit to leave a loadable plugin, with an explicit check. -- **`node --test test/*.test.mjs` can pass with zero tests.** Codex verified on - Node 24 that an unmatched quoted glob exits 0 having run nothing, and the glob - does not expand on Windows at all. Test files are now listed explicitly. -- Smaller: paths were being trimmed (a path may legitimately end in whitespace); - `withEnv` restored the environment at the first `await` rather than after the - body; temp directories were never cleaned up; the integration suite used fixed - ports, fixed sleeps, a shared output file, and never awaited process exit; and - the plan's claim that every row of the 3.6 error table had a test was false. - -Also folded in from that round: the plugin `options` argument, which opencode -really does pass as the second parameter (`J(Z, $.options)`), is now honoured -with env taking precedence over it; and `CHANGELOG.md` is a required deliverable -so the breaking header removal is disclosed somewhere durable rather than only -in a PR description. - -Not taken: rewriting the integration probe to drive a live provider. It asserts -the opencode-side contract plus the key our resolver derives from it, which is -the part that can break under an opencode upgrade; asserting provider-side cache -behavior needs credentials and a controlled baseline, and belongs to the -measurement work in section 7, not to a compatibility gate. From eafa50f2b7df830ddbf8690e5af6556912808b90 Mon Sep 17 00:00:00 2001 From: hayate Date: Sun, 30 Aug 2026 21:10:16 +0900 Subject: [PATCH 19/19] fix: stay silent on providers that have no cache key field Core seeds a prompt cache key only for a fixed set of provider SDKs. Anthropic caches by cache_control breakpoint, and every @ai-sdk/openai-compatible provider - DeepSeek among them - has no cache key in its API at all, so for those there is nothing correct to write. Warning about it fired on a routine configuration, read as a possible breakage, and is the fastest way to teach an operator to ignore the channel that also carries the states which do mean something: a renamed field, a missing sessionID, a foreign value on a provider that does support one. Those stay loud. This one moves to the debug log, where it keeps the explanation for anyone actually asking why no key was applied. The provider-label fallback test asserted against this warning, so it now observes the foreign-key path instead - a silent channel would have left that behaviour with no coverage at all. README gains a Provider support section naming both lists explicitly, including why setCacheKey: true is the wrong lever for an openai-compatible provider: core routes it to the camelCase spelling and the SDK forwards unrecognised options into the body verbatim, so the wire gets "promptCacheKey", which no OpenAI-style API reads. --- CHANGELOG.md | 9 ++++++- README.md | 42 +++++++++++++++++++++-------- plugins/opencode-context-cache.mjs | 15 ++++++++--- test/unit/logger.test.mjs | 7 ++--- test/unit/plugin-hook.test.mjs | 43 ++++++++++++++++++++++++------ 5 files changed, 89 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a649a60..1ccaed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,14 @@ - 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. + 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. diff --git a/README.md b/README.md index e62b8f2..1de6b0b 100644 --- a/README.md +++ b/README.md @@ -118,14 +118,33 @@ provider treats this field as a cache *lookup* key rather than a routing hint ## Provider support -This sets the OpenAI-family fields `promptCacheKey` and `prompt_cache_key`. It -applies wherever opencode itself sets one: OpenAI, Azure, xAI, Mistral, Venice, -DeepInfra, Cerebras, opencode's own provider, and anything you enable with -`setCacheKey: true`. - -It does **not** apply to Anthropic, which caches via `cache_control` breakpoints -on message content and ignores a cache key entirely. With an Anthropic provider -the plugin is inert and says so once on stderr. +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: + +**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`. + +**The plugin does nothing for everything else**, which is most of the catalog: + +- **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. + +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`). + +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. ## About the hashing @@ -157,11 +176,12 @@ request naming the fields it applied. 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. +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`. + Expected, informational: -- **"exposes no prompt cache key field"** - opencode placed no cache key field - for this provider. Normal for Anthropic and anything else that does not use - one. If it used to work and now does not, opencode may have renamed the field. - **"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. diff --git a/plugins/opencode-context-cache.mjs b/plugins/opencode-context-cache.mjs index 8a31bc0..d65136b 100644 --- a/plugins/opencode-context-cache.mjs +++ b/plugins/opencode-context-cache.mjs @@ -425,11 +425,18 @@ const OpenCodeContextCachePlugin = async (input = {}, options = {}) => { "the field. Prompt caching has reverted to a per-session key.", ); } else if (reason === "no-fields") { - logger.warnOnce( - `absent:${provider}`, + // 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 is expected for providers that do not support one; if it used to work, " + - "opencode may have renamed the field.", + "This provider does not support one; if it used to work, opencode may have " + + "renamed the field.", ); } diff --git a/test/unit/logger.test.mjs b/test/unit/logger.test.mjs index 49f767a..7143df6 100644 --- a/test/unit/logger.test.mjs +++ b/test/unit/logger.test.mjs @@ -134,9 +134,10 @@ 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"); - assert.equal(logger.warnOnce("absent:openai", "first"), true); - assert.equal(logger.warnOnce("absent:openai", "again"), false); - assert.equal(logger.warnOnce("absent:anthropic", "other"), true); + // 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"]); }); diff --git a/test/unit/plugin-hook.test.mjs b/test/unit/plugin-hook.test.mjs index fe9544c..ecd75a5 100644 --- a/test/unit/plugin-hook.test.mjs +++ b/test/unit/plugin-hook.test.mjs @@ -197,22 +197,46 @@ test("a nested directory shares the key of its worktree root", async () => { }); }); -test("a missing cache key field warns once per provider, with debug off", async () => { +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(), { options: {} }); - await hooks["chat.params"](hookInput(), { options: {} }); + await hooks["chat.params"](hookInput({ model: { providerID: "deepseek" } }), { options: {} }); await hooks["chat.params"](hookInput({ model: { providerID: "anthropic" } }), { options: {} }); - assert.equal(warnings.length, 2, "one per provider, not one per request"); - assert.match(warnings[0], /openai/); - assert.match(warnings[1], /anthropic/); + 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 = []; @@ -416,8 +440,11 @@ test("the provider label falls back to provider.info.id, then to unknown", async { directory: "/srv/repo", worktree: "/srv/repo" }, { warn: (m) => warnings.push(m) }, ); - await hooks["chat.params"]({ sessionID: SESSION, provider: { info: { id: "via-info" } } }, { options: {} }); - await hooks["chat.params"]({ sessionID: SESSION }, { options: {} }); + // 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/); });