diff --git a/CHANGELOG.md b/CHANGELOG.md index 824cea9..39671c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Fixed + +- **Compaction and warming keep the prompt cache on OpenCode 2.** Review, Shell and Trail added + their system text to the conversation's requests only, through the session `context` hook. + OpenCode 2 builds compaction and `generate` (warming) requests through their own hooks, so those + went out with a shorter system block — 4 parts where the conversation had 9 — and the provider + wrote the whole conversation to its cache again instead of reading it. The same text is now added + on all three hooks; a compaction's system block matches the conversation's exactly. Reported in + #53. + ## [0.11.1] - 2026-10-17 ### Added diff --git a/packages/client/src/opencode/server/index.ts b/packages/client/src/opencode/server/index.ts index 4a598c5..c97af2d 100644 --- a/packages/client/src/opencode/server/index.ts +++ b/packages/client/src/opencode/server/index.ts @@ -309,9 +309,14 @@ export function dualServer(id: string, start: ServerStart) { } if (parts.system) { const system = parts.system - await ctx.session.hook("context", async (event) => { + /** + * Every request kind gets the same text: a compaction or warming request whose system part + * differs from the conversation's misses the provider's prompt cache and prefills it again. + */ + const add = async (event: { sessionID: string; system: unknown[] }) => { for (const text of await system(event.sessionID)) event.system.push({ type: "text", text }) - }) + } + for (const kind of ["context", "compaction", "generate"] as const) await ctx.session.hook(kind, add) } const stop = new AbortController() if (parts.sessionDeleted || parts.event) { diff --git a/packages/client/src/opencode/server/v2.ts b/packages/client/src/opencode/server/v2.ts index 1012606..876191d 100644 --- a/packages/client/src/opencode/server/v2.ts +++ b/packages/client/src/opencode/server/v2.ts @@ -39,7 +39,11 @@ export interface V2ServerContext { prompt?(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise /** A session's messages as the model is given them (2.0.15). */ context?(input: { sessionID: string }): Promise - hook(name: "context", run: (event: { sessionID: string; system: unknown[] }) => unknown): Promise + /** One hook per request kind: `context` is the primary request only, not compaction or `generate`. */ + hook( + name: "context" | "compaction" | "generate", + run: (event: { sessionID: string; system: unknown[] }) => unknown, + ): Promise } event: { subscribe(options: { signal: AbortSignal }): AsyncIterable } } diff --git a/packages/client/test/server.test.ts b/packages/client/test/server.test.ts index a430dcc..5eb3204 100644 --- a/packages/client/test/server.test.ts +++ b/packages/client/test/server.test.ts @@ -179,7 +179,7 @@ function fakeV2(events: { type: string; data?: { sessionID?: string } }[] = [], const skills: { id: string; path: string }[] = [] const commands: { name: string; execute(input: unknown): Promise }[] = [] const prompts: unknown[] = [] - let context: ((event: { sessionID: string; system: unknown[] }) => unknown) | undefined + const runs = new Map unknown>() const ctx = { options: {}, location: { directory }, @@ -205,9 +205,9 @@ function fakeV2(events: { type: string; data?: { sessionID?: string } }[] = [], get: async () => undefined, synthetic: async () => undefined, prompt: async (input: unknown) => void prompts.push(input), - hook: async (name: string, run: typeof context) => { + hook: async (name: string, run: (event: { sessionID: string; system: unknown[] }) => unknown) => { hooks.push(name) - context = run + runs.set(name, run) }, }, event: { @@ -216,7 +216,7 @@ function fakeV2(events: { type: string; data?: { sessionID?: string } }[] = [], }, }, } - return { ctx, added, hooks, skills, commands, prompts, system: () => context } + return { ctx, added, hooks, skills, commands, prompts, system: (kind = "context") => runs.get(kind) } } describe("one entry for both", () => { @@ -231,7 +231,7 @@ describe("one entry for both", () => { expect(started).toBe(false) }) - test("v2's setup registers the tools, the context hook, and follows deleted sessions", async () => { + test("v2's setup registers the tools, the request hooks, and follows deleted sessions", async () => { const deleted: string[] = [] let disposed = false const fake = fakeV2([{ type: "session.idle" }, { type: "session.deleted", data: { sessionID: "ses_9" } }]) @@ -249,10 +249,13 @@ describe("one entry for both", () => { }) const cleanup = await entry.setup(fake.ctx as never) expect(fake.added).toContain("echo") - expect(fake.hooks).toEqual(["context"]) - const event = { sessionID: "ses_1", system: [] as unknown[] } - await fake.system()?.(event) - expect(event.system).toEqual([{ type: "text", text: "guidance for ses_1" }]) + /** Compaction and warming carry the same system text, or they miss the prompt cache. */ + expect(fake.hooks).toEqual(["context", "compaction", "generate"]) + for (const kind of fake.hooks) { + const event = { sessionID: "ses_1", system: [] as unknown[] } + await fake.system(kind)?.(event) + expect(event.system).toEqual([{ type: "text", text: "guidance for ses_1" }]) + } await Bun.sleep(0) expect(deleted).toEqual(["ses_9"]) await cleanup?.()