mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3023c2d995 |
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, AIError, LLMEvent, LLMRequest, Message } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -9,14 +9,15 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
import type { Instructions } from "../instructions/index.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { Agent } from "../agent.js"
|
||||
import { State } from "../state.js"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
@@ -77,26 +78,25 @@ export type AutoInput = {
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
/** The runner resolves the conversation agent only when there is history to compact. */
|
||||
readonly context: Effect.Effect<
|
||||
SessionContext.Loaded,
|
||||
AgentNotFoundError | SessionRunnerModel.Error | Instructions.InitializationBlocked
|
||||
>
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
export type ManualInput = Pick<AutoInput, "session" | "messages" | "context" | "prepare"> & {
|
||||
readonly inputID: SessionMessage.ID
|
||||
readonly started?: boolean
|
||||
/** Invoked after content planning, not when the caller captures the operation. */
|
||||
readonly resolveModel: SessionContext.Interface["resolveModel"]
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
}
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly context: AutoInput["context"]
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
readonly recent: string
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly inputID?: SessionMessage.ID
|
||||
readonly started?: boolean
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
@@ -176,10 +176,7 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
const select = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
tokens: number,
|
||||
): { readonly head: string; readonly recent: string } | undefined => {
|
||||
const select = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||
const conversation = messages
|
||||
.filter((message) => message.type !== "compaction" && message.type !== "system")
|
||||
.flatMap((message) => {
|
||||
@@ -201,10 +198,8 @@ const select = (
|
||||
if (latestUser > 0) split = latestUser
|
||||
}
|
||||
return {
|
||||
head: conversation
|
||||
.slice(0, split)
|
||||
.map((item) => item.text)
|
||||
.join("\n\n"),
|
||||
split: messages.indexOf(conversation[split].message),
|
||||
hasHead: split > 0,
|
||||
recent: conversation
|
||||
.slice(split)
|
||||
.map((item) => item.text)
|
||||
@@ -212,14 +207,12 @@ const select = (
|
||||
}
|
||||
}
|
||||
|
||||
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
|
||||
export const buildPrompt = () =>
|
||||
[
|
||||
input.previousSummary
|
||||
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
|
||||
: "Create a new anchored summary from the conversation history.",
|
||||
"Summarize the conversation above so work can continue without the earlier messages.",
|
||||
SUMMARY_TEMPLATE,
|
||||
"The following is the conversation history:",
|
||||
...input.context,
|
||||
"If the history contains a conversation checkpoint, incorporate its summary and recent context. Preserve still-true details, remove stale details, and merge in the new facts.",
|
||||
"Do not continue the task or call tools. Output only the summary.",
|
||||
].join("\n\n")
|
||||
|
||||
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||
@@ -229,13 +222,10 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
|
||||
(message): message is SessionMessage.CompactionCompleted =>
|
||||
message.type === "compaction" && message.status === "completed",
|
||||
)
|
||||
const previousRecent = previousSummary?.recent ?? ""
|
||||
const summarizeRecent = !previousRecent && !selected.head
|
||||
const summarizeRecent = !previousSummary?.recent && !selected.hasHead
|
||||
return {
|
||||
prompt: buildPrompt({
|
||||
previousSummary: previousSummary?.summary,
|
||||
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
|
||||
}),
|
||||
// Keep the existing checkpoint and chronological updates in their original positions.
|
||||
messages: summarizeRecent ? messages : messages.slice(0, selected.split),
|
||||
recent: summarizeRecent ? "" : selected.recent,
|
||||
}
|
||||
}
|
||||
@@ -262,11 +252,39 @@ const make = (dependencies: Dependencies) => {
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||
if (!plan.started)
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
|
||||
if (
|
||||
!plan.messages.some((message) => message.type !== "compaction" && message.type !== "system" && serialize(message))
|
||||
)
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
recent: plan.recent,
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
const loaded = yield* plan.context.pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: toSessionError(cause),
|
||||
inputID: plan.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in loaded) return loaded
|
||||
const content = planContent(loaded.messages, state.get().tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
if (!plan.started)
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: loaded.session.id,
|
||||
reason: plan.reason,
|
||||
recent: content.recent,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
|
||||
@@ -276,33 +294,44 @@ const make = (dependencies: Dependencies) => {
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: loaded.agent.info,
|
||||
model: loaded.model,
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: content.messages,
|
||||
})
|
||||
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript,
|
||||
})
|
||||
const request = LLMRequest.update(prepared.request, {
|
||||
messages: [...prepared.request.messages, Message.user(buildPrompt())],
|
||||
})
|
||||
yield* dependencies.llm.stream(request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.toolCall(event))
|
||||
failure = { type: "compaction.failed", message: "Compaction attempted to call a tool" }
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
const step = SessionUsage.record(event.usage, loaded.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
@@ -317,7 +346,7 @@ const make = (dependencies: Dependencies) => {
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
@@ -332,36 +361,29 @@ const make = (dependencies: Dependencies) => {
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
reason: plan.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
}
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
reason: plan.reason,
|
||||
text: summary,
|
||||
recent: plan.recent,
|
||||
recent: content.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved: input.resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
const compact = Effect.fn("SessionCompaction.compact")((input: AutoInput) =>
|
||||
execute({
|
||||
session: input.session,
|
||||
messages: input.messages,
|
||||
context: input.context,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
@@ -383,36 +405,12 @@ const make = (dependencies: Dependencies) => {
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")((input: ManualInput) =>
|
||||
execute({
|
||||
...input,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface Loaded {
|
||||
*/
|
||||
export interface Interface {
|
||||
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
|
||||
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
|
||||
readonly select: (sessionID: SessionSchema.ID, agentID?: Agent.ID) => Effect.Effect<Selection, AgentNotFoundError>
|
||||
/** Resolves the model and active history for that selection. */
|
||||
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
|
||||
readonly resolveModel: (
|
||||
@@ -119,7 +119,7 @@ const layer = Layer.effect(
|
||||
return { agent, primary, selected }
|
||||
})
|
||||
|
||||
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
|
||||
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
@@ -127,8 +127,8 @@ const layer = Layer.effect(
|
||||
|
||||
yield* plugins.flush
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const agent = yield* agents.select(agentID ?? session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: agent.id })
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(agent.info.permissions),
|
||||
|
||||
@@ -63,6 +63,24 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
||||
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
/** Finds the last assistant even when a checkpoint has replaced it in model-visible history. */
|
||||
export const latestAssistant = Effect.fn("SessionHistory.latestAssistant")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
const message = yield* decodeMessageRow(row).pipe(Effect.orDie)
|
||||
return message.type === "assistant" ? message : undefined
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
|
||||
@@ -60,7 +60,7 @@ interface PrepareInput {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
/** Omitted for requests that carry no tools (title, compaction). */
|
||||
/** Omitted for requests that carry no tools, such as titles. */
|
||||
readonly tools?: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
@@ -70,7 +70,7 @@ interface PrepareInput {
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape the agent conversation. Requests that are not
|
||||
* part of the conversation (title, compaction) opt out: their transcripts
|
||||
* part of the conversation (such as titles) opt out: their transcripts
|
||||
* pass through unchanged.
|
||||
*/
|
||||
readonly contextHooks?: false
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InstructionState } from "../instruction-state.js"
|
||||
import { SessionCompaction } from "../compaction.js"
|
||||
import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionHistory } from "../history.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
@@ -139,11 +140,12 @@ const layer = Layer.effect(
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const compacted = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* store.context(sessionID)
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: context.resolveModel,
|
||||
prepare: context.prepare,
|
||||
messages: yield* store.context(sessionID),
|
||||
messages,
|
||||
context: loadCompactionContext(sessionID, messages),
|
||||
inputID: pending.id,
|
||||
started: true,
|
||||
})
|
||||
@@ -204,6 +206,20 @@ const layer = Layer.effect(
|
||||
return selected
|
||||
})
|
||||
|
||||
const loadCompactionContext = Effect.fn("SessionRunner.loadCompactionContext")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
messages: readonly SessionMessage.Info[],
|
||||
loaded?: SessionContext.Loaded,
|
||||
) {
|
||||
const last =
|
||||
messages.findLast((message) => message.type === "assistant") ??
|
||||
(yield* SessionHistory.latestAssistant(db, sessionID))
|
||||
if (loaded && (!last || last.agent === loaded.agent.id)) return loaded
|
||||
const selected = yield* context.select(sessionID, last?.agent)
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, sessionID)
|
||||
return yield* context.load(selected)
|
||||
})
|
||||
|
||||
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
|
||||
const sessionID = first.session.id
|
||||
@@ -221,6 +237,7 @@ const layer = Layer.effect(
|
||||
messages: loaded.messages,
|
||||
resolved: loaded.model,
|
||||
prepare: context.prepare,
|
||||
context: loadCompactionContext(sessionID, loaded.messages, loaded),
|
||||
}
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
|
||||
@@ -78,25 +78,33 @@ describe("ConfigCompactionPlugin.Plugin", () => {
|
||||
const started = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Started)
|
||||
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
const messages: SessionMessage.Info[] = [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
},
|
||||
],
|
||||
context: Effect.succeed({
|
||||
session,
|
||||
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
|
||||
model: resolved,
|
||||
initial: "",
|
||||
messages,
|
||||
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
|
||||
}),
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_compaction_manual"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LanguageModel, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import type { SessionContext } from "@opencode-ai/core/session/context"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
@@ -74,6 +75,18 @@ const resolved = SessionRunnerModel.resolved(model, {
|
||||
cost,
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
})
|
||||
const context = (
|
||||
session: Session.Info,
|
||||
messages: readonly SessionMessage.Info[],
|
||||
): Effect.Effect<SessionContext.Loaded> =>
|
||||
Effect.succeed({
|
||||
session,
|
||||
agent: { id: Agent.defaultID, info: { ...Agent.Info.default(Agent.defaultID), system: "Working agent system" } },
|
||||
model: resolved,
|
||||
initial: "Session instructions",
|
||||
messages,
|
||||
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
@@ -93,7 +106,7 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
test("compaction prompt preserves detailed work state and relevant files", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
|
||||
const prompt = SessionCompaction.buildPrompt()
|
||||
|
||||
expect(prompt).toContain("## Work State\n### Completed")
|
||||
expect(prompt).toContain("### Active")
|
||||
@@ -125,7 +138,7 @@ test("compaction truncation does not split surrogate pairs", () => {
|
||||
})
|
||||
|
||||
test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
|
||||
const prompt = SessionCompaction.buildPrompt()
|
||||
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
|
||||
"## Objective",
|
||||
"## Important Details",
|
||||
@@ -249,8 +262,8 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
prepare: modelRequests.prepare,
|
||||
context: context(session, [userMessage]),
|
||||
messages: [userMessage],
|
||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||
}),
|
||||
@@ -269,6 +282,9 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(requests[0]?.generation).toBeUndefined()
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Working agent system", "Session instructions"])
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"])
|
||||
expect(requests[0]?.messages.at(-1)?.content).toEqual([Message.text(SessionCompaction.buildPrompt())])
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
@@ -305,19 +321,20 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
|
||||
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
|
||||
})
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages: SessionMessage.Info[] = [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
context: context(session, messages),
|
||||
messages,
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_fork_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
@@ -327,38 +344,45 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps session context hooks away from compaction requests", () =>
|
||||
it.effect("applies the working agent's context hooks to compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
// Context hooks shape the agent conversation; compaction is not part of it,
|
||||
// so it opts out and the transcript passes through unchanged.
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.agent).toBe(Agent.defaultID)
|
||||
event.system.push(SystemPart.make("Injected conversation context"))
|
||||
event.messages.push(Message.user("Additional conversation context"))
|
||||
}),
|
||||
)
|
||||
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages: SessionMessage.Info[] = [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
context: context(session, messages),
|
||||
messages,
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_hook_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system).toEqual([])
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([
|
||||
"Working agent system",
|
||||
"Session instructions",
|
||||
"Injected conversation context",
|
||||
])
|
||||
expect(requests[0]?.messages.at(-2)?.content).toEqual([Message.text("Additional conversation context")])
|
||||
expect(requests[0]?.messages.at(-1)?.content).toEqual([Message.text(SessionCompaction.buildPrompt())])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1641,7 +1641,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect(userTexts(requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
@@ -2287,7 +2287,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(4)
|
||||
expect(userTexts(requests[1])).toContain("Steer after compaction")
|
||||
expect(userTexts(requests[1])).toContain("Completion after compaction")
|
||||
expect(userTexts(requests[2])[0]).toContain("Create a new anchored summary")
|
||||
expect(requests[2]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect(userTexts(requests[3])).toContain("Queue after compaction")
|
||||
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toBeUndefined()
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
|
||||
@@ -2368,12 +2368,17 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
requests.length = 0
|
||||
systemBaseline = "Changed before manual compaction"
|
||||
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
|
||||
const compaction = yield* session.compact({ sessionID, delivery: "steer" })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0])[0]).toContain("Earlier question")
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "system", "user"])
|
||||
expect(systemTexts(requests[0])).toEqual(["Changed before manual compaction"])
|
||||
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
@@ -2404,7 +2409,7 @@ describe("SessionRunnerLLM", () => {
|
||||
// Steer-delivered compaction runs at the boundary after the active step, ahead of
|
||||
// the queued prompt, and consuming it does not trigger an input-free model call.
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect(userTexts(requests[2])).toContain("Queued prompt")
|
||||
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
@@ -2421,7 +2426,13 @@ describe("SessionRunnerLLM", () => {
|
||||
currentModel = recoveryModel
|
||||
const stream = yield* TestLLM.gate
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
TestLLM.complete(
|
||||
{ reason: { normalized: "tool-calls" } },
|
||||
LLMEvent.reasoningStart({ id: "reasoning-active" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-active", text: "Check the active work" }),
|
||||
LLMEvent.reasoningEnd({ id: "reasoning-active", providerMetadata: { openai: { signature: "signed" } } }),
|
||||
LLMEvent.toolCall({ id: "call-active", name: "echo", input: { text: "active" } }),
|
||||
),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
TestLLM.text("Continued", "text-continued-after-compact"),
|
||||
)
|
||||
@@ -2435,7 +2446,20 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
// The compaction summary is requested before the tool turn's continuation step.
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect(requests[1]?.system).toEqual(requests[0]?.system)
|
||||
expect(requests[1]?.tools).toEqual(requests[0]?.tools)
|
||||
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool", "user"])
|
||||
expect(requests[1]?.messages[0]).toEqual(requests[0]?.messages[0])
|
||||
expect(requests[1]?.messages[1]?.content).toMatchObject([
|
||||
{ type: "reasoning", text: "Check the active work", providerMetadata: { openai: { signature: "signed" } } },
|
||||
{ type: "tool-call", id: "call-active", name: "echo", input: { text: "active" } },
|
||||
])
|
||||
expect(requests[1]?.messages[2]?.content).toMatchObject([
|
||||
{ type: "tool-result", id: "call-active", name: "echo", result: { type: "text", value: "active" } },
|
||||
])
|
||||
expect(executions).toEqual(["active"])
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
@@ -2444,6 +2468,121 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const mode of ["manual", "auto"] as const) {
|
||||
it.effect(`uses the last assistant's custom agent after a switch for ${mode} compaction`, () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* Agent.Service
|
||||
const bus = yield* Bus.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: Agent.ID[] = []
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("reviewer"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.system = "Reviewer instructions"
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(event.agent)
|
||||
event.system.push(SystemPart.make(`Context hook for ${event.agent}`))
|
||||
if (event.agent === "build") delete event.tools.echo
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("reviewer") })
|
||||
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-custom-agent", 3_950))
|
||||
yield* runPrompt(session, "Earlier question ".repeat(180))
|
||||
const original = requests[0]
|
||||
|
||||
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("build") })
|
||||
currentModel = compactModel
|
||||
requests.length = 0
|
||||
seen.length = 0
|
||||
yield* TestLLM.push(TestLLM.text("Reviewer summary", "text-custom-summary"))
|
||||
if (mode === "manual") yield* session.compact({ sessionID })
|
||||
if (mode === "auto") {
|
||||
yield* admit(session, "Recent exact request ".repeat(180))
|
||||
yield* TestLLM.push(TestLLM.text("Continued by build", "text-custom-continuation"))
|
||||
}
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(seen).toEqual(
|
||||
mode === "manual" ? [Agent.ID.make("reviewer")] : [Agent.ID.make("reviewer"), Agent.ID.make("build")],
|
||||
)
|
||||
expect(requests[0]?.model).toBe(compactModel)
|
||||
expect(requests[0]?.system).toEqual(original?.system)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([
|
||||
"Reviewer instructions",
|
||||
"Initial context",
|
||||
"Context hook for reviewer",
|
||||
])
|
||||
expect(requests[0]?.tools).toEqual(original?.tools)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("echo")
|
||||
expect(requests[0]?.messages[0]).toEqual(original?.messages[0])
|
||||
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect(yield* session.context(sessionID)).toContainEqual(
|
||||
expect.objectContaining({ type: "compaction", status: "completed", summary: "Reviewer summary" }),
|
||||
)
|
||||
expect((yield* session.get(sessionID))?.agent).toBe(Agent.ID.make("build"))
|
||||
if (mode === "auto") {
|
||||
expect(requests[1]?.system.map((part) => part.text)).toContain("Context hook for build")
|
||||
expect(requests[1]?.tools.map((tool) => tool.name)).not.toContain("echo")
|
||||
}
|
||||
if (mode === "manual") {
|
||||
expect((yield* session.context(sessionID)).some((message) => message.type === "assistant")).toBe(false)
|
||||
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("build") })
|
||||
const input = yield* admit(session, "New input without an assistant response")
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: input.id })
|
||||
yield* TestLLM.push(TestLLM.text("Updated reviewer summary", "text-checkpoint-summary"))
|
||||
yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(seen).toEqual([Agent.ID.make("reviewer"), Agent.ID.make("reviewer")])
|
||||
expect(requests[1]?.system).toEqual(original?.system)
|
||||
expect(requests[1]?.tools).toEqual(original?.tools)
|
||||
expect(userTexts(requests[1])[0]).toContain("<summary>\nReviewer summary\n</summary>")
|
||||
expect(userTexts(requests[1])).toContain("New input without an assistant response")
|
||||
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect(yield* session.context(sessionID)).toContainEqual(
|
||||
expect.objectContaining({ type: "compaction", status: "completed", summary: "Updated reviewer summary" }),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("fails manual compaction without executing a summarizer tool call even when it returns text", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-tool-summary-history"))
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{ reason: { normalized: "tool-calls" } },
|
||||
LLMEvent.textDelta({ id: "summary", text: "Must not become a checkpoint" }),
|
||||
LLMEvent.toolCall({ id: "call-summary", name: "echo", input: { text: "Must not execute" } }),
|
||||
),
|
||||
)
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
|
||||
expect(executions).toEqual([])
|
||||
expect(authorizations).toEqual([])
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "compaction.failed", message: "Compaction attempted to call a tool" },
|
||||
})
|
||||
expect(yield* session.context(sessionID)).toContainEqual(
|
||||
expect.objectContaining({ type: "user", text: "Earlier question" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves provider errors from manual compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -2550,7 +2689,12 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* runPrompt(session, "Recent exact request ".repeat(180))
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[0])[0]).toContain("## Objective")
|
||||
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "user"])
|
||||
expect(userTexts(requests[0])).toEqual(["Earlier question ".repeat(180), SessionCompaction.buildPrompt()])
|
||||
expect(requests[0]?.messages[1]?.content).toMatchObject([{ type: "text", text: "Earlier answer" }])
|
||||
expect(requests[0]?.model).toBe(compactModel)
|
||||
expect(requests[0]?.system).toEqual(requests[1]?.system)
|
||||
expect(requests[0]?.tools).toEqual(requests[1]?.tools)
|
||||
expect(userTexts(requests[1])).toHaveLength(1)
|
||||
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
|
||||
expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
|
||||
@@ -2560,8 +2704,10 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(context[0]).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "## Objective\n- Preserve the task",
|
||||
recent: `[User]: ${"Recent exact request ".repeat(180)}`,
|
||||
})
|
||||
|
||||
const checkpoint = requests[1]?.messages[0]
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
yield* TestLLM.push(
|
||||
@@ -2571,10 +2717,13 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* runPrompt(session, "Newest exact request ".repeat(180))
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[0])[0]).toContain(
|
||||
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
|
||||
)
|
||||
expect(requests[0]?.messages[0]).toEqual(checkpoint)
|
||||
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect(userTexts(requests[0])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
|
||||
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
|
||||
expect(userTexts(requests[0]).join("\n")).not.toContain("<previous-summary>")
|
||||
expect(userTexts(requests[0]).at(-1)).not.toContain("Preserve the task")
|
||||
expect(userTexts(requests[0]).join("\n")).not.toContain("Newest exact request")
|
||||
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "## Objective\n- Preserve the updated task",
|
||||
@@ -2641,7 +2790,12 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* runPrompt(session, "Continue")
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])[0]).toContain("## Objective")
|
||||
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
|
||||
expect(requests[1]?.messages.slice(0, -1)).toEqual(requests[0]?.messages.slice(0, -1))
|
||||
expect(requests[1]?.system).toEqual(requests[0]?.system)
|
||||
expect(requests[1]?.tools).toEqual(requests[0]?.tools)
|
||||
expect(requests[1]?.model).toBe(recoveryModel)
|
||||
expect(userTexts(requests[1])).not.toContain("Continue")
|
||||
expect(userTexts(requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
|
||||
|
||||
Reference in New Issue
Block a user