From 14a44cfef1d3a8c0c51426cb79fdc23d016e153a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 22:48:35 -0400 Subject: [PATCH 1/3] refactor(core): route title and compaction through shared model requests --- .changeset/unify-request-envelope.md | 5 + packages/core/src/session/compaction.ts | 146 +++++++----------- packages/core/src/session/runner/llm.ts | 3 +- packages/core/src/session/title.ts | 74 ++++----- packages/core/test/config/compaction.test.ts | 6 +- packages/core/test/session-compaction.test.ts | 70 ++++++++- packages/core/test/session-title.test.ts | 49 +++++- 7 files changed, 212 insertions(+), 141 deletions(-) create mode 100644 .changeset/unify-request-envelope.md diff --git a/.changeset/unify-request-envelope.md b/.changeset/unify-request-envelope.md new file mode 100644 index 000000000000..ce659a18f7ff --- /dev/null +++ b/.changeset/unify-request-envelope.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Title generation and compaction summaries now build their model requests through the shared session request boundary. Both requests now run session context hooks and unsupported-media filtering, title requests gain the session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own. diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 970c9ef1be80..461e2653f0dd 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -1,26 +1,21 @@ export * as SessionCompaction from "./compaction.js" -import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai" +import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" import type { StreamOptions } from "@opencode-ai/ai/route" import { SessionError } from "@opencode-ai/schema/session-error" import { Context, Effect, Layer, Stream } from "effect" import { Bus } from "../bus.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { llmClient } from "../effect/app-node-platform.js" +import { Tool } from "../tool.js" import { SessionEvent } from "./event.js" import type { SessionMessage } from "./message.js" -import { SessionModelHeaders } from "./model-headers.js" -import { SessionModelHook } from "./model-hook.js" -import { SessionModelHttp } from "./model-http.js" -import { SessionPromptCacheKey } from "./prompt-cache-key.js" -import { App } from "../app.js" +import { SessionModelRequest } from "./model-request.js" import { SessionRunnerModel } from "./runner/model.js" import { SessionSchema } from "./schema.js" import { toSessionError } from "./to-session-error.js" import { Token } from "../util/token.js" -import type { Info, Ref } from "../model.js" import { SessionUsage } from "./usage.js" -import { PluginHooks } from "../plugin/hooks.js" import { Agent } from "../agent.js" import { State } from "../state.js" @@ -70,22 +65,25 @@ export type Draft = { configure: (settings: Partial) => void } +// Compaction summaries carry no tools; prepare still requires a snapshot for hook reconciliation. +const noTools: Tool.Snapshot = { + definitions: [], + execute: () => new Tool.Error({ message: "Tools are not available for compaction" }), +} + type Dependencies = { - readonly app: App.Info readonly bus: Bus.Interface readonly llm: { readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream } readonly models: SessionRunnerModel.Interface - readonly hooks: PluginHooks.Interface + readonly modelRequests: SessionModelRequest.Interface } export type AutoInput = { readonly session: SessionSchema.Info readonly messages: readonly SessionMessage.Info[] - readonly model: LanguageModel - readonly ref: Ref - readonly cost: Info["cost"] + readonly model: SessionRunnerModel.Resolved } export type ManualInput = { @@ -95,13 +93,9 @@ export type ManualInput = { readonly started?: boolean } -type RequiredInput = Omit - type Plan = { readonly session: SessionSchema.Info - readonly model: LanguageModel - readonly ref: Ref - readonly cost: Info["cost"] + readonly model: SessionRunnerModel.Resolved readonly reason: SessionMessage.Compaction["reason"] readonly prompt: string readonly recent: string @@ -114,7 +108,7 @@ export type Outcome = | Pick export interface Interface extends State.Transformable { - readonly required: (input: RequiredInput) => boolean + readonly required: (input: AutoInput) => boolean readonly compact: (input: AutoInput) => Effect.Effect readonly compactManual: (input: ManualInput) => Effect.Effect } @@ -271,65 +265,50 @@ const make = (dependencies: Dependencies) => { }) : Effect.void, ) - const request = yield* SessionModelHook.apply( - dependencies.hooks, - { sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.ref }, - LLM.request({ - model: plan.model, - promptCacheKey: SessionPromptCacheKey.make(plan.session.id), - http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) }, - messages: [Message.user(plan.prompt)], - tools: [], - }), - ) - yield* dependencies.llm - .stream(request, { - http: SessionModelHttp.middleware(dependencies.hooks, { - sessionID: plan.session.id, - agent: Agent.ID.make("compaction"), - model: plan.ref, - }), - }) - .pipe( - Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) - failure = { - type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", - message: event.message, - } - if (LLMEvent.is.textDelta(event)) { - chunks.push(event.text) - return dependencies.bus.publish(SessionEvent.Compaction.Delta, { - sessionID: plan.session.id, - text: event.text, - }) - } - if (LLMEvent.is.stepFinish(event)) { - const step = SessionUsage.record(event.usage, plan.cost) - usage = usage ? SessionUsage.add(usage, step) : step + const prepared = yield* dependencies.modelRequests.prepare({ + scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.model, tools: noTools }, + transcript: { system: [], messages: [Message.user(plan.prompt)] }, + }) + yield* dependencies.llm.stream(prepared.request, prepared.options).pipe( + Stream.runForEach((event) => { + if (LLMEvent.is.providerError(event)) + failure = { + type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", + message: event.message, } - return Effect.void + if (LLMEvent.is.textDelta(event)) { + chunks.push(event.text) + return dependencies.bus.publish(SessionEvent.Compaction.Delta, { + sessionID: plan.session.id, + text: event.text, + }) + } + if (LLMEvent.is.stepFinish(event)) { + const step = SessionUsage.record(event.usage, plan.model.cost) + usage = usage ? SessionUsage.add(usage, step) : step + } + return Effect.void + }), + Effect.catchTag("AI.Error", (error) => + Effect.sync(() => { + failure = toSessionError(error) }), - Effect.catchTag("AI.Error", (error) => - Effect.sync(() => { - failure = toSessionError(error) - }), - ), - Effect.onInterrupt(() => - recordUsage.pipe( - Effect.andThen( - plan.reason === "auto" - ? failed({ - sessionID: plan.session.id, - reason: plan.reason, - error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, - inputID: plan.inputID, - }).pipe(Effect.asVoid) - : Effect.void, - ), + ), + Effect.onInterrupt(() => + recordUsage.pipe( + Effect.andThen( + plan.reason === "auto" + ? failed({ + sessionID: plan.session.id, + reason: plan.reason, + error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, + inputID: plan.inputID, + }).pipe(Effect.asVoid) + : Effect.void, ), ), - ) + ), + ) yield* recordUsage const summary = chunks.join("") if (failure || !summary.trim()) { @@ -355,8 +334,6 @@ const make = (dependencies: Dependencies) => { return yield* execute({ session: input.session, model: input.model, - ref: input.ref, - cost: input.cost, reason: "auto", ...content, }) @@ -367,17 +344,17 @@ const make = (dependencies: Dependencies) => { error, }) }) - const required = (input: RequiredInput) => { + const required = (input: AutoInput) => { const config = state.get() if (!config.auto) return false - const context = input.model.route.defaults.limits?.context + const context = input.model.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false const last = input.messages.findLast( (message): message is SessionMessage.Assistant & { tokens: NonNullable } => message.type === "assistant" && message.tokens !== undefined, ) if (!last) return false - const limits = input.model.route.defaults.limits + const limits = input.model.model.route.defaults.limits const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX) const promptCeiling = Math.min( limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer, @@ -410,9 +387,7 @@ const make = (dependencies: Dependencies) => { if ("status" in resolved) return resolved return yield* execute({ session: input.session, - model: resolved.model, - ref: resolved.ref, - cost: resolved.cost, + model: resolved, reason: "manual", inputID: input.inputID, started: input.started, @@ -434,14 +409,13 @@ export const layer = Layer.effect( const bus = yield* Bus.Service const llm = yield* LLMClient.Service const models = yield* SessionRunnerModel.Service - const app = yield* App.Metadata - const hooks = yield* PluginHooks.Service - return make({ bus, llm, models, app, hooks }) + const modelRequests = yield* SessionModelRequest.Service + return make({ bus, llm, models, modelRequests }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node], + deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node], }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0e721f4913f4..f528021e46ea 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -315,10 +315,9 @@ const layer = Layer.effect( const loaded = yield* context.load(selected) const { session, agent } = loaded const resolved = loaded.model - const model = resolved.model // Make room: history must fit the context window before the call. A pending manual // compaction owns this instead; the runner executes it between steps. - const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost } + const compactionInput = { session, messages: loaded.messages, model: resolved } if (compaction.required(compactionInput)) { const compacted = yield* compaction.compact(compactionInput) if (compacted.status === "completed") diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 5524538c0daa..f349f0ed164e 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -1,6 +1,6 @@ export * as SessionTitle from "./title.js" -import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import type { StreamOptions } from "@opencode-ai/ai/route" import { Context, DateTime, Effect, Layer, Stream } from "effect" import { Agent } from "../agent.js" @@ -8,14 +8,11 @@ import { Database } from "../database/database.js" import { Bus } from "../bus.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback" -import { App } from "../app.js" import { llmClient } from "../effect/app-node-platform.js" -import { PluginHooks } from "../plugin/hooks.js" +import { Tool } from "../tool.js" import { SessionEvent } from "./event.js" import { SessionHistory } from "./history.js" -import { SessionModelHeaders } from "./model-headers.js" -import { SessionModelHook } from "./model-hook.js" -import { SessionModelHttp } from "./model-http.js" +import { SessionModelRequest } from "./model-request.js" import { SessionRunnerModel } from "./runner/model.js" import { SessionSchema } from "./schema.js" import { SessionUsage } from "./usage.js" @@ -24,16 +21,21 @@ import { SessionStore } from "./store.js" const MAX_LENGTH = 100 const titleChanged = Symbol("Session title changed") +// Title generation carries no tools; prepare still requires a snapshot for hook reconciliation. +const noTools: Tool.Snapshot = { + definitions: [], + execute: () => new Tool.Error({ message: "Tools are not available for title generation" }), +} + type Dependencies = { - readonly app: App.Info readonly bus: Bus.Interface readonly llm: { readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream } readonly agents: Agent.Interface readonly models: SessionRunnerModel.Interface + readonly modelRequests: SessionModelRequest.Interface readonly store: SessionStore.Interface - readonly hooks: PluginHooks.Interface } export interface Interface { @@ -81,39 +83,27 @@ const make = (dependencies: Dependencies) => { }) : Effect.void, ) - const request = yield* SessionModelHook.apply( - dependencies.hooks, - { sessionID: session.id, agent: agent.id, model: resolved.ref }, - LLM.request({ - model: resolved.model, - http: { headers: SessionModelHeaders.make(session, dependencies.app) }, - system: agent.system, + const prepared = yield* dependencies.modelRequests.prepare({ + scope: { session, agentID: agent.id, model: resolved, tools: noTools }, + transcript: { + system: agent.system ? [SystemPart.make(agent.system)] : [], messages: [Message.user(firstUser.text)], - tools: [], + }, + }) + const streamed = yield* dependencies.llm.stream(prepared.request, prepared.options).pipe( + Stream.runForEach((event) => { + if (LLMEvent.is.providerError(event)) failed = true + if (LLMEvent.is.textDelta(event)) chunks.push(event.text) + if (LLMEvent.is.stepFinish(event)) { + const step = SessionUsage.record(event.usage, resolved.cost) + usage = usage ? SessionUsage.add(usage, step) : step + } + return Effect.void }), + Effect.as(true), + Effect.catchTag("AI.Error", () => Effect.succeed(false)), + Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)), ) - const streamed = yield* dependencies.llm - .stream(request, { - http: SessionModelHttp.middleware(dependencies.hooks, { - sessionID: session.id, - agent: agent.id, - model: resolved.ref, - }), - }) - .pipe( - Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) failed = true - if (LLMEvent.is.textDelta(event)) chunks.push(event.text) - if (LLMEvent.is.stepFinish(event)) { - const step = SessionUsage.record(event.usage, resolved.cost) - usage = usage ? SessionUsage.add(usage, step) : step - } - return Effect.void - }), - Effect.as(true), - Effect.catchTag("AI.Error", () => Effect.succeed(false)), - Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)), - ) yield* recordUsage if (!streamed || failed) return const title = chunks @@ -146,11 +136,10 @@ export const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* Agent.Service const models = yield* SessionRunnerModel.Service + const modelRequests = yield* SessionModelRequest.Service const store = yield* SessionStore.Service const database = yield* Database.Service - const app = yield* App.Metadata - const hooks = yield* PluginHooks.Service - const title = make({ bus, llm, agents, models, store, app, hooks }) + const title = make({ bus, llm, agents, models, modelRequests, store }) return Service.of({ generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID), }) @@ -165,9 +154,8 @@ export const node = makeLocationNode({ llmClient, Agent.node, SessionRunnerModel.node, + SessionModelRequest.node, SessionStore.node, Database.node, - App.node, - PluginHooks.node, ], }) diff --git a/packages/core/test/config/compaction.test.ts b/packages/core/test/config/compaction.test.ts index 0010c6871ce4..0a1e9ed0585f 100644 --- a/packages/core/test/config/compaction.test.ts +++ b/packages/core/test/config/compaction.test.ts @@ -150,8 +150,10 @@ const session = Session.Info.make({ }) const input = (tokens: number) => ({ session, - model, - cost: [], + model: SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], + }), messages: [ Schema.decodeUnknownSync(SessionMessage.Assistant)({ id: SessionMessage.ID.make("msg_compaction_config"), diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 08033ba8aaf4..c8a323002122 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -143,12 +143,14 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () => }) const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({ session, - model: LanguageModel.make({ - id: "test-model", - provider: "test-provider", - route: OpenAIChat.route.with({ limits }), - }), - cost: [], + model: SessionRunnerModel.resolved( + LanguageModel.make({ + id: "test-model", + provider: "test-provider", + route: OpenAIChat.route.with({ limits }), + }), + { capabilities: { tools: true, input: ["text"], output: ["text"] }, cost: [] }, + ), messages: [ Schema.decodeUnknownSync(SessionMessage.Assistant)({ id: SessionMessage.ID.make("msg_assistant"), @@ -267,3 +269,59 @@ it.effect("manual compaction summarizes short context instead of no-op", () => ]) }), ) + +it.effect("forked session compaction reuses the fork root prompt cache key", () => + Effect.gen(function* () { + requests = [] + const db = (yield* Database.Service).db + const compaction = yield* SessionCompaction.Service + const store = yield* SessionStore.Service + const sessionID = Session.ID.make("ses_fork_compaction") + const rootID = Session.ID.make("ses_fork_compaction_root") + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + fork_session_id: rootID, + fork_boundary: { type: "before", messageID: SessionMessage.ID.create() }, + slug: "fork-compaction", + directory: "/project", + title: "Fork compaction", + version: "test", + }) + .run() + .pipe(Effect.orDie) + + const session = yield* store + .get(sessionID) + .pipe( + Effect.flatMap((session) => + session ? Effect.succeed(session) : Effect.die("fork compaction test session missing"), + ), + ) + expect( + yield* compaction.compactManual({ + session, + messages: [ + { + id: SessionMessage.ID.create(), + type: "user", + text: "Summarize the forked conversation.", + time: { created: DateTime.makeUnsafe(0) }, + }, + ], + inputID: SessionMessage.ID.make("msg_fork_compaction"), + }), + ).toEqual({ status: "completed" }) + + expect(requests).toHaveLength(1) + expect(requests[0]?.promptCacheKey).toBe(rootID) + }), +) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index b32e98e5ea6e..290ee9e9e86f 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test" -import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { Agent } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" @@ -14,6 +14,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionTitle } from "@opencode-ai/core/session/title" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { Session } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -77,7 +78,15 @@ const models = Layer.mock(SessionRunnerModel.Service)({ }) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Agent.node, SessionTitle.node]), + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + SessionStore.node, + Agent.node, + PluginHooks.node, + SessionTitle.node, + ]), [ [llmClient, client], [SessionRunnerModel.node, models], @@ -154,6 +163,9 @@ it.effect("generates a title from the sole user message and renames the session" "x-opencode-session": sessionID, "x-opencode-client": "opencode", }) + expect(requests[0]?.promptCacheKey).toBe(sessionID) + expect(requests[0]?.tools).toEqual([]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."]) expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") const renamed = yield* store.get(sessionID) expect(renamed?.title).toBe("Generated Title") @@ -322,6 +334,39 @@ it.effect("retries after a failed title request", () => }), ) +it.effect("applies session context hooks to title requests", () => + Effect.gen(function* () { + requests = [] + titleStream = successfulTitle + const agentService = yield* Agent.Service + yield* agentService.transform((editor) => { + editor.update(Agent.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "You are a title generator." + }) + }) + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "context", (event) => + Effect.sync(() => { + event.system.push(SystemPart.make("Keep titles in sentence case.")) + }), + ) + const sessionID = Session.ID.make("ses_title_context_hook") + yield* insertSession(sessionID) + yield* prompt(sessionID, "Hook this title request") + + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(sessionID) + + expect(requests).toHaveLength(1) + expect(requests[0]?.system.map((part) => part.text)).toEqual([ + "You are a title generator.", + "Keep titles in sentence case.", + ]) + }), +) + it.effect("preserves a manual rename completed while generation is in flight", () => Effect.gen(function* () { requests = [] From 5a2009b0c0862fb63a68d43fab633a63b150e75f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 23:16:15 -0400 Subject: [PATCH 2/3] refactor(core): opt housekeeping requests out of context hooks --- .changeset/unify-request-envelope.md | 2 +- packages/core/src/session/compaction.ts | 14 +- packages/core/src/session/model-request.ts | 33 +++-- packages/core/src/session/title.ts | 10 +- packages/core/test/config/compaction.test.ts | 17 +-- packages/core/test/session-compaction.test.ts | 130 ++++++++++-------- packages/core/test/session-title.test.ts | 9 +- 7 files changed, 114 insertions(+), 101 deletions(-) diff --git a/.changeset/unify-request-envelope.md b/.changeset/unify-request-envelope.md index ce659a18f7ff..b7ac2041dc6e 100644 --- a/.changeset/unify-request-envelope.md +++ b/.changeset/unify-request-envelope.md @@ -2,4 +2,4 @@ "@opencode-ai/core": patch --- -Title generation and compaction summaries now build their model requests through the shared session request boundary. Both requests now run session context hooks and unsupported-media filtering, title requests gain the session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own. +Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: conversation-shaping plugins do not observe housekeeping requests. Title requests gain the session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own. diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 461e2653f0dd..6a4a6400fc94 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -7,7 +7,6 @@ import { Context, Effect, Layer, Stream } from "effect" import { Bus } from "../bus.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { llmClient } from "../effect/app-node-platform.js" -import { Tool } from "../tool.js" import { SessionEvent } from "./event.js" import type { SessionMessage } from "./message.js" import { SessionModelRequest } from "./model-request.js" @@ -65,12 +64,6 @@ export type Draft = { configure: (settings: Partial) => void } -// Compaction summaries carry no tools; prepare still requires a snapshot for hook reconciliation. -const noTools: Tool.Snapshot = { - definitions: [], - execute: () => new Tool.Error({ message: "Tools are not available for compaction" }), -} - type Dependencies = { readonly bus: Bus.Interface readonly llm: { @@ -266,8 +259,9 @@ const make = (dependencies: Dependencies) => { : Effect.void, ) const prepared = yield* dependencies.modelRequests.prepare({ - scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.model, tools: noTools }, + scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.model }, transcript: { system: [], messages: [Message.user(plan.prompt)] }, + contextHooks: false, }) yield* dependencies.llm.stream(prepared.request, prepared.options).pipe( Stream.runForEach((event) => { @@ -347,14 +341,14 @@ const make = (dependencies: Dependencies) => { const required = (input: AutoInput) => { const config = state.get() if (!config.auto) return false - const context = input.model.model.route.defaults.limits?.context + const limits = input.model.model.route.defaults.limits + const context = limits?.context if (context === undefined || context <= 0) return false const last = input.messages.findLast( (message): message is SessionMessage.Assistant & { tokens: NonNullable } => message.type === "assistant" && message.tokens !== undefined, ) if (!last) return false - const limits = input.model.model.route.defaults.limits const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX) const promptCeiling = Math.min( limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer, diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index bd7f5a79e267..d38e6588606c 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -61,13 +61,19 @@ interface PrepareInput { readonly session: SessionSchema.Info readonly agentID: Agent.ID readonly model: SessionRunnerModel.Resolved - readonly tools: Tool.Snapshot + /** Omitted for housekeeping requests that carry no tools. */ + readonly tools?: Tool.Snapshot } readonly transcript: { readonly system: Array readonly messages: Array } readonly toolChoice?: LLM.RequestInput["toolChoice"] + /** + * Session context hooks shape agent conversations. Housekeeping callers + * (title, compaction) opt out: their transcripts pass through unchanged. + */ + readonly contextHooks?: false /** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */ readonly webSocket?: "session" } @@ -209,7 +215,10 @@ export const layer = Layer.effect( const session = input.scope.session const resolved = input.scope.model const model = resolved.model - const tools = input.scope.tools + const tools = input.scope.tools ?? { + definitions: [], + execute: () => new Tool.Error({ message: "Tools are not available for this request" }), + } const registry = new Map(tools.definitions.map((tool) => [tool.name, tool])) // The definition objects we hand to hooks, mapped back to their tools. Hooks rename a // tool by moving its definition to a new key; recognizing the object recovers the tool. @@ -219,14 +228,18 @@ export const layer = Layer.effect( ), ) // Hooks mutate this record in place: edit descriptions and schemas, rename, or remove. - const context = yield* hooks.trigger("session", "context", { - sessionID: session.id, - agent: input.scope.agentID, - model: resolved.ref, - system: input.transcript.system, - messages: input.transcript.messages, - tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])), - }) + const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])) + const context = + input.contextHooks === false + ? { system: input.transcript.system, messages: input.transcript.messages, tools: definitions } + : yield* hooks.trigger("session", "context", { + sessionID: session.id, + agent: input.scope.agentID, + model: resolved.ref, + system: input.transcript.system, + messages: input.transcript.messages, + tools: definitions, + }) // Match each surviving entry back to its tool, by recognizing a moved definition or // by key. Identity wins so a definition moved onto another tool's name still executes // the tool it describes. Entries matching neither were invented by a hook and dropped. diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index f349f0ed164e..f4fda46727c0 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -9,7 +9,6 @@ import { Bus } from "../bus.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback" import { llmClient } from "../effect/app-node-platform.js" -import { Tool } from "../tool.js" import { SessionEvent } from "./event.js" import { SessionHistory } from "./history.js" import { SessionModelRequest } from "./model-request.js" @@ -21,12 +20,6 @@ import { SessionStore } from "./store.js" const MAX_LENGTH = 100 const titleChanged = Symbol("Session title changed") -// Title generation carries no tools; prepare still requires a snapshot for hook reconciliation. -const noTools: Tool.Snapshot = { - definitions: [], - execute: () => new Tool.Error({ message: "Tools are not available for title generation" }), -} - type Dependencies = { readonly bus: Bus.Interface readonly llm: { @@ -84,11 +77,12 @@ const make = (dependencies: Dependencies) => { : Effect.void, ) const prepared = yield* dependencies.modelRequests.prepare({ - scope: { session, agentID: agent.id, model: resolved, tools: noTools }, + scope: { session, agentID: agent.id, model: resolved }, transcript: { system: agent.system ? [SystemPart.make(agent.system)] : [], messages: [Message.user(firstUser.text)], }, + contextHooks: false, }) const streamed = yield* dependencies.llm.stream(prepared.request, prepared.options).pipe( Stream.runForEach((event) => { diff --git a/packages/core/test/config/compaction.test.ts b/packages/core/test/config/compaction.test.ts index 0a1e9ed0585f..e2587b120b34 100644 --- a/packages/core/test/config/compaction.test.ts +++ b/packages/core/test/config/compaction.test.ts @@ -28,6 +28,10 @@ const model = LanguageModel.make({ provider: "test-provider", route: OpenAIChat.route.with({ limits: { context: 100_000, output: 1_000 } }), }) +const resolved = SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], +}) const config = Config.testLayer() const it = testEffect( Layer.merge( @@ -42,13 +46,7 @@ const it = testEffect( [ SessionRunnerModel.node, Layer.mock(SessionRunnerModel.Service)({ - resolve: () => - Effect.succeed( - SessionRunnerModel.resolved(model, { - capabilities: { tools: true, input: ["text"], output: ["text"] }, - cost: [], - }), - ), + resolve: () => Effect.succeed(resolved), }), ], [Config.node, config], @@ -150,10 +148,7 @@ const session = Session.Info.make({ }) const input = (tokens: number) => ({ session, - model: SessionRunnerModel.resolved(model, { - capabilities: { tools: true, input: ["text"], output: ["text"] }, - cost: [], - }), + model: resolved, messages: [ Schema.decodeUnknownSync(SessionMessage.Assistant)({ id: SessionMessage.ID.make("msg_compaction_config"), diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index c8a323002122..0145dcdc7ea5 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -19,6 +19,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { App } from "@opencode-ai/core/app" import { Agent } from "@opencode-ai/core/agent" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Money } from "@opencode-ai/schema/money" @@ -77,7 +78,14 @@ const models = Layer.mock(SessionRunnerModel.Service)({ }) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]), + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + SessionStore.node, + PluginHooks.node, + SessionCompaction.node, + ]), [ [Bus.node, Bus.configured({ persist: true })], [llmClient, client], @@ -178,21 +186,10 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () => }), ) -it.effect("manual compaction summarizes short context instead of no-op", () => +/** Seeds the global project plus one session row, returning the projected session. */ +const insertSession = (id: Session.ID, overrides?: Partial) => Effect.gen(function* () { - requests = [] const db = (yield* Database.Service).db - const compaction = yield* SessionCompaction.Service - const bus = yield* Bus.Service - const store = yield* SessionStore.Service - const sessionID = Session.ID.make("ses_manual_compaction") - const parentID = Session.ID.make("ses_manual_compaction_parent") - const userMessage = { - id: SessionMessage.ID.create(), - type: "user" as const, - text: "Manual compaction should include this short conversation.", - time: { created: DateTime.makeUnsafe(0) }, - } yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) @@ -202,24 +199,38 @@ it.effect("manual compaction summarizes short context instead of no-op", () => yield* db .insert(SessionTable) .values({ - id: sessionID, + id, project_id: Project.ID.global, - parent_id: parentID, - slug: "manual-compaction", + slug: id, directory: "/project", - title: "Manual compaction", + title: id, version: "test", + ...overrides, }) .run() .pipe(Effect.orDie) + const store = yield* SessionStore.Service + return yield* store + .get(id) + .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`)))) + }) - const session = yield* store - .get(sessionID) - .pipe( - Effect.flatMap((session) => - session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"), - ), - ) +it.effect("manual compaction summarizes short context instead of no-op", () => + Effect.gen(function* () { + requests = [] + const db = (yield* Database.Service).db + const compaction = yield* SessionCompaction.Service + const bus = yield* Bus.Service + const store = yield* SessionStore.Service + const sessionID = Session.ID.make("ses_manual_compaction") + const parentID = Session.ID.make("ses_manual_compaction_parent") + const userMessage = { + id: SessionMessage.ID.create(), + type: "user" as const, + text: "Manual compaction should include this short conversation.", + time: { created: DateTime.makeUnsafe(0) }, + } + const session = yield* insertSession(sessionID, { parent_id: parentID }) const delta = yield* bus .subscribe(SessionEvent.Compaction.Delta) @@ -273,39 +284,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () => it.effect("forked session compaction reuses the fork root prompt cache key", () => Effect.gen(function* () { requests = [] - const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service - const store = yield* SessionStore.Service const sessionID = Session.ID.make("ses_fork_compaction") const rootID = Session.ID.make("ses_fork_compaction_root") - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - fork_session_id: rootID, - fork_boundary: { type: "before", messageID: SessionMessage.ID.create() }, - slug: "fork-compaction", - directory: "/project", - title: "Fork compaction", - version: "test", - }) - .run() - .pipe(Effect.orDie) - - const session = yield* store - .get(sessionID) - .pipe( - Effect.flatMap((session) => - session ? Effect.succeed(session) : Effect.die("fork compaction test session missing"), - ), - ) + const session = yield* insertSession(sessionID, { + fork_session_id: rootID, + fork_boundary: { type: "before", messageID: SessionMessage.ID.create() }, + }) expect( yield* compaction.compactManual({ session, @@ -325,3 +310,36 @@ it.effect("forked session compaction reuses the fork root prompt cache key", () expect(requests[0]?.promptCacheKey).toBe(rootID) }), ) + +it.effect("keeps session context hooks away from compaction requests", () => + Effect.gen(function* () { + requests = [] + const compaction = yield* SessionCompaction.Service + // Conversation-shaping hooks must not observe housekeeping requests: compaction + // opts out of context hooks, so the transcript passes through unchanged. + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "context", (event) => + Effect.sync(() => { + event.system.push(SystemPart.make("Injected conversation context")) + }), + ) + const session = yield* insertSession(Session.ID.make("ses_hook_compaction")) + expect( + yield* compaction.compactManual({ + session, + messages: [ + { + id: SessionMessage.ID.create(), + type: "user", + text: "Summarize this conversation.", + time: { created: DateTime.makeUnsafe(0) }, + }, + ], + inputID: SessionMessage.ID.make("msg_hook_compaction"), + }), + ).toEqual({ status: "completed" }) + + expect(requests).toHaveLength(1) + expect(requests[0]?.system).toEqual([]) + }), +) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 290ee9e9e86f..c40cc2f99de9 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -334,7 +334,7 @@ it.effect("retries after a failed title request", () => }), ) -it.effect("applies session context hooks to title requests", () => +it.effect("keeps session context hooks away from title requests", () => Effect.gen(function* () { requests = [] titleStream = successfulTitle @@ -346,6 +346,8 @@ it.effect("applies session context hooks to title requests", () => agent.system = "You are a title generator." }) }) + // Conversation-shaping hooks must not observe housekeeping requests: title + // generation opts out of context hooks, so the transcript passes through unchanged. const hooks = yield* PluginHooks.Service yield* hooks.register("session", "context", (event) => Effect.sync(() => { @@ -360,10 +362,7 @@ it.effect("applies session context hooks to title requests", () => yield* title.generateForFirstPrompt(sessionID) expect(requests).toHaveLength(1) - expect(requests[0]?.system.map((part) => part.text)).toEqual([ - "You are a title generator.", - "Keep titles in sentence case.", - ]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."]) }), ) From 575b7fcefb27112bc385456caea88bab4a4f7e02 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 20 Aug 2026 12:34:04 -0400 Subject: [PATCH 3/3] docs(core): describe the context-hook opt-out without coined vocabulary --- .changeset/unify-request-envelope.md | 2 +- packages/core/src/session/model-request.ts | 7 ++++--- packages/core/test/session-compaction.test.ts | 4 ++-- packages/core/test/session-title.test.ts | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.changeset/unify-request-envelope.md b/.changeset/unify-request-envelope.md index b7ac2041dc6e..8f06625fe449 100644 --- a/.changeset/unify-request-envelope.md +++ b/.changeset/unify-request-envelope.md @@ -2,4 +2,4 @@ "@opencode-ai/core": patch --- -Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: conversation-shaping plugins do not observe housekeeping requests. Title requests gain the session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own. +Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own. diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index d38e6588606c..9e8d4c59b3e3 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -61,7 +61,7 @@ interface PrepareInput { readonly session: SessionSchema.Info readonly agentID: Agent.ID readonly model: SessionRunnerModel.Resolved - /** Omitted for housekeeping requests that carry no tools. */ + /** Omitted for requests that carry no tools (title, compaction). */ readonly tools?: Tool.Snapshot } readonly transcript: { @@ -70,8 +70,9 @@ interface PrepareInput { } readonly toolChoice?: LLM.RequestInput["toolChoice"] /** - * Session context hooks shape agent conversations. Housekeeping callers - * (title, compaction) opt out: their transcripts pass through unchanged. + * Session context hooks shape the agent conversation. Requests that are not + * part of the conversation (title, compaction) opt out: their transcripts + * pass through unchanged. */ readonly contextHooks?: false /** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */ diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 1a5180ba0731..41aed11033b4 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -311,8 +311,8 @@ it.effect("keeps session context hooks away from compaction requests", () => Effect.gen(function* () { requests = [] const compaction = yield* SessionCompaction.Service - // Conversation-shaping hooks must not observe housekeeping requests: compaction - // opts out of context hooks, so the transcript passes through unchanged. + // Context hooks shape the agent conversation; compaction is not part of it, + // so it opts out and the transcript passes through unchanged. const hooks = yield* PluginHooks.Service yield* hooks.register("session", "context", (event) => Effect.sync(() => { diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 857e60666b4a..6caae49de7a0 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -347,8 +347,8 @@ it.effect("keeps session context hooks away from title requests", () => agent.system = "You are a title generator." }) }) - // Conversation-shaping hooks must not observe housekeeping requests: title - // generation opts out of context hooks, so the transcript passes through unchanged. + // Context hooks shape the agent conversation; title generation is not part of + // it, so it opts out and the transcript passes through unchanged. const hooks = yield* PluginHooks.Service yield* hooks.register("session", "context", (event) => Effect.sync(() => {