Files
Kilo-Org_kilocode/packages/opencode/test/kilocode/session-compaction-chunks.test.ts
Imanol Maiztegui abdef9a7ee feat(opencode): add tool metadata buffering and migrate virtua to v0.42.3
Introduce a `metadata` method on SessionProcessor.Handle that buffers
metadata emitted before tool-call registration, then applies it on the
running transition. This decouples metadata emission timing from
tool-call lifecycle.

Downgrade virtua from 0.49.1 to 0.42.3 and migrate the virtualizer API:
- Replace `findItemIndex(scrollOffset)` with `findStartIndex()`
- Replace `bufferSize` prop with `overscan` (count-based)

Additional changes:
- Change Permission.reply return type from Promise<boolean> to Promise<void>
- Make Ruleset type readonly and remove unnecessary array spreads
- Update nvidia provider headers to reference Kilo branding
- Reorder SDK event type definitions for installation events
- Reduce promise facade allowlist in check script
2026-06-17 16:41:43 +02:00

594 lines
21 KiB
TypeScript

import { afterEach, describe, expect, mock, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import * as Stream from "effect/Stream"
import { LLMEvent, type LLMEvent as Event } from "@opencode-ai/llm"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Image } from "../../src/image/image"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { provideTestInstance } from "../fixture/fixture"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Snapshot } from "../../src/snapshot"
import { KiloCompactionChunks } from "../../src/kilocode/session/compaction-chunks"
import { KiloSessionCompaction } from "../../src/kilocode/session/compaction"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { Reference } from "../../src/reference/reference"
import { SessionCompaction } from "../../src/session/compaction"
import * as SessionProcessorModule from "../../src/session/processor"
import type { SessionProcessor } from "../../src/session/processor"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Session as SessionNs } from "../../src/session/session"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { SyncEvent } from "../../src/sync"
import { ProviderTest } from "../fake/provider"
import { tmpdir } from "../fixture/fixture"
const providerID = ProviderID.make("test")
const modelID = ModelID.make("test-model")
const ref = { providerID, modelID }
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const store = {
updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.promise(() => svc.updateMessage(msg)),
updatePart: <T extends MessageV2.Part>(part: T) => Effect.promise(() => svc.updatePart(part)),
}
const svc = {
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
messages(input: Parameters<SessionNs.Interface["messages"]>[0]) {
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(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
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, text: 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,
providerID,
parentID,
time: { created: Date.now() },
finish: "stop",
}
await svc.updateMessage(msg)
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
function llm() {
const queue: Array<Stream.Stream<Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<Event, unknown>)> = []
return {
push(stream: Stream.Stream<Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<Event, unknown>)) {
queue.push(stream)
},
layer: Layer.succeed(
LLM.Service,
LLM.Service.of({
stream: (input) => {
const item = queue.shift() ?? Stream.empty
const stream = typeof item === "function" ? item(input) : item
return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event)))
},
}),
),
}
}
function reply(text: string, capture?: (input: LLM.StreamInput) => void) {
return (input: LLM.StreamInput) => {
capture?.(input)
const usage = { inputTokens: 1, outputTokens: 1, totalTokens: 2 }
return Stream.make(
LLMEvent.textStart({ id: "txt-0" }),
LLMEvent.textDelta({ id: "txt-0", text }),
LLMEvent.textEnd({ id: "txt-0" }),
LLMEvent.stepFinish({ index: 0, reason: "stop", usage }),
LLMEvent.finish({ reason: "stop", usage }),
)
}
}
function fakeRuntime(outputTokenMax?: number) {
const calls: string[] = []
const outputs: number[] = []
const bus = Bus.layer
const processor = Layer.effect(
SessionProcessorModule.SessionProcessor.Service,
Effect.gen(function* () {
const sessions = yield* SessionNs.Service
return SessionProcessorModule.SessionProcessor.Service.of({
create: Effect.fn("TestSessionProcessor.create")((input) =>
Effect.succeed({
get message() {
return input.assistantMessage
},
updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)),
metadata: Effect.fn("TestSessionProcessor.metadata")(() => Effect.void),
completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void),
process: Effect.fn("TestSessionProcessor.process")((stream: LLM.StreamInput) =>
Effect.gen(function* () {
outputs.push(input.model.limit.output)
calls.push(JSON.stringify(stream.messages))
const text = stream.messages.some((msg) =>
JSON.stringify(msg).includes("Create a new anchored summary"),
)
? "final summary"
: calls.length === 1
? "chunk one"
: "chunk two"
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
type: "text",
text,
})
input.assistantMessage.finish = "stop"
return "continue" as const
}),
),
} satisfies SessionProcessor.Handle),
),
})
}),
)
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 10_000, output: 1_000 } })
return {
calls,
outputs,
rt: ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus).pipe(
Layer.provide(ProviderTest.fake({ model }).layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(RuntimeFlags.layer({ outputTokenMax })),
Layer.provide(Reference.defaultLayer),
Layer.provide(bus),
Layer.provide(
Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }),
}),
),
),
),
}
}
function liveRuntime(layer: Layer.Layer<LLM.Service>, context = 10_000) {
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),
Layer.provide(SyncEvent.defaultLayer),
)
const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } })
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(ProviderTest.fake({ model }).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(SyncEvent.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(RuntimeFlags.layer()),
Layer.provide(Reference.defaultLayer),
Layer.provide(status),
Layer.provide(bus),
Layer.provide(
Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }),
}),
),
),
)
}
afterEach(() => {
mock.restore()
})
describe("KiloCompactionChunks", () => {
test("splits oversized history into chronological chunks", async () => {
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 7_000, output: 1_000 } })
const sessionID = SessionID.make("ses_chunks_split")
const messages: MessageV2.WithParts[] = Array.from({ length: 4 }, (_, index) => ({
info: {
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
},
parts: [
{
id: PartID.ascending(),
messageID: MessageID.ascending(),
sessionID,
type: "text",
text: `${index}: ${"x".repeat(8_000)}`,
},
],
}))
const chunks = await Effect.runPromise(KiloCompactionChunks.split({ messages, model, size: 2_000 }))
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.flatMap((chunk) => chunk.messages.map((msg) => msg.info.id))).toEqual(
messages.map((msg) => msg.info.id),
)
})
test("uses runtime output cap for fallback selection and chunk budget", () => {
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 10_000, output: 8_000 } })
const cfg = {} as Config.Info
const outputTokenMax = 512
expect(KiloCompactionChunks.needed({ cfg, model, tokens: 5_000, outputTokenMax })).toBe(false)
expect(KiloCompactionChunks.budget({ cfg, model, outputTokenMax })).toBe(5_692)
})
test("falls back to chunk workers after the first compaction overflows", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(10_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000))
const second = await user(session.id, "second " + "c".repeat(10_000))
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000))
await Effect.runPromise(
KiloSessionCompaction.create({
session: store,
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
}),
)
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
const parts = summaries
.flatMap((msg) => msg.parts)
.filter((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(calls.at(-1)).toContain("Create a new anchored summary")
expect(summaries).toHaveLength(1)
expect(parts.map((part) => part.text)).toEqual(["final summary"])
} finally {
await rt.dispose()
}
},
})
})
test("uses chunk fallback before sending oversized normal compaction", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(10_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000))
const second = await user(session.id, "second " + "c".repeat(10_000))
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000))
await Effect.runPromise(
KiloSessionCompaction.create({
session: store,
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
}),
)
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls[0]).toContain("Summarize conversation chunk")
expect(calls[0]).not.toContain("Create a new anchored summary")
} finally {
await rt.dispose()
}
},
})
})
test("uses a worker even when fallback selection produces one oversized chunk", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(20_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000))
await Effect.runPromise(
KiloSessionCompaction.create({
session: store,
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
}),
)
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
const parts = summaries
.flatMap((msg) => msg.parts)
.filter((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThan(0)
expect(calls[0]).toContain("Summarize conversation chunk")
expect(summaries).toHaveLength(1)
expect(parts.map((part) => part.text)).toEqual(["final summary"])
} finally {
await rt.dispose()
}
},
})
})
test("serializes oversized fallback chunks before summarizing", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "single huge request " + "a".repeat(80_000))
await Effect.runPromise(
KiloSessionCompaction.create({
session: store,
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
}),
)
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls[0]).toContain("compacted transcript")
expect(calls[0]).toContain("Text truncated for compaction")
expect(calls[0]).toContain("Summarize conversation chunk")
} finally {
await rt.dispose()
}
},
})
})
test("caps worker output budget below the configured runtime limit", async () => {
const { rt, calls, outputs } = fakeRuntime(512)
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(80_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(80_000))
await Effect.runPromise(
KiloSessionCompaction.create({
session: store,
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
}),
)
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThan(0)
expect(outputs.at(-1)).toBe(512)
} finally {
await rt.dispose()
}
},
})
})
test("compacts oversized replay turns after overflow compaction", async () => {
const stub = llm()
const calls: string[] = []
stub.push(reply("history summary"))
stub.push(reply("replay summary", (input) => calls.push(JSON.stringify(input.messages))))
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const old = await user(session.id, "old context")
await assistant(session.id, old.id, tmp.path, "old reply")
const large = await user(session.id, "large replay " + "x".repeat(40_000))
await Effect.runPromise(
KiloSessionCompaction.create({
session: store,
sessionID: session.id,
agent: "build",
model: ref,
auto: true,
overflow: true,
}),
)
const rt = liveRuntime(stub.layer)
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: true,
overflow: true,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const replay = all.findLast((msg) => msg.info.role === "user" && msg.info.id !== large.id)
const part = replay?.parts.find((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls).toHaveLength(1)
expect(calls[0]).toContain("Summarize conversation chunk 1 of 1")
expect(part?.text).toContain("compacted representation")
expect(part?.text).toContain("replay summary")
} finally {
await rt.dispose()
}
},
})
})
})