Compare commits

...
Author SHA1 Message Date
Aiden Cline 3023c2d995 fix(core): preserve conversation agent during compaction 2026-08-26 23:08:20 -05:00
8 changed files with 369 additions and 150 deletions
+89 -91
View File
@@ -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,
+4 -4
View File
@@ -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),
+18
View File
@@ -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,
+2 -2
View File
@@ -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
+19 -2
View File
@@ -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)
+23 -15
View File
@@ -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" })
+50 -26
View File
@@ -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())])
}),
)
+164 -10
View File
@@ -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" },