From 17745a1cdfd8acf2b44406d6d6df3788713cd654 Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Fri, 19 Jun 2026 15:12:18 +0200 Subject: [PATCH 1/2] fix: keep opencode enrich out of system prompt --- .../plan.md | 199 ++++++++++++++++++ .../todo.md | 120 +++++++++++ plugin/opencode/README.md | 31 +-- plugin/opencode/agentmemory-capture.ts | 34 ++- test/opencode-prompt-cache.test.ts | 152 +++++++++++++ 5 files changed, 505 insertions(+), 31 deletions(-) create mode 100644 docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/plan.md create mode 100644 docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md create mode 100644 test/opencode-prompt-cache.test.ts diff --git a/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/plan.md b/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/plan.md new file mode 100644 index 000000000..573303dc2 --- /dev/null +++ b/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/plan.md @@ -0,0 +1,199 @@ +# Issue 287 Prompt Cache System Mutation 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:** Keep OpenCode's system prompt stable after first-turn memory context injection by moving volatile file enrichment into message parts. + +**Architecture:** Preserve the current file-stash sources and `/agentmemory/enrich` request shape. `experimental.chat.system.transform` remains responsible only for stable first-turn instructions/context; `chat.message` becomes the volatile enrichment injection point and tags injected text so prompt observation ignores it. + +**Tech Stack:** TypeScript ESM, OpenCode plugin hook object, Vitest, mocked `fetch`. + +--- + +## Sprint Contract + +Goal: Fix issue #287 without changing API, schema, auth, dependencies, versioning, or remote boundaries. + +Scope: +- Modify `plugin/opencode/agentmemory-capture.ts`. +- Modify `plugin/opencode/README.md`. +- Add a focused test file such as `test/opencode-prompt-cache.test.ts`. +- Update `docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md`. + +Non-goals: +- Do not change `/agentmemory/enrich`, REST endpoint registration, MCP tools, persisted schemas, auth behavior, package versions, dependency manifests, or provider code. +- Do not remove first-turn `AGENTMEMORY_INSTRUCTIONS` or `/context` system injection. +- Do not run or rely on `upstream`. + +Acceptance criteria: +- `chat.system.transform` appends stable first-turn instructions/context but never file-specific `/enrich` context. +- `chat.message` appends returned enrich context to `output.parts` as a text part. +- Processed stashed files are cleared after successful message-part injection. +- The prompt observation payload excludes injected enrich text. +- OpenCode README no longer documents file enrichment as system-prompt injection. + +Intended verification: +- Red: `corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-prompt-cache.test.ts` fails on the current code. +- Green: the same targeted test passes after implementation. +- Targeted existing tests: `corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-auto-context.test.ts test/integration-plaintext-http.test.ts test/context-injection.test.ts test/hook-source-smoke.test.ts`. +- Broader checks: `corepack pnpm run lint`, `corepack pnpm run build`, `corepack pnpm test`. +- Security gates: `semgrep scan --config p/default --error --metrics=off .`; after staging, `gitleaks protect --staged --redact`. + +## Feature / Verification Matrix + +| Change | Verification method | Status | Evidence | +| --- | --- | --- | --- | +| Remove `/enrich` from system transform | New handler-level red/green test | Pending | Not run | +| Add `/enrich` message-part injection | New handler-level red/green test | Pending | Not run | +| Exclude injected context from prompt observation | New handler-level red/green test | Pending | Not run | +| Update docs | Stale-reference search and diff inspection | Pending | Not run | +| Preserve existing behavior | Existing targeted tests and full checks | Pending | Not run | + +## Files + +- Create: `test/opencode-prompt-cache.test.ts` +- Modify: `plugin/opencode/agentmemory-capture.ts` +- Modify: `plugin/opencode/README.md` +- Modify: `docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md` + +## Task 1: Add failing OpenCode prompt-cache regression + +**Files:** +- Create: `test/opencode-prompt-cache.test.ts` + +- [ ] **Step 1: Add a plugin loader and fetch recorder** + +Use the existing import/reset pattern from `test/integration-plaintext-http.test.ts`: reset modules, set `AGENTMEMORY_URL=http://localhost:3111`, stub `globalThis.fetch`, import `AgentmemoryCapturePlugin`, and instantiate it with `{ worktree: "/tmp/project" }`. + +- [ ] **Step 2: Write the system-transform regression** + +Test flow: +1. Start a session through the plugin `event` handler. +2. Call `experimental.chat.system.transform` once to consume first-turn stable context. +3. Stash `src/a.ts` through `tool.execute.before`. +4. Call `experimental.chat.system.transform` again with `output.system = ["base"]`. +5. Assert no `/agentmemory/enrich` request was made during that second system-transform call. +6. Assert `output.system` remains `["base"]`. + +Expected red result on current code: the second system-transform call posts to `/agentmemory/enrich` and appends the mocked enrich context to `output.system`. + +- [ ] **Step 3: Write the message-part injection regression** + +Test flow: +1. Start a fresh session. +2. Stash `src/b.ts` through `tool.execute.before`. +3. Call `chat.message` with `output.parts = [{ type: "text", text: "user prompt" }]`. +4. Assert `/agentmemory/enrich` was called with `{ sessionId: "ses-2", files: ["src/b.ts"], toolName: "enrich_inject" }`. +5. Assert `output.parts` contains the user text plus a text part whose `text` is the mocked enrich context and whose `synthetic` flag is true. +6. Assert the `/agentmemory/observe` prompt payload is `"user prompt"` and does not contain the enrich context. +7. Call `chat.message` again with a new prompt and no new stashed file, then assert no second `/enrich` request occurs. + +Expected red result on current code: `chat.message` does not call `/enrich` and does not append an enrich text part. + +- [ ] **Step 4: Run the red test** + +Run: + +```bash +corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-prompt-cache.test.ts +``` + +Expected: the new tests fail for the two issue-specific reasons above. + +## Task 2: Move volatile enrich injection to `chat.message` + +**Files:** +- Modify: `plugin/opencode/agentmemory-capture.ts` + +- [ ] **Step 1: Add message-part enrich injection after file stashing** + +Inside the existing `"chat.message"` handler, after file parts are added to `stashFor(sid)` and before prompt observation, read `const stash = stashFor(sid)`, return early if empty, call `postJson("/enrich", { sessionId: sid, files, toolName: "enrich_inject" })`, and append `{ type: "text", text: enrichCtx, synthetic: true }` to `output.parts` when `enrichCtx` is a non-empty string. + +- [ ] **Step 2: Preserve stash deletion semantics** + +Delete only the files included in the enrich request, and only after appending the message part. Keep the existing ten-file slice and `MAX_STASHED_FILES` behavior. + +- [ ] **Step 3: Remove the volatile system-transform block** + +Delete the `stashFor(sid)` through `output.system.push(enrichCtx)` block from `experimental.chat.system.transform`. Leave the first-turn instructions/context block unchanged. + +- [ ] **Step 4: Run the targeted green test** + +Run: + +```bash +corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-prompt-cache.test.ts +``` + +Expected: the new tests pass. + +## Task 3: Update OpenCode README + +**Files:** +- Modify: `plugin/opencode/README.md` + +- [ ] **Step 1: Update the file-enrichment table** + +Change the enrichment row from `experimental.chat.system.transform` / `output.system[]` to `chat.message` / `output.parts[]`. Keep memory context injection documented as `experimental.chat.system.transform` / `output.system[]`. + +- [ ] **Step 2: Rewrite the two-layer pipeline text** + +Describe `experimental.chat.system.transform` as first-turn stable system context only, and `chat.message` as volatile file-enrichment message-part injection. + +- [ ] **Step 3: Update the prompt layout diagram** + +Show a stable system prompt and a message stream containing user message plus optional file enrichment. Remove wording that says per-turn file enrichment mutates the system prompt. + +- [ ] **Step 4: Search stale references** + +Run: + +```bash +rg -n "Enrichment inject|output\\.system\\[\\]|system prompt.*file enrichment|file enrichment.*system prompt|output\\.system\\.push\\(enrichCtx\\)" plugin/opencode README.md test +``` + +Expected: no stale OpenCode README/test references claim file enrichment is injected through `output.system[]`; source may still contain first-turn system context references. + +## Task 4: Focused cleanup and verification + +**Files:** +- Modify touched files only if needed. +- Modify: `docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md` + +- [ ] **Step 1: Simple-code pass** + +Inspect only the active diff for duplicate stash handling, confusing local names, unnecessary comments, or over-broad helper extraction. Preserve behavior, prompt layout, request shape, auth, API, schema, persistence, and versioning. + +- [ ] **Step 2: Run targeted existing tests** + +Run: + +```bash +corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-prompt-cache.test.ts test/opencode-auto-context.test.ts test/integration-plaintext-http.test.ts test/context-injection.test.ts test/hook-source-smoke.test.ts +``` + +Expected: all targeted tests pass. + +- [ ] **Step 3: Run broader verification** + +Run: + +```bash +corepack pnpm run lint +corepack pnpm run build +corepack pnpm test +semgrep scan --config p/default --error --metrics=off . +``` + +Expected: all pass, or blockers are recorded with closest targeted evidence. + +- [ ] **Step 4: Update task record** + +Record red/green evidence, final verification, Sprint Contract status, Feature / Verification Matrix status, review notes, and residual risks in `docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md`. + +## Self-Review + +- Spec coverage: issue validity, red/green regression, plugin fix, docs update, verification, and security gates are covered. +- Placeholder scan: no unresolved placeholders or broad "handle edge cases" steps remain. +- Boundary check: no dependency, version, API, auth, schema, MCP, REST, provider, or remote changes are planned. +- Delegation check: arena validity was delegated. Implementation is tightly coupled around one plugin handler pair, so the lead agent will implement inline after the failing test and use focused review/verification rather than overlapping worker edits. diff --git a/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md b/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md new file mode 100644 index 000000000..515cc0529 --- /dev/null +++ b/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md @@ -0,0 +1,120 @@ +# Issue 287 Prompt Cache System Mutation + +Scope: GitHub issue #287 in branch `issue/287-prompt-cache-system-mutation`. + +## Validity Evidence + +- Worktree: `/Users/A1538552/.codex/worktrees/3a3f/agentmemory`. +- Start state: detached `HEAD` at `96498f568eb960d722abecc08f9d7f5a1597a93a`, matching `origin/main`; switched to new branch `issue/287-prompt-cache-system-mutation`. +- Remote boundary: use only `origin` (`https://github.com/wbugitlab1/agentmemory.git`); do not target `upstream` (`https://github.com/rohitg00/agentmemory.git`). +- GitHub issue #287 is open and no matching PR was found for the specified fork. +- Current code still has the reported behavior: `plugin/opencode/agentmemory-capture.ts` reads a per-session file stash in `experimental.chat.system.transform`, calls `postJson("/enrich", ...)`, and pushes `enrichCtx` into `output.system`. +- `chat.message` currently observes prompt text and stashes file parts, but does not call `/enrich` or append enrichment to `output.parts`. +- `/agentmemory/enrich` returns variable file/session/memory context from `src/functions/enrich.ts`, so appending it to `output.system` can change the model prefix on file-touching turns. +- `plugin/opencode/README.md` still documents file enrichment as `experimental.chat.system.transform` -> `output.system[]`. +- One issue subclaim is stale: current tool visibility defaults to `core`, not `all`; that does not invalidate the system-prompt mutation root cause. + +## Sprint Contract + +Goal: Preserve OpenCode prompt-cache stability by moving volatile per-turn `/agentmemory/enrich` injection out of `output.system` and into the user message stream. + +Scope: +- `plugin/opencode/agentmemory-capture.ts` +- `plugin/opencode/README.md` +- Focused OpenCode plugin tests under `test/` + +Non-goals: +- No REST endpoint, MCP tool, persisted schema, auth, dependency, package-manager, provider, remote, or version changes. +- No changes to first-turn `AGENTMEMORY_INSTRUCTIONS` or `/context` system injection. +- No work on issues other than #287. + +Acceptance criteria: +- `experimental.chat.system.transform` keeps first-turn stable instructions/context injection but never calls `/enrich` or appends file-specific enrich context to `output.system`. +- `chat.message` consumes stashed file paths, calls the existing `/agentmemory/enrich` endpoint with the existing payload shape, appends returned context to `output.parts`, and clears only injected files. +- Prompt observation does not record injected agentmemory context as user-authored prompt text. +- OpenCode README documents stable system context plus volatile message-part file enrichment. +- Regression tests fail on the current implementation and pass after the fix. + +Intended verification: +- Red targeted vitest run for the new OpenCode prompt-cache regression. +- Green targeted vitest run for the same test plus existing OpenCode context tests. +- `corepack pnpm run lint`, `corepack pnpm test`, and `corepack pnpm run build` where dependency state permits. +- Required security gates before commit or handoff for this non-trivial code/docs/test change: Semgrep and staged Gitleaks after staging. + +Known boundaries: +- The delegated automation request authorizes routine issue reads, branch setup, branch push, PR creation, clean PR merge to `origin/main`, and post-success thread archival for this issue after green verification. +- Human Checkpoints still apply for scope expansion, API/auth/schema/dependency/architecture changes, skipped or failing verification, divergent validity conclusions, and accepted security or quality risks. +- `upstream` remote exists but is out of scope. +- Existing issue #148/#258 and issue #821-#830 worktrees are out of scope and must not be touched. + +Stop conditions: +- A fix requires changing OpenCode plugin API contracts beyond moving the existing enrich payload to `output.parts`. +- Verification is blocked by missing dependencies, package-manager hardening, security findings, or scanner failures that cannot be resolved inside scope. +- GitHub PR checks fail or the PR cannot be cleanly merged to `origin/main`. + +## Feature / Verification Matrix + +| Change | Verification method | Status | Evidence | +| --- | --- | --- | --- | +| Remove volatile enrich from system prompt | New handler-level regression test checks `chat.system.transform` does not call `/enrich` and does not append enrich context | Done | Red run failed because `/agentmemory/enrich` was called from system transform; green run passed | +| Inject enrich through message parts | New handler-level regression test checks `chat.message` calls `/enrich`, appends a text part, and clears processed stash | Done | Red run failed with 0 enrich calls from `chat.message`; green run passed | +| Avoid recording injected context as user prompt | New test checks `/observe` prompt payload excludes enrich context | Done | Green test asserts `/observe` prompt is only user text | +| Update OpenCode docs | README diff plus stale-reference search | Done | README updated; stale-reference search only finds new message-part enrich and stable `/context` system references | +| Preserve existing OpenCode/context behavior | Existing targeted tests and full suite | Done | Targeted suite passed 5 files / 57 tests; full suite passed 207 files / 2827 tests | + +## Subagent Ledger + +| Workstream | Scope | Edits allowed | Expected output | Result | Residual risk | +| --- | --- | --- | --- | --- | --- | +| Arena candidate 1 | Issue validity/reproduction | No repo edits | Validity report | Valid; recommended moving `/enrich` to `chat.message`; identified docs/tests | Runtime OpenCode telemetry not run | +| Arena candidate 2 | Issue validity/reproduction | No repo edits | Validity report | Selected base; strongest line-level evidence, boundary notes, and regression recommendations | Exact OpenCode message-part metadata needs implementation confirmation | +| Arena candidate 3 | Issue validity/reproduction | No repo edits | Validity report | Valid; added conditionality, stale `AGENTMEMORY_TOOLS` note, prompt-observation caveat | Runtime OpenCode telemetry not run | +| Arena judge | Read-only comparison | No repo edits | Score table and base recommendation | Recommended Candidate 2 as base with Candidate 3 grafts; no validity disagreement | Did not independently re-query remote issue state | + +## Arena Synthesis + +Base: Candidate 2. + +Grafts: +- Candidate 3 caveat: the failure is conditional on non-empty stashed files and non-empty `/enrich` context, not every turn unconditionally. +- Candidate 3 caveat: the `AGENTMEMORY_TOOLS=all` aggravating factor is stale because current code defaults to `core`. +- Candidate 3 caveat: injected message parts should be marked so prompt observation does not treat agentmemory context as user-authored text, without preventing OpenCode from sending the text part to the model. +- Candidate 1 distinction: issue #309 is about deterministic extraction-cache keys, not this OpenCode provider prompt-cache mutation. + +Validity decision: valid with high confidence. No arena disagreement. + +## Progress + +- Read repo instructions and relevant process skills. +- Confirmed git state and remotes. +- Created branch `issue/287-prompt-cache-system-mutation`. +- Read GitHub issue #287 and local affected code/docs/tests. +- Completed arena validity investigation and cross-judge. +- Created this task record before implementation edits. +- Created `test/opencode-prompt-cache.test.ts`. +- Red test evidence: `corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-prompt-cache.test.ts` failed as expected before implementation: + - `chat.system.transform` still called `/agentmemory/enrich`. + - `chat.message` had 0 `/agentmemory/enrich` calls. +- Dependency setup note: first pnpm exec was blocked by ignored-build hardening during implicit install. Ran repo-approved `corepack pnpm install --frozen-lockfile --ignore-scripts`, then reran the test. No build approvals were added. +- Removed a generated `allowBuilds` scaffold from `pnpm-workspace.yaml`; package-manager config remains unchanged. +- Implemented the fix: + - `chat.message` now calls `/enrich` for stashed files and appends returned context as a synthetic text part. + - `experimental.chat.system.transform` no longer reads the stash or appends `/enrich` context to `output.system`. + - First-turn `AGENTMEMORY_INSTRUCTIONS` and `/context` system injection remain unchanged. +- Updated `plugin/opencode/README.md` to document stable system context plus volatile message-part file enrichment. +- Green verification evidence: + - `corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-prompt-cache.test.ts` passed 1 file / 2 tests. + - `corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-prompt-cache.test.ts test/opencode-auto-context.test.ts test/integration-plaintext-http.test.ts test/context-injection.test.ts test/hook-source-smoke.test.ts` passed 5 files / 57 tests. It emitted existing Vite source-map warnings for packaged hook `.mjs` files before the build regenerated maps. + - `corepack pnpm run lint` passed. + - `corepack pnpm run build` passed with existing tsdown plugin-timing and ineffective dynamic-import warnings. + - `corepack pnpm test` passed 207 files / 2827 tests. + - `semgrep scan --config p/default --error --metrics=off .` passed with 0 findings. + +## Final Review Notes + +- Security/privacy reviewer: `ACCEPT`. +- Test coverage reviewer: `ACCEPT`. +- Maintainability/integration reviewer: `ACCEPT`. +- Sprint Contract status: acceptance criteria met locally. +- No API, auth, schema, MCP, REST, dependency, provider, package-manager, version, or remote changes were made. +- Residual risk: no live OpenCode/provider token telemetry was collected; the fix is verified at the plugin handler boundary and by source/docs checks. diff --git a/plugin/opencode/README.md b/plugin/opencode/README.md index f929d2550..f67301f5e 100644 --- a/plugin/opencode/README.md +++ b/plugin/opencode/README.md @@ -127,7 +127,7 @@ When `AGENTMEMORY_SECRET` is set for a remote server, use `https://` or a loopba | File tool params | `tool.execute.before` → stash paths | — | | File edited | `file.edited` → stash paths | — | | File part attached | `message.part.updated` (file) → stash paths | — | -| Enrichment inject | `experimental.chat.system.transform` | POST /enrich → `output.system[]` | +| Enrichment inject | `chat.message` | POST /enrich → `output.parts[]` | | Memory context inject | `experimental.chat.system.transform` | POST /context → `output.system[]` | ### Permissions @@ -154,24 +154,28 @@ When `AGENTMEMORY_SECRET` is set for a remote server, use `https://` or a loopba ### File enrichment + memory injection (two-layer pipeline) -`experimental.chat.system.transform` fires before every LLM call and injects two layers of context: +OpenCode gets two layers of context without mutating the system prompt on every file-touching turn: -1. **Memory context** (once per session): calls `/agentmemory/context` and injects project profile, recent session summaries, and important past observations into the system prompt. This is the OpenCode equivalent of Claude's MEMORY.md bridge — instead of syncing to a markdown file, context is injected directly into the system prompt. +1. **Memory context** (once per session): `experimental.chat.system.transform` calls `/agentmemory/context` and injects project profile, recent session summaries, and important past observations into the system prompt. This is the OpenCode equivalent of Claude's MEMORY.md bridge — instead of syncing to a markdown file, stable session context is injected directly into the system prompt. -2. **File enrichment** (every turn with stashed files): calls `/agentmemory/enrich` with files stashed by `tool.execute.before`, `file.edited`, and `message.part.updated` (file parts). File-specific context (past observations, related bugs, semantic search) is injected into the system prompt. +2. **File enrichment** (when `chat.message` sees stashed files): calls `/agentmemory/enrich` with files stashed by `tool.execute.before`, `file.edited`, and `message.part.updated` (file parts). File-specific context (past observations, related bugs, semantic search) is appended as a message text part so the system prompt remains stable for provider prompt caches. ```text -System prompt = [OpenCode instructions] + [memory context] + [file enrichment] + [user message] - ^ ^ - first turn only every file-touching turn +System prompt = [OpenCode instructions] + [memory context] + ^ + first turn only + +Message stream = [user message] + [file enrichment text part] + ^ + when stashed files exist ``` **Differences from Claude's PreToolUse:** | Dimension | Claude (PreToolUse) | OpenCode (two-hop pipeline) | |---|---|---| -| Injection mechanism | stdout → context window | `output.system[]` → system prompt | -| Timing | Same turn (parallel with tool) | Next turn (before next LLM call) | +| Injection mechanism | stdout → context window | `output.parts[]` → message stream | +| Timing | Same turn (parallel with tool) | Next user message after files are stashed | | File set | Per-tool (immediate) | Batched (all files since last enrichment) | | Coverage | Edit/Write/Read/Glob/Grep only | Edit/Write/Read/Glob/Grep only | | What gets injected | `` + bug memories | Identical `/enrich` response | @@ -194,11 +198,12 @@ agentmemory ──write──▶ MEMORY.md ──read──▶ Claude system ### OpenCode: direct injection (one-hop) ``` -agentmemory ──push──▶ OpenCode system prompt +agentmemory ──push──▶ OpenCode system prompt + message stream ``` -- `experimental.chat.system.transform` calls `/context` at runtime and pushes the response directly into `output.system[]` -- **Always current** — context is fetched at session start (once) and before file-touching turns (per-batch) +- `experimental.chat.system.transform` calls `/context` at runtime and pushes stable session context directly into `output.system[]` +- `chat.message` calls `/enrich` for stashed files and pushes volatile file context into `output.parts[]` +- **Fresh at injection points** — session context is fetched once, and file enrichment is fetched when stashed files are attached to a user message - **No file intermediary** — no stale copies, no merge conflicts, no disk I/O - `AGENTS.md` is a static instruction file for project conventions, coding standards, and tool guidance — agentmemory does not read or write it @@ -208,7 +213,7 @@ agentmemory ──push──▶ OpenCode system prompt |---|---|---| | Freshness | Stale between syncs | Always current (fetched at call time) | | Visibility | Human-readable file in repo | In-memory injection only | -| Simplicity | Two moving parts (bridge + file) | One step (API → system prompt) | +| Simplicity | Two moving parts (bridge + file) | One step (API → prompt/message stream) | | Team sharing | File is git-trackable, CI-friendly | Memory shared via agentmemory server API | | Integration | Any tool can read MEMORY.md | Requires OpenCode plugin SDK | diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts index a78874dcd..d6c0097c1 100644 --- a/plugin/opencode/agentmemory-capture.ts +++ b/plugin/opencode/agentmemory-capture.ts @@ -592,6 +592,22 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { } } + const stash = stashFor(sid); + if (stash.size > 0) { + const enrichFiles = [...stash].slice(0, 10); + const enrichResult = await postJson("/enrich", { + sessionId: sid, + files: enrichFiles, + toolName: "enrich_inject", + }); + + const enrichCtx = (enrichResult as any)?.context; + if (typeof enrichCtx === "string" && enrichCtx.length > 0) { + parts.push({ type: "text", text: enrichCtx, synthetic: true }); + for (const f of enrichFiles) stash.delete(f); + } + } + const textParts = parts.filter((p: any) => p.type === "text" && !p.synthetic && !p.ignored); const userText = textParts.map((p: any) => p.text || "").join("\n"); @@ -667,24 +683,6 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { } contextInjectedSessions.add(sid); } - - const stash = stashFor(sid); - if (stash.size === 0) return; - const files = [...stash].slice(0, 10); - - const enrichResult = await postJson("/enrich", { - sessionId: sid, - files, - toolName: "enrich_inject", - }); - - const enrichCtx = (enrichResult as any)?.context; - if (typeof enrichCtx === "string" && enrichCtx.length > 0) { - if (Array.isArray(output.system)) { - output.system.push(enrichCtx); - } - for (const f of files) stash.delete(f); - } }, // ── experimental.session.compacting (WIP) ── diff --git a/test/opencode-prompt-cache.test.ts b/test/opencode-prompt-cache.test.ts new file mode 100644 index 000000000..6486d363f --- /dev/null +++ b/test/opencode-prompt-cache.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const originalEnv = { ...process.env }; +const originalFetch = globalThis.fetch; + +type PluginHandlers = { + event: (input: { event: Record }) => Promise; + "chat.message": ( + input: Record, + output: { parts: Array> }, + ) => Promise; + "tool.execute.before": ( + input: { sessionID?: string; tool: string }, + output: { args?: Record }, + ) => Promise; + "experimental.chat.system.transform": ( + input: { sessionID?: string }, + output: { system: string[] }, + ) => Promise; +}; + +type FetchCall = { + path: string; + body: Record; +}; + +function response(body: Record): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function fetchCalls(fetchMock: ReturnType): FetchCall[] { + return fetchMock.mock.calls.map(([url, init]) => ({ + path: new URL(String(url)).pathname, + body: JSON.parse(String((init as RequestInit).body ?? "{}")) as Record, + })); +} + +async function loadPlugin(fetchMock: ReturnType): Promise { + vi.resetModules(); + process.env = { + ...originalEnv, + AGENTMEMORY_URL: "http://localhost:3111", + AGENTMEMORY_SECRET: "", + }; + vi.stubGlobal("fetch", fetchMock); + const mod = await import("../plugin/opencode/agentmemory-capture.ts"); + return await mod.AgentmemoryCapturePlugin({ worktree: "/tmp/project" }) as PluginHandlers; +} + +async function startSession(plugin: PluginHandlers, sessionId: string): Promise { + await plugin.event({ + event: { + type: "session.created", + properties: { info: { id: sessionId, title: "Prompt cache test" } }, + }, + }); +} + +describe("OpenCode prompt-cache-safe enrichment (#287)", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + globalThis.fetch = originalFetch; + process.env = { ...originalEnv }; + }); + + it("does not inject per-file enrich context through chat.system.transform", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + const body = JSON.parse(String(init?.body ?? "{}")) as { files?: string[] }; + if (path.endsWith("/session/start")) return response({ context: "stable session context" }); + if (path.endsWith("/enrich")) return response({ context: `enrich:${body.files?.join(",")}` }); + return response({ success: true }); + }); + const plugin = await loadPlugin(fetchMock); + + await startSession(plugin, "ses-system"); + await plugin["experimental.chat.system.transform"]( + { sessionID: "ses-system" }, + { system: [] }, + ); + + await plugin["tool.execute.before"]( + { sessionID: "ses-system", tool: "Read" }, + { args: { filePath: "src/a.ts" } }, + ); + const beforeSystemTransform = fetchMock.mock.calls.length; + const output = { system: ["base"] }; + + await plugin["experimental.chat.system.transform"]( + { sessionID: "ses-system" }, + output, + ); + + const newCalls = fetchCalls(fetchMock).slice(beforeSystemTransform); + expect(newCalls.map((call) => call.path)).not.toContain("/agentmemory/enrich"); + expect(output.system).toEqual(["base"]); + }); + + it("injects per-file enrich context through chat.message without recording it as user prompt", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + const body = JSON.parse(String(init?.body ?? "{}")) as { files?: string[] }; + if (path.endsWith("/session/start")) return response({}); + if (path.endsWith("/enrich")) return response({ context: `enrich:${body.files?.join(",")}` }); + return response({ success: true }); + }); + const plugin = await loadPlugin(fetchMock); + + await startSession(plugin, "ses-message"); + await plugin["tool.execute.before"]( + { sessionID: "ses-message", tool: "Read" }, + { args: { filePath: "src/b.ts" } }, + ); + + const firstOutput = { parts: [{ type: "text", text: "user prompt" }] }; + await plugin["chat.message"]( + { sessionID: "ses-message", agent: "build", model: null, variant: null }, + firstOutput, + ); + + const calls = fetchCalls(fetchMock); + const enrichCalls = calls.filter((call) => call.path === "/agentmemory/enrich"); + expect(enrichCalls).toHaveLength(1); + expect(enrichCalls[0].body).toMatchObject({ + sessionId: "ses-message", + files: ["src/b.ts"], + toolName: "enrich_inject", + }); + expect(firstOutput.parts).toEqual([ + { type: "text", text: "user prompt" }, + { type: "text", text: "enrich:src/b.ts", synthetic: true }, + ]); + + const observeCalls = calls.filter((call) => call.path === "/agentmemory/observe"); + expect(observeCalls.at(-1)?.body).toMatchObject({ + hookType: "prompt_submit", + data: { prompt: "user prompt" }, + }); + + const beforeSecondMessage = fetchMock.mock.calls.length; + await plugin["chat.message"]( + { sessionID: "ses-message", agent: "build", model: null, variant: null }, + { parts: [{ type: "text", text: "next prompt" }] }, + ); + const secondCalls = fetchCalls(fetchMock).slice(beforeSecondMessage); + expect(secondCalls.map((call) => call.path)).not.toContain("/agentmemory/enrich"); + }); +}); From 0fea099518ee6162f28f1c2a0f1ffd76a6ce97ec Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Fri, 19 Jun 2026 15:15:30 +0200 Subject: [PATCH 2/2] docs: record issue 287 post-merge verification --- .../todo.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md b/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md index 515cc0529..cd5f71c5c 100644 --- a/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md +++ b/docs/todos/2026-06-19-issue-287-prompt-cache-system-mutation/todo.md @@ -118,3 +118,11 @@ Validity decision: valid with high confidence. No arena disagreement. - Sprint Contract status: acceptance criteria met locally. - No API, auth, schema, MCP, REST, dependency, provider, package-manager, version, or remote changes were made. - Residual risk: no live OpenCode/provider token telemetry was collected; the fix is verified at the plugin handler boundary and by source/docs checks. +- Post-merge base update: + - Fetched `origin/main` at `b5c3cac7`. + - Merged `origin/main` into the issue branch with merge commit `f48ecb5e`; PR diff against updated `origin/main` remained scoped to the five task-owned files. + - Post-merge `corepack pnpm exec vitest run --exclude test/integration.test.ts test/opencode-prompt-cache.test.ts test/opencode-auto-context.test.ts test/integration-plaintext-http.test.ts test/context-injection.test.ts test/hook-source-smoke.test.ts` passed 5 files / 57 tests. + - Post-merge `corepack pnpm run lint` passed. + - Post-merge `corepack pnpm run build` passed with existing tsdown plugin-timing and ineffective dynamic-import warnings. + - Post-merge `corepack pnpm test` passed 207 files / 2827 tests. + - Post-merge `semgrep scan --config p/default --error --metrics=off .` passed with 0 findings.