Procházet zdrojové kódy

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

Kit Langton před 1 měsícem
rodič
revize
f7034a35a8

+ 1 - 1
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,

+ 16 - 16
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

+ 2 - 0
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,
 ])

+ 18 - 5
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<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)

+ 124 - 31
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<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],
+})

+ 20 - 20
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<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,
   ],

+ 107 - 0
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<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: "" },
+      ])
+    }),
+  )
+})

+ 112 - 0
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) },
+    ])
+  }),
+)

+ 2 - 0
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),

+ 2 - 0
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),

+ 1 - 1
packages/protocol/src/groups/session.ts

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

+ 14 - 6
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
    */

+ 19 - 0
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()
         }),