Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Kit Langton
746381008d test(session): clarify compaction test harness 2026-05-10 20:23:37 -04:00
Kit Langton
1e7586a93f test(session): effectify remaining compaction process tests 2026-05-10 20:12:56 -04:00

View file

@ -1,8 +1,7 @@
import { afterEach, describe, expect, mock, test } from "bun:test" import { afterEach, describe, expect, mock, test } from "bun:test"
import { APICallError } from "ai" import { APICallError } from "ai"
import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import z from "zod"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Image } from "@/image/image" import { Image } from "@/image/image"
@ -10,11 +9,10 @@ import { Agent } from "../../src/agent/agent"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session/llm"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session/compaction"
import { Token } from "@/util/token" import { Token } from "@/util/token"
import { WithInstance } from "../../src/project/with-instance"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { provideTmpdirInstance, TestInstance, tmpdir } from "../fixture/fixture" import { provideTmpdirInstance, TestInstance } from "../fixture/fixture"
import { Session as SessionNs } from "@/session/session" import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
@ -32,26 +30,6 @@ import { TestConfig } from "../fixture/config"
void Log.init({ print: false }) void Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
messages(input: z.output<typeof SessionNs.MessagesInput.zod>) {
return run(SessionNs.Service.use((svc) => svc.messages(input)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
const summary = Layer.succeed( const summary = Layer.succeed(
SessionSummary.Service, SessionSummary.Service,
SessionSummary.Service.of({ SessionSummary.Service.of({
@ -102,50 +80,6 @@ function createModel(opts: {
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
async function user(sessionID: SessionID, text: string) {
const msg = await svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
})
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
async function assistant(sessionID: SessionID, parentID: MessageID, root: string) {
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "build",
agent: "build",
path: { cwd: root, root },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID,
time: { created: Date.now() },
finish: "end_turn",
}
await svc.updateMessage(msg)
return msg
}
function createUserMessage(sessionID: SessionID, text: string) { function createUserMessage(sessionID: SessionID, text: string) {
return Effect.gen(function* () { return Effect.gen(function* () {
const ssn = yield* SessionNs.Service const ssn = yield* SessionNs.Service
@ -193,8 +127,10 @@ function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root:
) )
} }
async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { function createSummaryAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string, text: string) {
const msg: MessageV2.Assistant = { return SessionNs.Service.use((ssn) =>
Effect.gen(function* () {
const msg = yield* ssn.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "assistant", role: "assistant",
sessionID, sessionID,
@ -214,9 +150,8 @@ async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root:
summary: true, summary: true,
time: { created: Date.now() }, time: { created: Date.now() },
finish: "end_turn", finish: "end_turn",
} })
await svc.updateMessage(msg) yield* ssn.updatePart({
await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: msg.id, messageID: msg.id,
sessionID, sessionID,
@ -224,6 +159,8 @@ async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root:
text, text,
}) })
return msg return msg
}),
)
} }
function createCompactionMarker(sessionID: SessionID) { function createCompactionMarker(sessionID: SessionID) {
@ -248,10 +185,6 @@ function createCompactionMarker(sessionID: SessionID) {
) )
} }
async function createCompactionMarkerAsync(sessionID: SessionID) {
return run(createCompactionMarker(sessionID))
}
function fake( function fake(
input: Parameters<SessionProcessorModule.SessionProcessor.Interface["create"]>[0], input: Parameters<SessionProcessorModule.SessionProcessor.Interface["create"]>[0],
result: "continue" | "compact", result: "continue" | "compact",
@ -283,26 +216,6 @@ function cfg(compaction?: Config.Info["compaction"]) {
}) })
} }
function runtime(
result: "continue" | "compact",
plugin = Plugin.defaultLayer,
provider = ProviderTest.fake(),
config = Config.defaultLayer,
) {
const bus = Bus.layer
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
Layer.provide(provider.layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(layer(result)),
Layer.provide(Agent.defaultLayer),
Layer.provide(plugin),
Layer.provide(bus),
Layer.provide(config),
),
)
}
const deps = Layer.mergeAll( const deps = Layer.mergeAll(
wide().layer, wide().layer,
layer("continue"), layer("continue"),
@ -320,16 +233,22 @@ const env = Layer.mergeAll(
const it = testEffect(env) const it = testEffect(env)
const processEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer)
const itProcess = testEffect(processEnv) const itCompaction = testEffect(compactionEnv)
function compactionProcessLayer(options?: { type CompactionProcessOptions = {
result?: "continue" | "compact" result?: "continue" | "compact"
llm?: Layer.Layer<LLM.Service> llm?: Layer.Layer<LLM.Service>
plugin?: Layer.Layer<Plugin.Service> plugin?: Layer.Layer<Plugin.Service>
provider?: ReturnType<typeof ProviderTest.fake> provider?: ReturnType<typeof ProviderTest.fake>
config?: Layer.Layer<Config.Service> config?: Layer.Layer<Config.Service>
}) { }
function withCompaction(options?: CompactionProcessOptions) {
return Effect.provide(compactionProcessLayer(options))
}
function compactionProcessLayer(options?: CompactionProcessOptions) {
const bus = Bus.layer const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus)) const status = SessionStatus.layer.pipe(Layer.provide(bus))
const processor = options?.llm const processor = options?.llm
@ -365,10 +284,6 @@ function readCompactionPart(sessionID: SessionID) {
) )
} }
async function lastCompactionPart(sessionID: SessionID) {
return run(readCompactionPart(sessionID))
}
function llm() { function llm() {
const queue: Array< const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>) Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
@ -391,29 +306,6 @@ function llm() {
} }
} }
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake(), config = Config.defaultLayer) {
const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus))
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(
Layer.provide(summary),
Layer.provide(Image.defaultLayer),
)
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(provider.layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(status),
Layer.provide(bus),
Layer.provide(config),
),
)
}
function reply( function reply(
text: string, text: string,
capture?: (input: LLM.StreamInput) => void, capture?: (input: LLM.StreamInput) => void,
@ -469,23 +361,14 @@ function reply(
} }
} }
function wait(ms = 50) { function plugin(ready: Deferred.Deferred<void>) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function defer() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
function plugin(ready: ReturnType<typeof defer>) {
return Layer.mock(Plugin.Service)({ return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => { trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.session.compacting") return Effect.succeed(output) if (name !== "experimental.session.compacting") return Effect.succeed(output)
return Effect.sync(() => ready.resolve()).pipe(Effect.andThen(Effect.never), Effect.as(output)) return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe(
Effect.andThen(Effect.never),
Effect.as(output),
)
}, },
list: () => Effect.succeed([]), list: () => Effect.succeed([]),
init: () => Effect.void, init: () => Effect.void,
@ -980,7 +863,7 @@ describe("session.compaction.process", () => {
}), }),
) )
itProcess.instance( itCompaction.instance(
"marks summary message as errored on compact result", "marks summary message as errored on compact result",
Effect.gen(function* () { Effect.gen(function* () {
const ssn = yield* SessionNs.Service const ssn = yield* SessionNs.Service
@ -1005,7 +888,7 @@ describe("session.compaction.process", () => {
expect(summary.info.finish).toBe("error") expect(summary.info.finish).toBe("error")
expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact")
} }
}).pipe(Effect.provide(compactionProcessLayer({ result: "compact" }))), }).pipe(withCompaction({ result: "compact" })),
) )
it.instance( it.instance(
@ -1039,7 +922,7 @@ describe("session.compaction.process", () => {
}), }),
) )
itProcess.instance( itCompaction.instance(
"persists tail_start_id for retained recent turns", "persists tail_start_id for retained recent turns",
Effect.gen(function* () { Effect.gen(function* () {
const ssn = yield* SessionNs.Service const ssn = yield* SessionNs.Service
@ -1062,10 +945,10 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id) const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction") expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id) expect(part?.tail_start_id).toBe(keep.id)
}).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))), }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
) )
itProcess.instance( itCompaction.instance(
"shrinks retained tail to fit preserve token budget", "shrinks retained tail to fit preserve token budget",
Effect.gen(function* () { Effect.gen(function* () {
const ssn = yield* SessionNs.Service const ssn = yield* SessionNs.Service
@ -1088,10 +971,10 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id) const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction") expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id) expect(part?.tail_start_id).toBe(keep.id)
}).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) }))), }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
) )
itProcess.instance( itCompaction.instance(
"falls back to full summary when even one recent turn exceeds preserve token budget", "falls back to full summary when even one recent turn exceeds preserve token budget",
() => { () => {
const stub = llm() const stub = llm()
@ -1113,16 +996,12 @@ describe("session.compaction.process", () => {
expect(part?.type).toBe("compaction") expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined() expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("yyyy") expect(captured).toContain("yyyy")
}).pipe( }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) }))
Effect.provide(
compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) }),
),
)
}, },
{ git: true }, { git: true },
) )
itProcess.instance( itCompaction.instance(
"falls back to full summary when retained tail media exceeds preserve token budget", "falls back to full summary when retained tail media exceeds preserve token budget",
() => { () => {
const stub = llm() const stub = llm()
@ -1154,16 +1033,12 @@ describe("session.compaction.process", () => {
expect(part?.tail_start_id).toBeUndefined() expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("recent image turn") expect(captured).toContain("recent image turn")
expect(captured).toContain("Attached image/png: big.png") expect(captured).toContain("Attached image/png: big.png")
}).pipe( }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
Effect.provide(
compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }),
),
)
}, },
{ git: true }, { git: true },
) )
itProcess.instance( itCompaction.instance(
"retains a split turn suffix when a later message fits the preserve token budget", "retains a split turn suffix when a later message fits the preserve token budget",
() => { () => {
const stub = llm() const stub = llm()
@ -1209,16 +1084,12 @@ describe("session.compaction.process", () => {
expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role).toBe("assistant")
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
}).pipe( }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
Effect.provide(
compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }),
),
)
}, },
{ git: true }, { git: true },
) )
itProcess.instance( itCompaction.instance(
"allows plugins to disable synthetic continue prompt", "allows plugins to disable synthetic continue prompt",
Effect.gen(function* () { Effect.gen(function* () {
const ssn = yield* SessionNs.Service const ssn = yield* SessionNs.Service
@ -1247,7 +1118,7 @@ describe("session.compaction.process", () => {
), ),
), ),
).toBe(false) ).toBe(false)
}).pipe(Effect.provide(compactionProcessLayer({ plugin: autocontinue(false) }))), }).pipe(withCompaction({ plugin: autocontinue(false) })),
) )
it.instance( it.instance(
@ -1315,9 +1186,10 @@ describe("session.compaction.process", () => {
}), }),
) )
test("stops quickly when aborted during retry backoff", async () => { itCompaction.instance(
"stops quickly when aborted during retry backoff",
() => {
const stub = llm() const stub = llm()
const ready = defer()
stub.push( stub.push(
Stream.fromAsyncIterable( Stream.fromAsyncIterable(
{ {
@ -1338,133 +1210,77 @@ describe("session.compaction.process", () => {
), ),
) )
await using tmp = await tmpdir({ git: true }) return Effect.gen(function* () {
await WithInstance.provide({ const ssn = yield* SessionNs.Service
directory: tmp.path, const bus = yield* Bus.Service
fn: async () => { const ready = yield* Deferred.make<void>()
const session = await svc.create({}) const session = yield* ssn.create({})
const msg = await user(session.id, "hello") const msg = yield* createUserMessage(session.id, "hello")
const msgs = await svc.messages({ sessionID: session.id }) const msgs = yield* ssn.messages({ sessionID: session.id })
const abort = new AbortController() const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
const rt = liveRuntime(stub.layer, wide())
let off: (() => void) | undefined
let run: Promise<"continue" | "stop"> | undefined
try {
off = await rt.runPromise(
Bus.Service.use((svc) =>
svc.subscribeCallback(SessionStatus.Event.Status, (evt) => {
if (evt.properties.sessionID !== session.id) return if (evt.properties.sessionID !== session.id) return
if (evt.properties.status.type !== "retry") return if (evt.properties.status.type !== "retry") return
ready.resolve() Deferred.doneUnsafe(ready, Effect.void)
}), })
), yield* Effect.addFinalizer(() => Effect.sync(off))
)
run = rt const fiber = yield* SessionCompaction.use
.runPromiseExit( .process({
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: msg.id, parentID: msg.id,
messages: msgs, messages: msgs,
sessionID: session.id, sessionID: session.id,
auto: false, auto: false,
}),
),
{ signal: abort.signal },
)
.then((exit) => {
if (Exit.isFailure(exit)) {
if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop"
throw Cause.squash(exit.cause)
}
return exit.value
}) })
.pipe(Effect.forkChild)
await Promise.race([ yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
ready.promise,
wait(1000).then(() => {
throw new Error("timed out waiting for retry status")
}),
])
const start = Date.now() const start = Date.now()
abort.abort() yield* Fiber.interrupt(fiber)
const result = await Promise.race([ const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
run.then((value) => ({ kind: "done" as const, value, ms: Date.now() - start })),
wait(250).then(() => ({ kind: "timeout" as const })),
])
expect(result.kind).toBe("done") expect(Exit.isFailure(exit)).toBe(true)
if (result.kind === "done") { if (Exit.isFailure(exit)) {
expect(result.value).toBe("stop") expect(Cause.hasInterrupts(exit.cause)).toBe(true)
expect(result.ms).toBeLessThan(250) expect(Date.now() - start).toBeLessThan(250)
}
} finally {
off?.()
abort.abort()
await rt.dispose()
await run?.catch(() => undefined)
} }
}).pipe(withCompaction({ llm: stub.layer }))
}, },
}) { git: true },
}) )
test("does not leave a summary assistant when aborted before processor setup", async () => { itCompaction.instance(
const ready = defer() "does not leave a summary assistant when aborted before processor setup",
() =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await WithInstance.provide({ const ready = yield* Deferred.make<void>()
directory: tmp.path, return yield* Effect.gen(function* () {
fn: async () => { const ssn = yield* SessionNs.Service
const session = await svc.create({}) const session = yield* ssn.create({})
const msg = await user(session.id, "hello") const msg = yield* createUserMessage(session.id, "hello")
const msgs = await svc.messages({ sessionID: session.id }) const msgs = yield* ssn.messages({ sessionID: session.id })
const abort = new AbortController() const fiber = yield* SessionCompaction.use
const rt = runtime("continue", plugin(ready), wide()) .process({
let run: Promise<"continue" | "stop"> | undefined
try {
run = rt
.runPromiseExit(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: msg.id, parentID: msg.id,
messages: msgs, messages: msgs,
sessionID: session.id, sessionID: session.id,
auto: false, auto: false,
}),
),
{ signal: abort.signal },
)
.then((exit) => {
if (Exit.isFailure(exit)) {
if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop"
throw Cause.squash(exit.cause)
}
return exit.value
}) })
.pipe(Effect.forkChild)
await Promise.race([ yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
ready.promise, yield* Fiber.interrupt(fiber)
wait(1000).then(() => { const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
throw new Error("timed out waiting for compaction hook") const all = yield* ssn.messages({ sessionID: session.id })
}),
])
abort.abort() expect(Exit.isFailure(exit)).toBe(true)
expect(await run).toBe("stop") if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true)
const all = await svc.messages({ sessionID: session.id })
expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false)
} finally { }).pipe(withCompaction({ plugin: plugin(ready) }))
abort.abort() }),
await rt.dispose() { git: true },
await run?.catch(() => undefined) )
}
},
})
})
itProcess.instance( itCompaction.instance(
"does not allow tool calls while generating the summary", "does not allow tool calls while generating the summary",
() => { () => {
const stub = llm() const stub = llm()
@ -1528,12 +1344,14 @@ describe("session.compaction.process", () => {
expect(summary?.info.role).toBe("assistant") expect(summary?.info.role).toBe("assistant")
expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) expect(summary?.parts.some((part) => part.type === "tool")).toBe(false)
}).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) }).pipe(withCompaction({ llm: stub.layer }))
}, },
{ git: true }, { git: true },
) )
test("summarizes only the head while keeping recent tail out of summary input", async () => { itCompaction.instance(
"summarizes only the head while keeping recent tail out of summary input",
() => {
const stub = llm() const stub = llm()
let captured = "" let captured = ""
stub.push( stub.push(
@ -1541,45 +1359,36 @@ describe("session.compaction.process", () => {
captured = JSON.stringify(input.messages) captured = JSON.stringify(input.messages)
}), }),
) )
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "older context")
yield* createUserMessage(session.id, "keep this turn")
yield* createUserMessage(session.id, "and this one too")
yield* createCompactionMarker(session.id)
await using tmp = await tmpdir({ git: true }) const msgs = yield* ssn.messages({ sessionID: session.id })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "older context")
await user(session.id, "keep this turn")
await user(session.id, "and this one too")
await createCompactionMarkerAsync(session.id)
const rt = liveRuntime(stub.layer, wide())
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy() expect(parent).toBeTruthy()
await rt.runPromise( yield* SessionCompaction.use.process({
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!, parentID: parent!,
messages: msgs, messages: msgs,
sessionID: session.id, sessionID: session.id,
auto: false, auto: false,
}), })
),
)
expect(captured).toContain("older context") expect(captured).toContain("older context")
expect(captured).not.toContain("keep this turn") expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too") expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?") expect(captured).not.toContain("What did we do so far?")
} finally { }).pipe(withCompaction({ llm: stub.layer }))
await rt.dispose()
}
}, },
}) { git: true },
}) )
test("anchors repeated compactions with the previous summary", async () => { itCompaction.instance(
"anchors repeated compactions with the previous summary",
() => {
const stub = llm() const stub = llm()
let captured = "" let captured = ""
stub.push(reply("summary one")) stub.push(reply("summary one"))
@ -1589,106 +1398,61 @@ describe("session.compaction.process", () => {
}), }),
) )
await using tmp = await tmpdir({ git: true }) return Effect.gen(function* () {
await WithInstance.provide({ const ssn = yield* SessionNs.Service
directory: tmp.path, const session = yield* ssn.create({})
fn: async () => { yield* createUserMessage(session.id, "older context")
const session = await svc.create({}) yield* createUserMessage(session.id, "keep this turn")
await user(session.id, "older context") yield* createCompactionMarker(session.id)
await user(session.id, "keep this turn")
await createCompactionMarkerAsync(session.id)
const rt = liveRuntime(stub.layer, wide()) let msgs = yield* ssn.messages({ sessionID: session.id })
try {
let msgs = await svc.messages({ sessionID: session.id })
let parent = msgs.at(-1)?.info.id let parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy() expect(parent).toBeTruthy()
await rt.runPromise( yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
await user(session.id, "latest turn") yield* createUserMessage(session.id, "latest turn")
await createCompactionMarkerAsync(session.id) yield* createCompactionMarker(session.id)
msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) msgs = MessageV2.filterCompacted(MessageV2.stream(session.id))
parent = msgs.at(-1)?.info.id parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy() expect(parent).toBeTruthy()
await rt.runPromise( yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(captured).toContain("<previous-summary>") expect(captured).toContain("<previous-summary>")
expect(captured).toContain("summary one") expect(captured).toContain("summary one")
expect(captured.match(/summary one/g)?.length).toBe(1) expect(captured.match(/summary one/g)?.length).toBe(1)
expect(captured).toContain("## Constraints & Preferences") expect(captured).toContain("## Constraints & Preferences")
expect(captured).toContain("## Progress") expect(captured).toContain("## Progress")
} finally { }).pipe(withCompaction({ llm: stub.layer }))
await rt.dispose()
}
}, },
}) { git: true },
}) )
test("keeps recent pre-compaction turns across repeated compactions", async () => { itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
const stub = llm() const stub = llm()
stub.push(reply("summary one")) stub.push(reply("summary one"))
stub.push(reply("summary two")) stub.push(reply("summary two"))
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const u1 = await user(session.id, "one")
const u2 = await user(session.id, "two")
const u3 = await user(session.id, "three")
await createCompactionMarkerAsync(session.id)
const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 })) return Effect.gen(function* () {
try { const ssn = yield* SessionNs.Service
let msgs = await svc.messages({ sessionID: session.id }) const session = yield* ssn.create({})
const u1 = yield* createUserMessage(session.id, "one")
const u2 = yield* createUserMessage(session.id, "two")
const u3 = yield* createUserMessage(session.id, "three")
yield* createCompactionMarker(session.id)
let msgs = yield* ssn.messages({ sessionID: session.id })
let parent = msgs.at(-1)?.info.id let parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy() expect(parent).toBeTruthy()
await rt.runPromise( yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const u4 = await user(session.id, "four") const u4 = yield* createUserMessage(session.id, "four")
await createCompactionMarkerAsync(session.id) yield* createCompactionMarker(session.id)
msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) msgs = MessageV2.filterCompacted(MessageV2.stream(session.id))
parent = msgs.at(-1)?.info.id parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy() expect(parent).toBeTruthy()
await rt.runPromise( yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
const ids = filtered.map((msg) => msg.info.id) const ids = filtered.map((msg) => msg.info.id)
@ -1701,23 +1465,19 @@ describe("session.compaction.process", () => {
expect( expect(
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
).toBe(true) ).toBe(true)
} finally { }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))
await rt.dispose()
}
},
})
}) })
test("ignores previous summaries when sizing the retained tail", async () => { itCompaction.instance(
await using tmp = await tmpdir() "ignores previous summaries when sizing the retained tail",
await WithInstance.provide({ Effect.gen(function* () {
directory: tmp.path, const ssn = yield* SessionNs.Service
fn: async () => { const test = yield* TestInstance
const session = await svc.create({}) const session = yield* ssn.create({})
await user(session.id, "older") yield* createUserMessage(session.id, "older")
const keep = await user(session.id, "keep this turn") const keep = yield* createUserMessage(session.id, "keep this turn")
const keepReply = await assistant(session.id, keep.id, tmp.path) const keepReply = yield* createAssistantMessage(session.id, keep.id, test.directory)
await svc.updatePart({ yield* ssn.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: keepReply.id, messageID: keepReply.id,
sessionID: session.id, sessionID: session.id,
@ -1725,14 +1485,14 @@ describe("session.compaction.process", () => {
text: "keep reply", text: "keep reply",
}) })
await createCompactionMarkerAsync(session.id) yield* createCompactionMarker(session.id)
const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id const firstCompaction = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id
expect(firstCompaction).toBeTruthy() expect(firstCompaction).toBeTruthy()
await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) yield* createSummaryAssistantMessage(session.id, firstCompaction!, test.directory, "summary ".repeat(800))
const recent = await user(session.id, "recent turn") const recent = yield* createUserMessage(session.id, "recent turn")
const recentReply = await assistant(session.id, recent.id, tmp.path) const recentReply = yield* createAssistantMessage(session.id, recent.id, test.directory)
await svc.updatePart({ yield* ssn.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: recentReply.id, messageID: recentReply.id,
sessionID: session.id, sessionID: session.id,
@ -1740,33 +1500,17 @@ describe("session.compaction.process", () => {
text: "recent reply", text: "recent reply",
}) })
await createCompactionMarkerAsync(session.id) yield* createCompactionMarker(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 }))
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy() expect(parent).toBeTruthy()
await rt.runPromise( yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const part = await lastCompactionPart(session.id) const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction") expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id) expect(part?.tail_start_id).toBe(keep.id)
} finally { }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
await rt.dispose() )
}
},
})
})
}) })
describe("util.token.estimate", () => { describe("util.token.estimate", () => {