import { describe, expect } from "bun:test" import { LanguageModel, LLMClient, LLMEvent } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { Bus } from "@opencode-ai/core/bus" import { Config } from "@opencode-ai/core/config" import { ConfigCompactionPlugin } from "@opencode-ai/core/config/plugin/compaction" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { llmClient } from "@opencode-ai/core/effect/app-node-platform" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { Session } from "@opencode-ai/core/session" import { Agent } from "@opencode-ai/core/agent" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { ConfigCompaction } from "@opencode-ai/schema/config/compaction" import { Document, Event, Info } from "@opencode-ai/schema/config" import { Money } from "@opencode-ai/schema/money" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { DateTime, Effect, Fiber, Layer, Option, Schema, Stream } from "effect" import { testEffect } from "../lib/effect" import { host } from "../plugin/host" const model = LanguageModel.make({ id: "test-model", provider: "test-provider", route: OpenAIChat.route, }) const limit = { context: 100_000, output: 1_000 } const resolved = SessionRunnerModel.resolved(model, { capabilities: { tools: true, input: ["text"], output: ["text"] }, cost: [], limit, }) const config = Config.testLayer() const it = testEffect( Layer.merge( config, AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [ [ llmClient, Layer.mock(LLMClient.Service)({ stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })), }), ], [ SessionRunnerModel.node, Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(resolved), }), ], [Config.node, config], ]), ), ) describe("ConfigCompactionPlugin.Plugin", () => { it.live("merges settings and reloads changed config", () => Effect.gen(function* () { const compaction = yield* SessionCompaction.Service const config = yield* Config.Test const bus = yield* Bus.Service yield* config.setEntries([ new Document({ type: "document", info: new Info({ compaction: new ConfigCompaction.Info({ auto: false, buffer: 20_000 }) }), }), new Document({ type: "document", info: new Info({ compaction: new ConfigCompaction.Info({ buffer: 10_000, keep: new ConfigCompaction.Keep({ tokens: 0 }), }), }), }), ]) yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } })) expect(compaction.required(nearInput)).toBe(false) const started = yield* bus .subscribe(SessionEvent.Compaction.Started) .pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true })) expect( yield* compaction.compactManual({ session, 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) }, }, ], inputID: SessionMessage.ID.make("msg_compaction_manual"), }), ).toEqual({ status: "completed" }) expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context") yield* config.setEntries([ new Document({ type: "document", info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }), }), new Document({ type: "document", info: new Info({ compaction: new ConfigCompaction.Info({ buffer: 10_000 }) }), }), ]) yield* bus.publish(Event.Updated, {}) yield* Effect.gen(function* () { for (let attempt = 0; attempt < 200; attempt++) { if (compaction.required(nearInput)) return yield* Effect.sleep("10 millis") } yield* Effect.die(new Error("Timed out waiting for compaction config reload")) }) expect(compaction.required(bufferedInput)).toBe(false) yield* config.setEntries([ new Document({ type: "document", info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }), }), ]) yield* bus.publish(Event.Updated, {}) for (let attempt = 0; attempt < 200; attempt++) { if (compaction.required(bufferedInput)) return yield* Effect.sleep("10 millis") } yield* Effect.die(new Error("Timed out waiting for compaction config reload")) }), ) }) const session = Session.Info.make({ id: Session.ID.make("ses_compaction_config"), projectID: Project.ID.global, cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }), }) const input = (tokens: number) => ({ session, resolved, messages: [ Schema.decodeUnknownSync(SessionMessage.Assistant)({ id: SessionMessage.ID.make("msg_compaction_config"), type: "assistant", agent: Agent.defaultID, model: { id: "test-model", providerID: "test-provider" }, content: [], tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, completed: 0 }, }), ], }) const bufferedInput = input(85_000) const nearInput = input(95_000)