feat(core): add v2 manual compaction (#34336)

This commit is contained in:
Kit Langton 2026-06-28 21:18:19 -04:00 committed by GitHub
parent 53b93b6991
commit f7034a35a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 438 additions and 80 deletions

View file

@ -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,

View file

@ -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

View file

@ -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,
])

View file

@ -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<NotFoundError>()("Session.NotFoundError", {
@ -113,7 +113,7 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("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<SessionSchema.Info[]>
@ -169,7 +169,9 @@ export interface Interface {
skill: string
resume?: boolean
}) => Effect.Effect<void, OperationUnavailableError>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
@ -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)

View file

@ -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<boolean>
readonly compactAfterOverflow: (input: AutoInput) => Effect.Effect<boolean>
readonly compactManual: (input: ManualInput) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@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],
})

View file

@ -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<string> = []) =>
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,
],

View file

@ -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<LocationServices>,
),
)
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: "" },
])
}),
)
})

View file

@ -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) },
])
}),
)

View file

@ -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),

View file

@ -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),

View file

@ -243,7 +243,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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(

View file

@ -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
*/

View file

@ -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()
}),