import { afterEach, describe, expect, mock, test } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { EventV2Bridge } from "@/event-v2-bridge" import { APICallError } from "ai" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" import * as Stream from "effect/Stream" import { Config } from "@/config/config" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" import { Plugin } from "../../src/plugin" import { provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { LLMEvent, Usage } from "@opencode-ai/llm" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" const summary = Layer.succeed( SessionSummary.Service, SessionSummary.Service.of({ summarize: () => Effect.void, diff: () => Effect.succeed([]), computeDiff: () => Effect.succeed([]), }), ) const ref = { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test-model"), } const usage = (input: ConstructorParameters[0]) => new Usage(input) const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 }) afterEach(() => { mock.restore() }) function createModel(opts: { context: number output: number input?: number cost?: Provider.Model["cost"] npm?: string }): Provider.Model { return { id: "test-model", providerID: "test", name: "Test", limit: { context: opts.context, input: opts.input, output: opts.output, }, cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, capabilities: { toolcall: true, attachment: false, reasoning: false, temperature: true, input: { text: true, image: false, audio: false, video: false }, output: { text: true, image: false, audio: false, video: false }, }, api: { npm: opts.npm ?? "@ai-sdk/anthropic" }, options: {}, } as Provider.Model } const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) function createUserMessage(sessionID: SessionID, text: string) { return Effect.gen(function* () { const ssn = yield* SessionNs.Service const msg = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID, agent: "build", model: ref, time: { created: Date.now() }, }) yield* ssn.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID, type: "text", text, }) return msg }) } function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string) { return SessionNs.Service.use((ssn) => ssn.updateMessage({ 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", }), ) } function createSummaryAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string, text: string) { return SessionNs.Service.use((ssn) => Effect.gen(function* () { const msg = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "assistant", sessionID, mode: "compaction", agent: "compaction", 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, summary: true, time: { created: Date.now() }, finish: "end_turn", }) yield* ssn.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID, type: "text", text, }) return msg }), ) } function createCompactionMarker(sessionID: SessionID) { return SessionNs.Service.use((ssn) => Effect.gen(function* () { const msg = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", model: ref, sessionID, agent: "build", time: { created: Date.now() }, }) yield* ssn.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID: msg.sessionID, type: "compaction", auto: false, }) }), ) } function fake( input: Parameters[0], result: "continue" | "compact", ) { const msg = input.assistantMessage return { get message() { return msg }, updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)), completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void), process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)), } satisfies SessionProcessorModule.SessionProcessor.Handle } function processorLayer(result: "continue" | "compact") { return Layer.succeed( SessionProcessorModule.SessionProcessor.Service, SessionProcessorModule.SessionProcessor.Service.of({ create: Effect.fn("TestSessionProcessor.create")((input) => Effect.succeed(fake(input, result))), }), ) } function cfg(compaction?: ConfigV1.Info["compaction"]) { const base = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info return Layer.succeed(Config.Service, TestConfig.make({ get: () => Effect.succeed({ ...base, compaction }) })) } const defaultProvider = wide() const compactionTestNode = LayerNode.group([ SessionCompaction.node, SessionNs.node, SessionProjector.node, Database.node, EventV2Bridge.node, CrossSpawnSpawner.node, ]) const env = AppNodeBuilder.build(compactionTestNode, [ [Provider.node, defaultProvider.layer], [SessionProcessorModule.SessionProcessor.node, processorLayer("continue")], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], ]) const it = testEffect(env) const compactionEnv = AppNodeBuilder.build( LayerNode.group([SessionNs.node, SessionProjector.node, Database.node, EventV2Bridge.node, CrossSpawnSpawner.node]), ) const itCompaction = testEffect(compactionEnv) type CompactionProcessOptions = { result?: "continue" | "compact" llm?: Layer.Layer plugin?: Layer.Layer provider?: ReturnType config?: Layer.Layer } function withCompaction(options?: CompactionProcessOptions) { return Effect.provide(compactionProcessLayer(options)) } function compactionProcessLayer(options?: CompactionProcessOptions) { const replacements: LayerNode.Replacements = [ [Provider.node, (options?.provider ?? wide()).layer], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], [SessionSummary.node, summary], ] if (!options?.llm) { return AppNodeBuilder.build(compactionTestNode, [ ...replacements, [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")], ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), ...(options?.config ? ([[Config.node, options.config]] as const) : []), ]) } return AppNodeBuilder.build(compactionTestNode, [ ...replacements, [LLM.node, options.llm], ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), ...(options?.config ? ([[Config.node, options.config]] as const) : []), ]) } function createSummaryCompaction(sessionID: SessionID) { return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false }) } function readCompactionPart(sessionID: SessionID) { return SessionNs.use .messages({ sessionID }) .pipe( Effect.map((messages) => messages.at(-2)?.parts.find((item): item is SessionV1.CompactionPart => item.type === "compaction"), ), ) } function llm() { const queue: Array< Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) > = [] return { push(stream: Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream)) { queue.push(stream) }, llmLayer: 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, ): (input: LLM.StreamInput) => Stream.Stream { return (input) => { capture?.(input) 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: basicUsage(), }), LLMEvent.finish({ reason: "stop", usage: basicUsage(), }), ) } } function plugin(ready: Deferred.Deferred) { return Layer.mock(Plugin.Service)({ trigger: (name: Name, _input: Input, output: Output) => { if (name !== "experimental.session.compacting") return Effect.succeed(output) return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe( Effect.andThen(Effect.never), Effect.as(output), ) }, list: () => Effect.succeed([]), init: () => Effect.void, }) } function autocontinue(enabled: boolean) { return Layer.mock(Plugin.Service)({ trigger: (name: Name, _input: Input, output: Output) => { if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output) return Effect.sync(() => { ;(output as { enabled: boolean }).enabled = enabled return output }) }, list: () => Effect.succeed([]), init: () => Effect.void, }) } describe("session.compaction.isOverflow", () => { it.live( "returns true when token count exceeds usable context", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 100_000, output: 32_000 }) const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } } expect(yield* compact.isOverflow({ tokens, model })).toBe(true) }), ), ) it.live( "returns false when token count within usable context", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 200_000, output: 32_000 }) const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } } expect(yield* compact.isOverflow({ tokens, model })).toBe(false) }), ), ) it.live( "includes cache.read in token count", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 100_000, output: 32_000 }) const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } } expect(yield* compact.isOverflow({ tokens, model })).toBe(true) }), ), ) it.live( "respects input limit for input caps", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } } expect(yield* compact.isOverflow({ tokens, model })).toBe(true) }), ), ) it.live( "returns false when input/output are within input caps", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } } expect(yield* compact.isOverflow({ tokens, model })).toBe(false) }), ), ) it.live( "returns false when output within limit with input caps", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 200_000, input: 120_000, output: 10_000 }) const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } } expect(yield* compact.isOverflow({ tokens, model })).toBe(false) }), ), ) // ─── Bug reproduction tests ─────────────────────────────────────────── // These tests demonstrate that when limit.input is set, isOverflow() // does not subtract any headroom for the next model response. This means // compaction only triggers AFTER we've already consumed the full input // budget, leaving zero room for the next API call's output tokens. // // Compare: without limit.input, usable = context - output (reserves space). // With limit.input, usable = limit.input (reserves nothing). // // Related issues: #10634, #8089, #11086, #12621 // Open PRs: #6875, #12924 it.live( "BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service // Simulate Claude with prompt caching: input limit = 200K, output limit = 32K const model = createModel({ context: 200_000, input: 200_000, output: 32_000 }) // We've used 198K tokens total. Only 2K under the input limit. // On the next turn, the full conversation (198K) becomes input, // plus the model needs room to generate output — this WILL overflow. const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } } // count = 180K + 3K + 15K = 198K // usable = limit.input = 200K (no output subtracted!) // 198K > 200K = false → no compaction triggered // WITHOUT limit.input: usable = 200K - 32K = 168K, and 198K > 168K = true ✓ // WITH limit.input: usable = 200K, and 198K > 200K = false ✗ // With 198K used and only 2K headroom, the next turn will overflow. // Compaction MUST trigger here. expect(yield* compact.isOverflow({ tokens, model })).toBe(true) }), ), ) it.live( "BUG: without limit.input, same token count correctly triggers compaction", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service // Same model but without limit.input — uses context - output instead const model = createModel({ context: 200_000, output: 32_000 }) // Same token usage as above const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } } // count = 198K // usable = context - output = 200K - 32K = 168K // 198K > 168K = true → compaction correctly triggered const result = yield* compact.isOverflow({ tokens, model }) expect(result).toBe(true) // ← Correct: headroom is reserved }), ), ) it.live( "BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service // Two models with identical context/output limits, differing only in limit.input const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 }) const withoutInputLimit = createModel({ context: 200_000, output: 32_000 }) // 170K total tokens — well above context-output (168K) but below input limit (200K) const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } } const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit }) const withoutLimit = yield* compact.isOverflow({ tokens, model: withoutInputLimit }) // Both models have identical real capacity — they should agree: expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output) expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K) }), ), ) it.live( "returns false when model context limit is 0", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 0, output: 32_000 }) const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } } expect(yield* compact.isOverflow({ tokens, model })).toBe(false) }), ), ) it.live( "returns false when compaction.auto is disabled", provideTmpdirInstance( () => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 100_000, output: 32_000 }) const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } } expect(yield* compact.isOverflow({ tokens, model })).toBe(false) }), { config: { compaction: { auto: false }, }, }, ), ) }) describe("session.compaction.create", () => { it.live( "creates a compaction user message and part", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const ssn = yield* SessionNs.Service const info = yield* ssn.create({}) yield* compact.create({ sessionID: info.id, agent: "build", model: ref, auto: true, overflow: true, }) const msgs = yield* ssn.messages({ sessionID: info.id }) expect(msgs).toHaveLength(1) expect(msgs[0].info.role).toBe("user") expect(msgs[0].parts).toHaveLength(1) expect(msgs[0].parts[0]).toMatchObject({ type: "compaction", auto: true, overflow: true, }) }), ), ) it.live.skip( "projects a compaction message to v2 (v2 projector disabled)", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const ssn = yield* SessionNs.Service const info = yield* ssn.create({}) yield* compact.create({ sessionID: info.id, agent: "build", model: ref, auto: true, overflow: true, }) const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( Effect.provide(AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])), ) expect(v2.at(-1)).toMatchObject({ type: "compaction", reason: "auto", summary: "", }) }), ), ) }) describe("session.compaction.prune", () => { it.live( "compacts old completed tool output", provideTmpdirInstance( (dir) => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const ssn = yield* SessionNs.Service const info = yield* ssn.create({}) const a = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, agent: "build", model: ref, time: { created: Date.now() }, }) yield* ssn.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: info.id, type: "text", text: "first", }) const b: SessionV1.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID: info.id, mode: "build", agent: "build", path: { cwd: dir, root: dir }, cost: 0, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 }, }, modelID: ref.modelID, providerID: ref.providerID, parentID: a.id, time: { created: Date.now() }, finish: "end_turn", } yield* ssn.updateMessage(b) yield* ssn.updatePart({ id: PartID.ascending(), messageID: b.id, sessionID: info.id, type: "tool", callID: crypto.randomUUID(), tool: "bash", state: { status: "completed", input: {}, output: "x".repeat(200_000), title: "done", metadata: {}, time: { start: Date.now(), end: Date.now() }, }, }) for (const text of ["second", "third"]) { const msg = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, agent: "build", model: ref, time: { created: Date.now() }, }) yield* ssn.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID: info.id, type: "text", text, }) } yield* compact.prune({ sessionID: info.id }) const msgs = yield* ssn.messages({ sessionID: info.id }) const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") expect(part?.type).toBe("tool") expect(part?.state.status).toBe("completed") if (part?.type === "tool" && part.state.status === "completed") { expect(part.state.time.compacted).toBeNumber() } }), { config: { compaction: { prune: true }, }, }, ), ) it.live( "skips protected skill tool output", provideTmpdirInstance((dir) => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const ssn = yield* SessionNs.Service const info = yield* ssn.create({}) const a = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, agent: "build", model: ref, time: { created: Date.now() }, }) yield* ssn.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: info.id, type: "text", text: "first", }) const b: SessionV1.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID: info.id, mode: "build", agent: "build", path: { cwd: dir, root: dir }, cost: 0, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 }, }, modelID: ref.modelID, providerID: ref.providerID, parentID: a.id, time: { created: Date.now() }, finish: "end_turn", } yield* ssn.updateMessage(b) yield* ssn.updatePart({ id: PartID.ascending(), messageID: b.id, sessionID: info.id, type: "tool", callID: crypto.randomUUID(), tool: "skill", state: { status: "completed", input: {}, output: "x".repeat(200_000), title: "done", metadata: {}, time: { start: Date.now(), end: Date.now() }, }, }) for (const text of ["second", "third"]) { const msg = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, agent: "build", model: ref, time: { created: Date.now() }, }) yield* ssn.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID: info.id, type: "text", text, }) } yield* compact.prune({ sessionID: info.id }) const msgs = yield* ssn.messages({ sessionID: info.id }) const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") expect(part?.type).toBe("tool") if (part?.type === "tool" && part.state.status === "completed") { expect(part.state.time.compacted).toBeUndefined() } }), ), ) }) describe("session.compaction.process", () => { it.instance( "throws when parent is not a user message", Effect.gen(function* () { const test = yield* TestInstance const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const reply = yield* createAssistantMessage(session.id, msg.id, test.directory) const msgs = yield* ssn.messages({ sessionID: session.id }) const exit = yield* Effect.exit( SessionCompaction.use.process({ parentID: reply.id, messages: msgs, sessionID: session.id, auto: false, }), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) expect(error).toBeInstanceOf(Error) if (error instanceof Error) { expect(error.message).toContain(`Compaction parent must be a user message: ${reply.id}`) } } }), ) it.instance( "publishes compacted event on continue", Effect.gen(function* () { const events = yield* EventV2Bridge.Service const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const done = yield* Deferred.make() const seen: string[] = [] const unsub = yield* events.listen((evt) => { seen.push(evt.type) if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) return Effect.void Deferred.doneUnsafe(done, Effect.void) return Effect.void }) yield* Effect.addFinalizer(() => unsub) const result = yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false, }) yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) expect(result).toBe("continue") expect(seen).toContain(SessionCompaction.Event.Compacted.type) expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) }), ) itCompaction.instance( "marks summary message as errored on compact result", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const result = yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false, }) const summary = (yield* ssn.messages({ sessionID: session.id })).find( (msg) => msg.info.role === "assistant" && msg.info.summary, ) expect(result).toBe("stop") expect(summary?.info.role).toBe("assistant") if (summary?.info.role === "assistant") { expect(summary.info.finish).toBe("error") expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") } }).pipe(withCompaction({ result: "compact" })), ) it.instance( "adds synthetic continue prompt when auto is enabled", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const result = yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: true, }) const all = yield* ssn.messages({ sessionID: session.id }) const last = all.at(-1) expect(result).toBe("continue") expect(last?.info.role).toBe("user") expect(last?.parts[0]).toMatchObject({ type: "text", synthetic: true, metadata: { compaction_continue: true }, }) if (last?.parts[0]?.type === "text") { expect(last.parts[0].text).toContain("Continue if you have next steps") } }), ) itCompaction.instance( "persists tail_start_id for retained recent turns", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") const keep = yield* createUserMessage(session.id, "second") yield* createUserMessage(session.id, "third") yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false, }) const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), ) itCompaction.instance( "shrinks retained tail to fit preserve token budget", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") yield* createUserMessage(session.id, "x".repeat(2_000)) const keep = yield* createUserMessage(session.id, "tiny") yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false, }) const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), ) itCompaction.instance( "falls back to full summary when even one recent turn exceeds preserve token budget", () => { const stub = llm() let captured = "" stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) return Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") yield* createUserMessage(session.id, "y".repeat(2_000)) yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("yyyy") }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) }, { git: true }, ) itCompaction.instance( "falls back to full summary when retained tail media exceeds preserve token budget", () => { const stub = llm() let captured = "" stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) return Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "older") const recent = yield* createUserMessage(session.id, "recent image turn") yield* ssn.updatePart({ id: PartID.ascending(), messageID: recent.id, sessionID: session.id, type: "file", mime: "image/png", filename: "big.png", url: `data:image/png;base64,${"a".repeat(4_000)}`, }) yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("recent image turn") expect(captured).toContain("Attached image/png: big.png") }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) itCompaction.instance( "retains a split turn suffix when a later message fits the preserve token budget", () => { const stub = llm() let captured = "" stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) return Effect.gen(function* () { const test = yield* TestInstance const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "older") const recent = yield* createUserMessage(session.id, "recent turn") const large = yield* createAssistantMessage(session.id, recent.id, test.directory) yield* ssn.updatePart({ id: PartID.ascending(), messageID: large.id, sessionID: session.id, type: "text", text: "z".repeat(2_000), }) const keep = yield* createAssistantMessage(session.id, recent.id, test.directory) yield* ssn.updatePart({ id: PartID.ascending(), messageID: keep.id, sessionID: session.id, type: "text", text: "keep tail", }) yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) expect(captured).toContain("zzzz") expect(captured).not.toContain("keep tail") const filtered = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) itCompaction.instance( "allows plugins to disable synthetic continue prompt", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const result = yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: true, }) const all = yield* ssn.messages({ sessionID: session.id }) const last = all.at(-1) expect(result).toBe("continue") expect(last?.info.role).toBe("assistant") expect( all.some( (msg) => msg.info.role === "user" && msg.parts.some( (part) => part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), ), ), ).toBe(false) }).pipe(withCompaction({ plugin: autocontinue(false) })), ) it.instance( "replays the prior user turn on overflow when earlier context exists", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "root") const replay = yield* createUserMessage(session.id, "image") yield* ssn.updatePart({ id: PartID.ascending(), messageID: replay.id, sessionID: session.id, type: "file", mime: "image/png", filename: "cat.png", url: "https://example.com/cat.png", }) const msg = yield* createUserMessage(session.id, "current") const msgs = yield* ssn.messages({ sessionID: session.id }) const result = yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: true, overflow: true, }) const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) expect(result).toBe("continue") expect(last?.info.role).toBe("user") expect(last?.parts.some((part) => part.type === "file")).toBe(false) expect( last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), ).toBe(true) }), ) it.instance( "falls back to overflow guidance when no replayable turn exists", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "earlier") const msg = yield* createUserMessage(session.id, "current") const msgs = yield* ssn.messages({ sessionID: session.id }) const result = yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: true, overflow: true, }) const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) expect(result).toBe("continue") expect(last?.info.role).toBe("user") if (last?.parts[0]?.type === "text") { expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") } }), ) itCompaction.instance( "stops quickly when aborted during retry backoff", () => { const stub = llm() stub.push( Stream.fromAsyncIterable( { async *[Symbol.asyncIterator]() { yield LLMEvent.stepStart({ index: 0 }) throw new APICallError({ message: "boom", url: "https://example.com/v1/chat/completions", requestBodyValues: {}, statusCode: 503, responseHeaders: { "retry-after-ms": "10000" }, responseBody: '{"error":"boom"}', isRetryable: true, }) }, }, (err) => err, ), ) return Effect.gen(function* () { const ssn = yield* SessionNs.Service const events = yield* EventV2Bridge.Service const ready = yield* Deferred.make() const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const off = yield* events.listen((evt) => { if (evt.type !== SessionStatus.Event.Status.type) return Effect.void const data = evt.data as typeof SessionStatus.Event.Status.data.Type if (data.sessionID !== session.id || data.status.type !== "retry") return Effect.void Deferred.doneUnsafe(ready, Effect.void) return Effect.void }) yield* Effect.addFinalizer(() => off) const fiber = yield* SessionCompaction.use .process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false, }) .pipe(Effect.forkChild) yield* Deferred.await(ready).pipe(Effect.timeout("5 seconds")) const start = Date.now() yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(Cause.hasInterrupts(exit.cause)).toBe(true) expect(Date.now() - start).toBeLessThan(250) } }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, { timeout: 10_000 }, ) itCompaction.instance( "does not leave a summary assistant when aborted before processor setup", () => Effect.gen(function* () { const ready = yield* Deferred.make() return yield* Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const fiber = yield* SessionCompaction.use .process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false, }) .pipe(Effect.forkChild) yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) const all = yield* ssn.messages({ sessionID: session.id }) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true) expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) }).pipe(withCompaction({ plugin: plugin(ready) })) }), { git: true }, ) itCompaction.instance( "silently drops reasoning-delta arriving without prior reasoning-start", () => { // Regression: PR initially auto-created a reasoning Part for orphan deltas (no preceding // reasoning-start). Reverted to match dev — drop silently. Pinned here so any future // change to processor.ts reasoning-delta handling triggers this test. const stub = llm() stub.push( Stream.make( LLMEvent.reasoningDelta({ id: "orphan-1", text: "stray reasoning" }), LLMEvent.textStart({ id: "txt-0" }), LLMEvent.textDelta({ id: "txt-0", text: "summary" }), LLMEvent.textEnd({ id: "txt-0" }), LLMEvent.stepFinish({ index: 0, reason: "stop", usage: basicUsage() }), LLMEvent.finish({ reason: "stop", usage: basicUsage() }), ), ) return Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false, }) const summary = (yield* ssn.messages({ sessionID: session.id })).find( (item) => item.info.role === "assistant" && item.info.summary, ) expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false) // Sanity: the text part still got through. expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true) }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) itCompaction.instance( "does not allow tool calls while generating the summary", () => { const stub = llm() stub.push( Stream.make( LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls", usage: basicUsage(), }), LLMEvent.finish({ reason: "tool-calls", usage: basicUsage(), }), ), ) return Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false }) const summary = (yield* ssn.messages({ sessionID: session.id })).find( (item) => item.info.role === "assistant" && item.info.summary, ) expect(summary?.info.role).toBe("assistant") expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) itCompaction.instance( "summarizes only the head while keeping recent tail out of summary input", () => { const stub = llm() let messages: LLM.StreamInput["messages"] = [] stub.push( reply("summary", (input) => { messages = 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) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false, }) const captured = JSON.stringify(messages) expect(messages).toHaveLength(1) expect(messages[0]?.role).toBe("user") expect(captured).toContain("[User]: older context") expect(captured).not.toContain("keep this turn") expect(captured).not.toContain("and this one too") expect(captured).not.toContain("What did we do so far?") }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) itCompaction.instance( "anchors repeated compactions with the previous summary", () => { const stub = llm() let captured = "" stub.push(reply("summary one")) stub.push( reply("summary two", (input) => { 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* createCompactionMarker(session.id) let msgs = yield* ssn.messages({ sessionID: session.id }) let parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) yield* createUserMessage(session.id, "latest turn") yield* createCompactionMarker(session.id) msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) expect(captured).toContain("") expect(captured).toContain("summary one") expect(captured.match(/summary one/g)?.length).toBe(1) expect(captured).toContain("## Important Details") expect(captured).toContain("## Work State") }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) itCompaction.instance( "serializes repeated compaction history as one user message", () => { const stub = llm() let captured: LLM.StreamInput["messages"] = [] stub.push( reply("summary two", (input) => { captured = input.messages }), ) return Effect.gen(function* () { const ssn = yield* SessionNs.Service const test = yield* TestInstance const session = yield* ssn.create({}) const turn = yield* createUserMessage(session.id, "original request") const kept = yield* createAssistantMessage(session.id, turn.id, test.directory) yield* ssn.updatePart({ id: PartID.ascending(), messageID: kept.id, sessionID: session.id, type: "tool", callID: "read-call", tool: "read", state: { status: "completed", input: { filePath: "src/index.ts" }, output: "file contents", title: "src/index.ts", metadata: {}, time: { start: Date.now(), end: Date.now() }, }, }) const previous = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", model: ref, sessionID: session.id, agent: "build", time: { created: Date.now() }, }) yield* ssn.updatePart({ id: PartID.ascending(), messageID: previous.id, sessionID: session.id, type: "compaction", auto: false, tail_start_id: kept.id, }) yield* createSummaryAssistantMessage(session.id, previous.id, test.directory, "summary one") yield* createCompactionMarker(session.id) const msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) expect(captured).toHaveLength(1) expect(captured[0]?.role).toBe("user") expect(JSON.stringify(captured)).toContain('[Assistant tool call]: read({\\"filePath\\":\\"src/index.ts\\"})') expect(JSON.stringify(captured)).toContain("[Tool result]: file contents") expect(JSON.stringify(captured)).not.toContain('\\"role\\":\\"assistant\\"') }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 0 }) })) }, { git: true }, ) itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) return Effect.gen(function* () { const ssn = yield* SessionNs.Service 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 expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) const u4 = yield* createUserMessage(session.id, "four") yield* createCompactionMarker(session.id) msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) const filtered = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) const ids = filtered.map((msg) => msg.info.id) expect(ids).not.toContain(u1.id) expect(ids).not.toContain(u2.id) expect(ids).toContain(u3.id) expect(ids).toContain(u4.id) expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), ).toBe(true) }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) itCompaction.instance( "ignores previous summaries when sizing the retained tail", Effect.gen(function* () { const ssn = yield* SessionNs.Service const test = yield* TestInstance const session = yield* ssn.create({}) yield* createUserMessage(session.id, "older") const keep = yield* createUserMessage(session.id, "keep this turn") const keepReply = yield* createAssistantMessage(session.id, keep.id, test.directory) yield* ssn.updatePart({ id: PartID.ascending(), messageID: keepReply.id, sessionID: session.id, type: "text", text: "keep reply", }) yield* createCompactionMarker(session.id) const firstCompaction = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id expect(firstCompaction).toBeTruthy() yield* createSummaryAssistantMessage(session.id, firstCompaction!, test.directory, "summary ".repeat(800)) const recent = yield* createUserMessage(session.id, "recent turn") const recentReply = yield* createAssistantMessage(session.id, recent.id, test.directory) yield* ssn.updatePart({ id: PartID.ascending(), messageID: recentReply.id, sessionID: session.id, type: "text", text: "recent reply", }) yield* createCompactionMarker(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), ) }) describe("util.token.estimate", () => { test("estimates tokens from text (4 chars per token)", () => { const text = "x".repeat(4000) expect(Token.estimate(text)).toBe(1000) }) test("estimates tokens from larger text", () => { const text = "y".repeat(20_000) expect(Token.estimate(text)).toBe(5000) }) test("returns 0 for empty string", () => { expect(Token.estimate("")).toBe(0) }) }) describe("SessionNs.getUsage", () => { test("normalizes standard usage to token format", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }), }) expect(result.tokens.input).toBe(1000) expect(result.tokens.output).toBe(500) expect(result.tokens.reasoning).toBe(0) expect(result.tokens.cache.read).toBe(0) expect(result.tokens.cache.write).toBe(0) }) test("extracts cached tokens to cache.read", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }), }) expect(result.tokens.input).toBe(800) expect(result.tokens.cache.read).toBe(200) }) test("handles anthropic cache write metadata", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }), metadata: { anthropic: { cacheCreationInputTokens: 300, }, }, }) expect(result.tokens.cache.write).toBe(300) }) test("subtracts cached tokens for anthropic provider", () => { const model = createModel({ context: 100_000, output: 32_000 }) // AI SDK v6 normalizes inputTokens to include cached tokens for all providers const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }), metadata: { anthropic: {}, }, }) expect(result.tokens.input).toBe(800) expect(result.tokens.cache.read).toBe(200) }) test("separates reasoning tokens from output tokens", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }), }) expect(result.tokens.input).toBe(1000) expect(result.tokens.output).toBe(400) expect(result.tokens.reasoning).toBe(100) expect(result.tokens.total).toBe(1500) }) test("does not double count reasoning tokens in cost", () => { const model = createModel({ context: 100_000, output: 32_000, cost: { input: 0, output: 15, cache: { read: 0, write: 0 }, }, }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }), }) expect(result.tokens.output).toBe(750_000) expect(result.tokens.reasoning).toBe(250_000) expect(result.cost).toBe(15) }) test("handles undefined optional values gracefully", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }), }) expect(result.tokens.input).toBe(0) expect(result.tokens.output).toBe(0) expect(result.tokens.reasoning).toBe(0) expect(result.tokens.cache.read).toBe(0) expect(result.tokens.cache.write).toBe(0) expect(Number.isNaN(result.cost)).toBe(false) }) test("calculates cost correctly", () => { const model = createModel({ context: 100_000, output: 32_000, cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 }, }, }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }), }) expect(result.cost).toBe(3 + 1.5) }) test("uses authoritative Copilot billed cost when provided", () => { const result = SessionNs.getUsage({ model: createModel({ context: 100_000, output: 32_000, cost: { input: 3, output: 15, cache: { read: 0.3, write: 0.3 } }, }), usage: usage({ inputTokens: 11_774, outputTokens: 39, totalTokens: 11_813 }), metadata: { copilot: { totalNanoAiu: 4_473_525_000 } }, }) expect(result.cost).toBe(0.04473525) }) test("uses matching context cost tier before over-200k fallback", () => { const model = createModel({ context: 1_000_000, output: 32_000, cost: { input: 1, output: 2, cache: { read: 0.1, write: 0.5 }, tiers: [ { input: 3, output: 4, cache: { read: 0.3, write: 1.5 }, tier: { type: "context", size: 200_000 }, }, { input: 5, output: 6, cache: { read: 0.5, write: 2.5 }, tier: { type: "context", size: 500_000 }, }, ], experimentalOver200K: { input: 100, output: 100, cache: { read: 100, write: 100 }, }, }, }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 650_000, outputTokens: 100_000, totalTokens: 750_000, cacheReadInputTokens: 100_000, }), }) expect(result.tokens.input).toBe(550_000) expect(result.cost).toBe(2.75 + 0.6 + 0.05) }) test("falls back to over-200k pricing when no cost tier matches", () => { const model = createModel({ context: 1_000_000, output: 32_000, cost: { input: 1, output: 2, cache: { read: 0.1, write: 0.5 }, tiers: [ { input: 5, output: 6, cache: { read: 0.5, write: 2.5 }, tier: { type: "context", size: 500_000 }, }, ], experimentalOver200K: { input: 3, output: 4, cache: { read: 0.3, write: 1.5 }, }, }, }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }), }) expect(result.cost).toBe(0.9 + 0.4) }) test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])( "computes total from components for %s models", (npm) => { const model = createModel({ context: 100_000, output: 32_000, npm }) // AI SDK v6: inputTokens includes cached tokens for all providers const item = usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200, }) if (npm === "@ai-sdk/amazon-bedrock") { const result = SessionNs.getUsage({ model, usage: item, metadata: { bedrock: { usage: { cacheWriteInputTokens: 300, }, }, }, }) // inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500 expect(result.tokens.input).toBe(500) expect(result.tokens.cache.read).toBe(200) expect(result.tokens.cache.write).toBe(300) // total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300) expect(result.tokens.total).toBe(1500) return } const result = SessionNs.getUsage({ model, usage: item, metadata: { anthropic: { cacheCreationInputTokens: 300, }, }, }) // inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500 expect(result.tokens.input).toBe(500) expect(result.tokens.cache.read).toBe(200) expect(result.tokens.cache.write).toBe(300) // total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300) expect(result.tokens.total).toBe(1500) }, ) test("extracts cache write tokens from vertex metadata key", () => { const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" }) const result = SessionNs.getUsage({ model, usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }), metadata: { vertex: { cacheCreationInputTokens: 300, }, }, }) expect(result.tokens.input).toBe(500) expect(result.tokens.cache.read).toBe(200) expect(result.tokens.cache.write).toBe(300) }) })