mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f36db6c114 | |||
| fb5726baa8 | |||
| 71751ac339 | |||
| c6364f4b2e |
@@ -1,7 +1,8 @@
|
|||||||
export * as SessionCompaction from "./compaction"
|
export * as SessionCompaction from "./compaction"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
@@ -10,6 +11,7 @@ import { SessionEvent } from "./event"
|
|||||||
import type { SessionMessage } from "./message"
|
import type { SessionMessage } from "./message"
|
||||||
import { SessionRunnerModel } from "./runner/model"
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
|
import { toSessionError } from "./to-session-error"
|
||||||
import { Token } from "../util/token"
|
import { Token } from "../util/token"
|
||||||
|
|
||||||
const DEFAULT_BUFFER = 20_000
|
const DEFAULT_BUFFER = 20_000
|
||||||
@@ -59,7 +61,8 @@ type Dependencies = {
|
|||||||
readonly llm: {
|
readonly llm: {
|
||||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||||
}
|
}
|
||||||
readonly config: readonly Config.Entry[]
|
readonly config: Settings
|
||||||
|
readonly models: SessionRunnerModel.Interface
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutoInput = {
|
export type AutoInput = {
|
||||||
@@ -75,6 +78,17 @@ type CompactInput = {
|
|||||||
readonly inputID?: SessionMessage.ID
|
readonly inputID?: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Selection = {
|
||||||
|
readonly head: string
|
||||||
|
readonly recent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FailureTarget = {
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly reason: SessionMessage.Compaction["reason"]
|
||||||
|
readonly inputID?: SessionMessage.ID
|
||||||
|
}
|
||||||
|
|
||||||
export type ManualInput = {
|
export type ManualInput = {
|
||||||
readonly session: SessionSchema.Info
|
readonly session: SessionSchema.Info
|
||||||
readonly messages: readonly SessionMessage.Info[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
@@ -101,7 +115,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
|
|||||||
)
|
)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|
||||||
const serialize = (message: SessionMessage.Info) => {
|
const serializeMessage = (message: SessionMessage.Info) => {
|
||||||
if (message.type === "user") {
|
if (message.type === "user") {
|
||||||
const files =
|
const files =
|
||||||
message.files?.map(
|
message.files?.map(
|
||||||
@@ -134,7 +148,7 @@ const serialize = (message: SessionMessage.Info) => {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = (documents: readonly Config.Entry[]) => {
|
const resolveSettings = (documents: readonly Config.Entry[]) => {
|
||||||
const configured = documents
|
const configured = documents
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||||
@@ -148,13 +162,13 @@ const settings = (documents: readonly Config.Entry[]) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = (
|
const selectCompactionContext = (
|
||||||
messages: readonly SessionMessage.Info[],
|
messages: readonly SessionMessage.Info[],
|
||||||
tokens: number,
|
tokens: number,
|
||||||
): { readonly head: string; readonly recent: string } | undefined => {
|
): Selection | undefined => {
|
||||||
const conversation = messages
|
const conversation = messages
|
||||||
.filter((message) => message.type !== "compaction")
|
.filter((message) => message.type !== "compaction")
|
||||||
.map(serialize)
|
.map(serializeMessage)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
if (conversation.length === 0) return undefined
|
if (conversation.length === 0) return undefined
|
||||||
let total = 0
|
let total = 0
|
||||||
@@ -181,6 +195,15 @@ const select = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findCompletedSummary = (messages: readonly SessionMessage.Info[]) =>
|
||||||
|
messages.find(
|
||||||
|
(message): message is SessionMessage.CompactionCompleted =>
|
||||||
|
message.type === "compaction" && message.status === "completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveOutputLimit = (request: LLMRequest) =>
|
||||||
|
request.generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 0
|
||||||
|
|
||||||
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
|
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
|
||||||
[
|
[
|
||||||
input.previousSummary
|
input.previousSummary
|
||||||
@@ -191,7 +214,14 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
|
|||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
|
|
||||||
const make = (dependencies: Dependencies) => {
|
const make = (dependencies: Dependencies) => {
|
||||||
const config = settings(dependencies.config)
|
const config = dependencies.config
|
||||||
|
const publishFailure = (target: FailureTarget, error: SessionError.Error) =>
|
||||||
|
dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
||||||
|
sessionID: target.sessionID,
|
||||||
|
reason: target.reason,
|
||||||
|
error,
|
||||||
|
inputID: target.inputID,
|
||||||
|
})
|
||||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
|
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly model: Model
|
readonly model: Model
|
||||||
@@ -202,12 +232,9 @@ const make = (dependencies: Dependencies) => {
|
|||||||
readonly output?: number
|
readonly output?: number
|
||||||
readonly inputID?: SessionMessage.ID
|
readonly inputID?: SessionMessage.ID
|
||||||
}) {
|
}) {
|
||||||
const context = input.model.route.defaults.limits?.context
|
|
||||||
if (context === undefined || context <= 0) return false
|
|
||||||
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
|
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
|
||||||
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
|
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
|
||||||
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
||||||
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
|
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
@@ -216,8 +243,8 @@ const make = (dependencies: Dependencies) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let failed = false
|
let failure: SessionError.Error | undefined
|
||||||
const summarized = yield* dependencies.llm
|
yield* dependencies.llm
|
||||||
.stream(
|
.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: input.model,
|
model: input.model,
|
||||||
@@ -228,7 +255,11 @@ const make = (dependencies: Dependencies) => {
|
|||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event)) failed = true
|
if (LLMEvent.is.providerError(event))
|
||||||
|
failure = {
|
||||||
|
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||||
|
message: event.message,
|
||||||
|
}
|
||||||
if (LLMEvent.is.textDelta(event)) {
|
if (LLMEvent.is.textDelta(event)) {
|
||||||
chunks.push(event.text)
|
chunks.push(event.text)
|
||||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||||
@@ -238,27 +269,26 @@ const make = (dependencies: Dependencies) => {
|
|||||||
}
|
}
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.as(true),
|
Effect.catchTag("LLM.Error", (error) =>
|
||||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
Effect.sync(() => {
|
||||||
|
failure = toSessionError(error)
|
||||||
|
}),
|
||||||
|
),
|
||||||
Effect.onInterrupt(() =>
|
Effect.onInterrupt(() =>
|
||||||
input.reason === "auto"
|
input.reason === "auto"
|
||||||
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
? publishFailure(input, {
|
||||||
sessionID: input.sessionID,
|
type: "compaction.interrupted",
|
||||||
reason: input.reason,
|
message: "Compaction was interrupted",
|
||||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
|
||||||
inputID: input.inputID,
|
|
||||||
})
|
})
|
||||||
: Effect.void,
|
: Effect.void,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const summary = chunks.join("")
|
const summary = chunks.join("")
|
||||||
if (!summarized || failed || !summary.trim()) {
|
if (failure || !summary.trim()) {
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
yield* publishFailure(
|
||||||
sessionID: input.sessionID,
|
input,
|
||||||
reason: input.reason,
|
failure ?? { type: "compaction.failed", message: "Compaction produced no summary" },
|
||||||
error: { type: "compaction.failed", message: "Compaction produced no summary" },
|
)
|
||||||
inputID: input.inputID,
|
|
||||||
})
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
||||||
@@ -269,61 +299,103 @@ const make = (dependencies: Dependencies) => {
|
|||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
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* (
|
const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* (
|
||||||
input: CompactInput & {
|
input: CompactInput & {
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
readonly reason: SessionMessage.Compaction["reason"]
|
||||||
readonly force: boolean
|
|
||||||
readonly output?: number
|
readonly output?: number
|
||||||
},
|
},
|
||||||
|
selected: Selection,
|
||||||
) {
|
) {
|
||||||
const context = input.model.route.defaults.limits?.context
|
const previousSummary = findCompletedSummary(input.messages)
|
||||||
if (context === undefined || context <= 0) return false
|
const summarizeRecent = selected.head.length === 0
|
||||||
const selected = select(input.messages, config.tokens)
|
const previousRecent = previousSummary?.recent ?? ""
|
||||||
if (!selected) return false
|
|
||||||
const previousSummary = input.messages.find(
|
|
||||||
(message) => message.type === "compaction" && message.status === "completed",
|
|
||||||
)
|
|
||||||
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({
|
return yield* compact({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
previousSummary: previousSummary?.summary,
|
||||||
context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
|
context: (summarizeRecent ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
),
|
),
|
||||||
recent: forcedShortContext ? "" : selected.recent,
|
recent: summarizeRecent ? "" : selected.recent,
|
||||||
output: input.output,
|
output: input.output,
|
||||||
inputID: input.inputID,
|
inputID: input.inputID,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
|
||||||
return yield* compactSelected({ ...input, reason: "manual", force: true })
|
const selected = selectCompactionContext(input.messages, config.tokens)
|
||||||
|
if (!selected) return false
|
||||||
|
return yield* compactSelected(
|
||||||
|
{
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messages: input.messages,
|
||||||
|
model: input.request.model,
|
||||||
|
reason: "auto",
|
||||||
|
output: resolveOutputLimit(input.request),
|
||||||
|
},
|
||||||
|
selected,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||||
|
const target = {
|
||||||
|
sessionID: input.session.id,
|
||||||
|
reason: "manual",
|
||||||
|
inputID: input.inputID,
|
||||||
|
} satisfies FailureTarget
|
||||||
|
const selected = selectCompactionContext(input.messages, config.tokens)
|
||||||
|
if (!selected) {
|
||||||
|
yield* publishFailure(target, { type: "compaction.unavailable", message: "Nothing to compact yet" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const resolved = yield* dependencies.models.resolve(input.session).pipe(
|
||||||
|
Effect.catch((error) => publishFailure(target, toSessionError(error)).pipe(Effect.as(undefined))),
|
||||||
|
)
|
||||||
|
if (!resolved) return false
|
||||||
|
return yield* compactSelected(
|
||||||
|
{
|
||||||
|
...target,
|
||||||
|
messages: input.messages,
|
||||||
|
model: resolved.model,
|
||||||
|
},
|
||||||
|
selected,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
|
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
|
||||||
if (!config.auto) return false
|
if (!config.auto) return false
|
||||||
const context = input.request.model.route.defaults.limits?.context
|
const context = input.request.model.route.defaults.limits?.context
|
||||||
if (context === undefined || context <= 0) return false
|
if (context === undefined || context <= 0) return false
|
||||||
const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0
|
const output = resolveOutputLimit(input.request)
|
||||||
if (
|
if (
|
||||||
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
|
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
|
||||||
context - Math.max(output, config.buffer)
|
context - Math.max(output, config.buffer)
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
return yield* compactAfterOverflow(input)
|
const selected = selectCompactionContext(input.messages, config.tokens)
|
||||||
|
if (!selected) return false
|
||||||
|
const previousSummary = findCompletedSummary(input.messages)
|
||||||
|
if (!selected.head && !previousSummary) return false
|
||||||
|
const previousRecent = previousSummary?.recent ?? ""
|
||||||
|
const summaryContext = [previousRecent, selected.head].filter(Boolean)
|
||||||
|
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
||||||
|
if (
|
||||||
|
Token.estimate(
|
||||||
|
buildPrompt({
|
||||||
|
previousSummary: previousSummary?.summary,
|
||||||
|
context: summaryContext,
|
||||||
|
}),
|
||||||
|
) >
|
||||||
|
context - summaryOutput
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
return yield* compact({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
model: input.request.model,
|
||||||
|
reason: "auto",
|
||||||
|
previousSummary: previousSummary?.summary,
|
||||||
|
context: summaryContext,
|
||||||
|
recent: selected.recent,
|
||||||
|
output,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
compactIfNeeded,
|
compactIfNeeded,
|
||||||
@@ -339,22 +411,7 @@ export const layer = Layer.effect(
|
|||||||
const llm = yield* LLMClient.Service
|
const llm = yield* LLMClient.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const compaction = make({ events, llm, config: yield* config.entries() })
|
return Service.of(make({ events, llm, models, config: resolveSettings(yield* config.entries()) }))
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
compactIfNeeded: compaction.compactIfNeeded,
|
|
||||||
compactAfterOverflow: compaction.compactAfterOverflow,
|
|
||||||
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
|
||||||
const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!resolved) return false
|
|
||||||
return yield* compaction.compactManual({
|
|
||||||
sessionID: input.session.id,
|
|
||||||
messages: input.messages,
|
|
||||||
model: resolved.model,
|
|
||||||
inputID: input.inputID,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ const compactModel = Model.make({
|
|||||||
provider: "fake",
|
provider: "fake",
|
||||||
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
|
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
|
||||||
})
|
})
|
||||||
|
const undersizedContextModel = Model.make({
|
||||||
|
id: "undersized-context",
|
||||||
|
provider: "fake",
|
||||||
|
route: OpenAIChat.route.with({ limits: { context: 1, output: 1_000 } }),
|
||||||
|
})
|
||||||
const recoveryModel = Model.make({
|
const recoveryModel = Model.make({
|
||||||
id: "recovery",
|
id: "recovery",
|
||||||
provider: "fake",
|
provider: "fake",
|
||||||
@@ -1534,11 +1539,12 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("settles an admitted manual compaction that cannot start", () =>
|
it.effect("explains when manual compaction has no history", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const compaction = yield* session.compact({ sessionID })
|
const compaction = yield* session.compact({ sessionID })
|
||||||
|
modelResolveHook = Effect.die("model resolution should not run")
|
||||||
|
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
@@ -1547,7 +1553,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
error: { message: "Compaction could not start" },
|
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
(yield* recordedEventTypes(sessionID)).filter(
|
(yield* recordedEventTypes(sessionID)).filter(
|
||||||
@@ -1557,10 +1563,73 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("manually compacts when the model has no context limit", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
response = reply.text("Earlier answer", "text-manual-unknown-history")
|
||||||
|
yield* admit(session, "Earlier question")
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = reply.text("Manual summary", "text-manual-unknown-summary")
|
||||||
|
const compaction = yield* session.compact({ sessionID })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(userTexts(requests[0])[0]).toContain("Earlier question")
|
||||||
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||||
|
type: "compaction",
|
||||||
|
status: "completed",
|
||||||
|
summary: "Manual summary",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves provider errors from manual compaction", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
response = reply.text("Earlier answer", "text-manual-provider-history")
|
||||||
|
yield* admit(session, "Earlier question")
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
response = [LLMEvent.providerError({ message: "summary unavailable" })]
|
||||||
|
const compaction = yield* session.compact({ sessionID })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||||
|
type: "compaction",
|
||||||
|
status: "failed",
|
||||||
|
error: { type: "provider.error", message: "summary unavailable" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves typed provider failures from manual compaction", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
response = reply.text("Earlier answer", "text-manual-failure-history")
|
||||||
|
yield* admit(session, "Earlier question")
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
responseStream = Stream.fail(providerUnavailable())
|
||||||
|
const compaction = yield* session.compact({ sessionID })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||||
|
type: "compaction",
|
||||||
|
status: "failed",
|
||||||
|
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
|
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
const session = yield* setup
|
||||||
const session = yield* SessionV2.Service
|
response = reply.text("Earlier answer", "text-manual-resolution-history")
|
||||||
|
yield* admit(session, "Earlier question")
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
const compaction = yield* session.compact({ sessionID })
|
const compaction = yield* session.compact({ sessionID })
|
||||||
modelResolveHook = Effect.die("model resolution failed")
|
modelResolveHook = Effect.die("model resolution failed")
|
||||||
|
|
||||||
@@ -1659,6 +1728,46 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("recovers from provider context overflow without a configured context limit", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setupOverflowRecovery
|
||||||
|
currentModel = model
|
||||||
|
responses = [
|
||||||
|
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||||
|
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||||
|
reply.text("Recovered", "text-final-unknown-limit"),
|
||||||
|
]
|
||||||
|
yield* admit(session, "Continue")
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(3)
|
||||||
|
expect(yield* session.context(sessionID)).toMatchObject([
|
||||||
|
{ type: "compaction", summary: "## Objective\n- Recover unknown limit" },
|
||||||
|
{ type: "assistant", finish: "stop" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("recovers from provider context overflow despite an undersized configured context limit", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setupOverflowRecovery
|
||||||
|
currentModel = undersizedContextModel
|
||||||
|
responses = [
|
||||||
|
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||||
|
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||||
|
reply.text("Recovered", "text-final-undersized-limit"),
|
||||||
|
]
|
||||||
|
yield* admit(session, "Continue")
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(3)
|
||||||
|
expect(yield* session.context(sessionID)).toMatchObject([
|
||||||
|
{ type: "compaction", summary: "## Objective\n- Recover undersized limit" },
|
||||||
|
{ type: "assistant", finish: "stop" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("persists a second context overflow after one recovery", () =>
|
it.effect("persists a second context overflow after one recovery", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
@@ -1718,7 +1827,14 @@ describe("SessionRunnerLLM", () => {
|
|||||||
|
|
||||||
expect(requests).toHaveLength(2)
|
expect(requests).toHaveLength(2)
|
||||||
const context = yield* session.context(sessionID)
|
const context = yield* session.context(sessionID)
|
||||||
expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }))
|
expect(context).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "compaction",
|
||||||
|
status: "failed",
|
||||||
|
reason: "auto",
|
||||||
|
error: { type: "provider.error", message: "summary unavailable" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
expect(context.slice(-3)).toMatchObject([
|
expect(context.slice(-3)).toMatchObject([
|
||||||
{ type: "user", text: "Continue" },
|
{ type: "user", text: "Continue" },
|
||||||
{ type: "compaction", status: "failed", reason: "auto" },
|
{ type: "compaction", status: "failed", reason: "auto" },
|
||||||
|
|||||||
Reference in New Issue
Block a user