diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index bbef1018ba7..b161ff37e29 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -399,7 +399,7 @@ export function make(options: ClientOptions) { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, successStatus: 204, - declaredStatuses: [404, 503, 400, 401], + declaredStatuses: [404, 409, 503, 500, 400, 401], empty: true, }, requestOptions, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 7d6702ca7fa..9d429cd63db 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -41,6 +41,14 @@ export type ConflictError = { export const isConflictError = (value: unknown): value is ConflictError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError" +export type SessionBusyError = { + readonly _tag: "SessionBusyError" + readonly sessionID: string + readonly message: string +} +export const isSessionBusyError = (value: unknown): value is SessionBusyError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError" + export type ServiceUnavailableError = { readonly _tag: "ServiceUnavailableError" readonly message: string @@ -49,6 +57,14 @@ export type ServiceUnavailableError = { export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError" +export type UnknownError = { + readonly _tag: "UnknownError" + readonly message: string + readonly ref?: string | undefined +} +export const isUnknownError = (value: unknown): value is UnknownError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError" + export type MessageNotFoundError = { readonly _tag: "MessageNotFoundError" readonly sessionID: string @@ -58,22 +74,6 @@ export type MessageNotFoundError = { export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError" -export type SessionBusyError = { - readonly _tag: "SessionBusyError" - readonly sessionID: string - readonly message: string -} -export const isSessionBusyError = (value: unknown): value is SessionBusyError => - typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError" - -export type UnknownError = { - readonly _tag: "UnknownError" - readonly message: string - readonly ref?: string | undefined -} -export const isUnknownError = (value: unknown): value is UnknownError => - typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError" - export type ProviderNotFoundError = { readonly _tag: "ProviderNotFoundError" readonly providerID: string diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 07669c08433..10ebe6e22c5 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -26,6 +26,7 @@ import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" import * as SessionRunnerLLM from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" +import { SessionCompaction } from "./session/compaction" import { SessionTodo } from "./session/todo" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" @@ -74,6 +75,7 @@ export const locationServices = LayerNode.group([ ReadToolFileSystem.node, BuiltInTools.node, SessionRunnerModel.node, + SessionCompaction.node, Snapshot.node, SessionRunnerLLM.node, ]) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 73ceacb99d6..40deed10810 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -33,6 +33,7 @@ import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" import { Snapshot } from "./snapshot" +import { SessionCompaction } from "./session/compaction" import { SessionRevert } from "./session/revert" import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" @@ -87,7 +88,6 @@ type CreateInput = CreateBaseInput & type CompactInput = { sessionID: SessionSchema.ID - prompt?: Prompt } export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { @@ -113,7 +113,7 @@ export class BusyError extends Schema.TaggedErrorClass()("Session.Bus export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError -export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError +export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError | BusyError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect @@ -169,7 +169,9 @@ export interface Interface { skill: string resume?: boolean }) => Effect.Effect - readonly compact: (input: CompactInput) => Effect.Effect + readonly compact: ( + input: CompactInput, + ) => Effect.Effect readonly wait: (id: SessionSchema.ID) => Effect.Effect readonly active: Effect.Effect> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect @@ -437,8 +439,19 @@ export const layer = Layer.effect( }) }), compact: Effect.fn("V2Session.compact")(function* (input) { - yield* result.get(input.sessionID) - return yield* new OperationUnavailableError({ operation: "compact" }) + const session = yield* result.get(input.sessionID) + // TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions. + if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID }) + const context = yield* store.context(input.sessionID) + const compacted = yield* Effect.gen(function* () { + const compaction = yield* SessionCompaction.Service + return yield* compaction.compactManual({ session, messages: context }) + }).pipe( + Effect.provide(locations.get(session.location)), + Effect.catch(() => Effect.succeed(false)), + ) + if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" }) + return undefined }), wait: Effect.fn("V2Session.wait")(function* (sessionID) { yield* result.get(sessionID) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 5229949cb95..de12ef50edb 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -1,11 +1,16 @@ export * as SessionCompaction from "./compaction" -import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" -import { DateTime, Effect, Stream } from "effect" +import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" +import { Context, DateTime, Effect, Layer, Stream } from "effect" import type { Config } from "../config" +import { Config as ConfigV2 } from "../config" import type { EventV2 } from "../event" +import { EventV2 as EventV2Service } from "../event" +import { makeLocationNode } from "../effect/app-node" +import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import { SessionMessage } from "./message" +import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { Token } from "../util/token" @@ -50,11 +55,6 @@ Rules: - Preserve exact file paths, commands, error strings, and identifiers when known. - Do not mention the summary process or that context was compacted.` -type Entry = { - readonly seq: number - readonly message: SessionMessage.Message -} - type Settings = { readonly auto: boolean readonly buffer: number @@ -69,13 +69,31 @@ type Dependencies = { readonly config: readonly Config.Entry[] } -type Input = { +export type AutoInput = { readonly sessionID: SessionSchema.ID - readonly entries: readonly Entry[] - readonly model: Model + readonly messages: readonly SessionMessage.Message[] readonly request: LLMRequest } +type CompactInput = { + readonly sessionID: SessionSchema.ID + readonly messages: readonly SessionMessage.Message[] + readonly model: Model +} + +export type ManualInput = { + readonly session: SessionSchema.Info + readonly messages: readonly SessionMessage.Message[] +} + +export interface Interface { + readonly compactIfNeeded: (input: AutoInput) => Effect.Effect + readonly compactAfterOverflow: (input: AutoInput) => Effect.Effect + readonly compactManual: (input: ManualInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SessionCompaction") {} + const estimate = (value: unknown) => Token.estimate(JSON.stringify(value)) const truncate = (value: string) => @@ -131,14 +149,14 @@ const settings = (documents: readonly Config.Entry[]) => { } const select = ( - entries: readonly Entry[], + messages: readonly SessionMessage.Message[], tokens: number, ): { readonly head: string; readonly recent: string } | undefined => { - const conversation = entries - .filter((entry) => entry.message.type !== "compaction") - .map((entry) => serialize(entry.message)) + const conversation = messages + .filter((message) => message.type !== "compaction") + .map(serialize) .filter(Boolean) - if (conversation.length === 0) return + if (conversation.length === 0) return undefined let total = 0 let split = conversation.length let splitPrefix = "" @@ -172,19 +190,21 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly ...input.context, ].join("\n\n") -export const make = (dependencies: Dependencies) => { +const make = (dependencies: Dependencies) => { const config = settings(dependencies.config) - const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) { + const compact = Effect.fn("SessionCompaction.compact")(function* (input: { + readonly sessionID: SessionSchema.ID + readonly model: Model + readonly reason: SessionMessage.Compaction["reason"] + readonly previousSummary?: string + readonly context: readonly string[] + readonly recent: string + readonly output?: number + }) { const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false - const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 - const selected = select(input.entries, config.tokens) - const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message - if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false - const summaryPrompt = buildPrompt({ - previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined, - context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean), - }) + const output = input.output ?? input.model.route.defaults.limits?.output ?? 0 + const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context }) const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS) if (Token.estimate(summaryPrompt) > context - summaryOutput) return false const messageID = SessionMessage.ID.create() @@ -192,7 +212,7 @@ export const make = (dependencies: Dependencies) => { sessionID: input.sessionID, messageID, timestamp: yield* DateTime.now, - reason: "auto", + reason: input.reason, }) const chunks: string[] = [] @@ -221,17 +241,58 @@ export const make = (dependencies: Dependencies) => { sessionID: input.sessionID, messageID, timestamp: yield* DateTime.now, - reason: "auto", + reason: input.reason, text: summary, - recent: selected.recent, + recent: input.recent, }) return true }) - const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) { - if (!config.auto) return false + const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) { + return yield* compactSelected({ + sessionID: input.sessionID, + messages: input.messages, + model: input.request.model, + reason: "auto", + force: false, + output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0, + }) + }) + const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* ( + input: CompactInput & { + readonly reason: SessionMessage.Compaction["reason"] + readonly force: boolean + readonly output?: number + }, + ) { const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false - const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 + const selected = select(input.messages, config.tokens) + if (!selected) return false + const previousSummary = input.messages.find((message) => message.type === "compaction") + const hasHead = selected.head.length > 0 + if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false + const forcedShortContext = input.force && !hasHead + const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : "" + return yield* compact({ + sessionID: input.sessionID, + model: input.model, + reason: input.reason, + previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined, + context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter( + Boolean, + ), + recent: forcedShortContext ? "" : selected.recent, + output: input.output, + }) + }) + const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) { + return yield* compactSelected({ ...input, reason: "manual", force: true }) + }) + const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) { + if (!config.auto) return false + const context = input.request.model.route.defaults.limits?.context + if (context === undefined || context <= 0) return false + const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0 if ( estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <= context - Math.max(output, config.buffer) @@ -242,5 +303,37 @@ export const make = (dependencies: Dependencies) => { return { compactIfNeeded, compactAfterOverflow, + compactManual, } } + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2Service.Service + const llm = yield* LLMClient.Service + const config = yield* ConfigV2.Service + const models = yield* SessionRunnerModel.Service + const compaction = make({ events, llm, config: yield* config.entries() }) + + return Service.of({ + compactIfNeeded: compaction.compactIfNeeded, + compactAfterOverflow: compaction.compactAfterOverflow, + compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) { + const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!model) return false + return yield* compaction.compactManual({ + sessionID: input.session.id, + messages: input.messages, + model, + }) + }), + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2Service.node, llmClient, ConfigV2.node, SessionRunnerModel.node], +}) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 356d1e13c3c..42f5a665a4d 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -10,7 +10,6 @@ import { } from "@opencode-ai/llm" import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" -import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" @@ -103,10 +102,9 @@ export const layer = Layer.effect( const systemContext = yield* SystemContextRegistry.Service const skillGuidance = yield* SkillGuidance.Service const referenceGuidance = yield* ReferenceGuidance.Service - const config = yield* Config.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db - const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() }) + const compaction = yield* SessionCompaction.Service const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) @@ -211,7 +209,7 @@ export const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) - if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) + if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request })) return yield* Effect.die(continueAfterCompaction(currentStep)) const startSnapshot = yield* snapshots.capture() const publisher = createLLMEventPublisher(events, { @@ -224,9 +222,9 @@ export const layer = Layer.effect( }, snapshot: startSnapshot, }) - const withPublication = Semaphore.makeUnsafe(1).withPermit + const publication = Semaphore.makeUnsafe(1) const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => - withPublication(publisher.publish(event, outputPaths)) + publication.withPermit(publisher.publish(event, outputPaths)) let overflowFailure: ProviderErrorEvent | undefined const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => @@ -241,7 +239,9 @@ export const layer = Layer.effect( yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return if (!toolMaterialization) { - yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps")) + yield* publication.withPermit( + publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"), + ) return } needsContinuation = true @@ -270,7 +270,7 @@ export const layer = Layer.effect( ).pipe(FiberSet.run(toolFibers)) }), ), - Effect.ensuring(withPublication(publisher.flush())), + Effect.ensuring(publication.withPermit(publisher.flush())), ) return yield* Effect.uninterruptibleMask((restore) => @@ -282,14 +282,14 @@ export const layer = Layer.effect( recoverOverflow && !publisher.hasAssistantStarted() && isContextOverflowFailure(overflowFailure ?? failure) && - (yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request }))) + (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request }))) ) return yield* Effect.die(continueAfterOverflowCompaction(currentStep)) if (overflowFailure) yield* publish(overflowFailure) const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { - yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) - yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) + yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true)) + yield* publication.withPermit(publisher.failAssistant(llmFailure.reason.message)) } if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) @@ -297,19 +297,19 @@ export const layer = Layer.effect( const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause) if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) { yield* FiberSet.clear(toolFibers) - yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) - yield* withPublication(publisher.failAssistant("Provider turn interrupted")) + yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted")) + yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted")) return yield* Effect.interrupt } if (streamInterrupted || toolsInterrupted) { yield* FiberSet.clear(toolFibers) - yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) - yield* withPublication(publisher.failAssistant("Provider turn interrupted")) + yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted")) + yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted")) } if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { const failure = Cause.squash(settled.cause) const message = failure instanceof Error ? failure.message : String(failure) - yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) + yield* publication.withPermit(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) } const stepSettlement = publisher.stepSettlement() if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) { @@ -320,7 +320,7 @@ export const layer = Layer.effect( .files({ from: startSnapshot, to: endSnapshot }) .pipe(Effect.catch(() => Effect.succeed(undefined))) : undefined - yield* withPublication( + yield* publication.withPermit( events.publish(SessionEvent.Step.Ended, { sessionID: session.id, timestamp: yield* DateTime.now, @@ -334,9 +334,9 @@ export const layer = Layer.effect( ) } if (publisher.hasProviderError()) - yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) + yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted")) if (stream._tag === "Success" && !publisher.hasProviderError()) - yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) + yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)) return yield* Effect.failCause(settled.cause) @@ -425,7 +425,7 @@ export const node = makeLocationNode({ SystemContextRegistry.node, SkillGuidance.node, ReferenceGuidance.node, - Config.node, + SessionCompaction.node, Snapshot.node, Database.node, ], diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts new file mode 100644 index 00000000000..a877624906c --- /dev/null +++ b/packages/core/test/session-compact.test.ts @@ -0,0 +1,107 @@ +import { describe, expect } from "bun:test" +import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" +import { OpenAIChat } from "@opencode-ai/llm/protocols" +import { Config } from "@opencode-ai/core/config" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationServices } from "@opencode-ai/core/location-services" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionCompaction } from "@opencode-ai/core/session/compaction" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { SessionStore } from "@opencode-ai/core/session/store" +import { DateTime, Effect, Layer, LayerMap, Stream } from "effect" +import { testEffect } from "./lib/effect" + +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) +const model = Model.make({ + id: "summary-model", + provider: "test", + route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), +}) +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +let requests: LLMRequest[] = [] +const client = Layer.mock(LLMClient.Service)({ + prepare: () => Effect.die("unused"), + stream: (request: LLMRequest) => { + requests.push(request) + return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" })) + }, + generate: () => Effect.die("unused"), +}) +const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) +const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const locations = Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + () => + // The test only needs the compaction location service used by SessionV2.compact. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + SessionCompaction.layer.pipe( + Layer.provide(client), + Layer.provide(config), + Layer.provide(models), + ) as unknown as Layer.Layer, + ), +) +const sessions = SessionV2.layer.pipe( + Layer.provide(locations), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(projects), + Layer.provide(SessionExecution.noopLayer), +) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + projects, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), +) + +describe("SessionV2.compact", () => { + it.effect("manually compacts the active session context", () => + Effect.gen(function* () { + requests = [] + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const created = yield* session.create({ location }) + + yield* events.publish(SessionEvent.Prompted, { + sessionID: created.id, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(0), + prompt: Prompt.make({ text: "Please compact this session history." }), + delivery: "steer", + }) + + yield* session.compact({ sessionID: created.id }) + + expect(requests).toHaveLength(1) + expect(JSON.stringify(requests[0]?.messages)).toContain("Please compact this session history.") + expect(yield* session.context(created.id)).toMatchObject([ + { type: "compaction", reason: "manual", summary: "manual session summary", recent: "" }, + ]) + }), + ) +}) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index e91c89c0095..143901c4748 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,5 +1,55 @@ import { expect, test } from "bun:test" +import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" +import { OpenAIChat } from "@opencode-ai/llm/protocols" +import { Config } from "@opencode-ai/core/config" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" import { SessionCompaction } from "@opencode-ai/core/session/compaction" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +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 { SessionV2 } from "@opencode-ai/core/session" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { DateTime, Effect, Layer, Stream } from "effect" +import { asc, eq } from "drizzle-orm" +import { testEffect } from "./lib/effect" + +let requests: LLMRequest[] = [] +const model = Model.make({ + id: "summary-model", + provider: "test", + route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), +}) +const client = Layer.mock(LLMClient.Service)({ + prepare: () => Effect.die("unused"), + stream: (request: LLMRequest) => { + requests.push(request) + return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" })) + }, + generate: () => Effect.die("unused"), +}) +const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) +const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) }) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionCompaction.layer.pipe( + Layer.provide(client), + Layer.provide(config), + Layer.provide(models), + Layer.provide(EventV2.defaultLayer), + ), + ), +) test("compaction describes tool media without embedding base64", () => { const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" @@ -16,3 +66,65 @@ test("compaction describes tool media without embedding base64", () => { expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]") expect(serialized).not.toContain(base64) }) + +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 store = yield* SessionStore.Service + const sessionID = SessionV2.ID.make("ses_manual_compaction") + 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: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "manual-compaction", + directory: "/project", + title: "Manual compaction", + version: "test", + }) + .run() + .pipe(Effect.orDie) + + const session = yield* store + .get(sessionID) + .pipe( + Effect.flatMap((session) => + session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"), + ), + ) + + expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true) + + expect(requests).toHaveLength(1) + expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") + expect(yield* store.context(sessionID)).toMatchObject([ + { type: "compaction", reason: "manual", summary: "manual summary", recent: "" }, + ]) + expect( + yield* db + .select({ type: EventTable.type }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie), + ).toEqual([ + { type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) }, + { type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) }, + ]) + }), +) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index cf3f3801d64..ac87c0e3669 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" +import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -73,6 +74,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const runner = SessionRunnerLLM.defaultLayer.pipe( + Layer.provide(SessionCompaction.layer), Layer.provide(Snapshot.noopLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 047f64002fa..4c9c0d2b8f7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -23,6 +23,7 @@ import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" @@ -234,6 +235,7 @@ const config = Layer.succeed( }), ) const runner = SessionRunnerLLM.layer.pipe( + Layer.provide(SessionCompaction.layer), Layer.provide(Snapshot.noopLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d675b72378c..c33f2076fee 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -243,7 +243,7 @@ export const makeSessionGroup = (sessionLo HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, ServiceUnavailableError], + error: [SessionNotFoundError, SessionBusyError, ServiceUnavailableError, UnknownError], }) .middleware(sessionLocationMiddleware) .annotateMerge( diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2096cec7c74..16d6ba7f1ee 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2729,6 +2729,12 @@ export type ServiceUnavailableError = { service?: string } +export type UnknownError1 = { + _tag: "UnknownError" + message: string + ref?: string +} + export type MessageNotFoundError = { _tag: "MessageNotFoundError" sessionID: string @@ -2736,12 +2742,6 @@ export type MessageNotFoundError = { message: string } -export type UnknownError1 = { - _tag: "UnknownError" - message: string - ref?: string -} - export type SessionDurableEvent = | SessionNextAgentSwitched | SessionNextModelSwitched @@ -11708,6 +11708,14 @@ export type V2SessionCompactErrors = { * SessionNotFoundError */ 404: SessionNotFoundError + /** + * SessionBusyError + */ + 409: SessionBusyError + /** + * UnknownError + */ + 500: UnknownError1 /** * ServiceUnavailableError */ diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index ef0c34fe692..cf092cc5e1e 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -208,6 +208,25 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), + Effect.catchTag( + "Session.BusyError", + (error) => + new SessionBusyError({ + sessionID: error.sessionID, + message: `Session is busy: ${error.sessionID}`, + }), + ), + Effect.catchTag("Session.MessageDecodeError", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to decode session message during compaction").pipe( + Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), + Effect.andThen( + Effect.fail( + new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }), + ), + ), + ) + }), ) return HttpApiSchema.NoContent.make() }),