Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/opencode/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion packages/client/src/opencode/server/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export interface V2ServerContext {
prompt?(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise<unknown>
/** A session's messages as the model is given them (2.0.15). */
context?(input: { sessionID: string }): Promise<unknown[]>
hook(name: "context", run: (event: { sessionID: string; system: unknown[] }) => unknown): Promise<unknown>
/** 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<unknown>
}
event: { subscribe(options: { signal: AbortSignal }): AsyncIterable<V2Event> }
}
Expand Down
21 changes: 12 additions & 9 deletions packages/client/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> }[] = []
const prompts: unknown[] = []
let context: ((event: { sessionID: string; system: unknown[] }) => unknown) | undefined
const runs = new Map<string, (event: { sessionID: string; system: unknown[] }) => unknown>()
const ctx = {
options: {},
location: { directory },
Expand All @@ -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: {
Expand All @@ -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", () => {
Expand All @@ -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" } }])
Expand All @@ -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?.()
Expand Down
Loading