compaction.test.ts 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788
  1. import { afterEach, describe, expect, mock, test } from "bun:test"
  2. import { APICallError } from "ai"
  3. import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
  4. import * as Stream from "effect/Stream"
  5. import { Bus } from "../../src/bus"
  6. import { Config } from "@/config/config"
  7. import { Image } from "@/image/image"
  8. import { Agent } from "../../src/agent/agent"
  9. import { LLM } from "../../src/session/llm"
  10. import { SessionCompaction } from "../../src/session/compaction"
  11. import { Token } from "@/util/token"
  12. import * as Log from "@opencode-ai/core/util/log"
  13. import { Permission } from "../../src/permission"
  14. import { Plugin } from "../../src/plugin"
  15. import { provideTmpdirInstance, TestInstance } from "../fixture/fixture"
  16. import { Session as SessionNs } from "@/session/session"
  17. import { MessageV2 } from "../../src/session/message-v2"
  18. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  19. import { SessionStatus } from "../../src/session/status"
  20. import { SessionSummary } from "../../src/session/summary"
  21. import { SessionV2 } from "../../src/v2/session"
  22. import { ModelID, ProviderID } from "../../src/provider/schema"
  23. import type { Provider } from "@/provider/provider"
  24. import * as SessionProcessorModule from "../../src/session/processor"
  25. import { Snapshot } from "../../src/snapshot"
  26. import { ProviderTest } from "../fake/provider"
  27. import { testEffect } from "../lib/effect"
  28. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  29. import { TestConfig } from "../fixture/config"
  30. import { SyncEvent } from "@/sync"
  31. import { RuntimeFlags } from "@/effect/runtime-flags"
  32. import { EventV2Bridge } from "@/event-v2-bridge"
  33. import { LLMEvent, Usage } from "@opencode-ai/llm"
  34. void Log.init({ print: false })
  35. const summary = Layer.succeed(
  36. SessionSummary.Service,
  37. SessionSummary.Service.of({
  38. summarize: () => Effect.void,
  39. diff: () => Effect.succeed([]),
  40. computeDiff: () => Effect.succeed([]),
  41. }),
  42. )
  43. const ref = {
  44. providerID: ProviderID.make("test"),
  45. modelID: ModelID.make("test-model"),
  46. }
  47. const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
  48. const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 })
  49. afterEach(() => {
  50. mock.restore()
  51. })
  52. function createModel(opts: {
  53. context: number
  54. output: number
  55. input?: number
  56. cost?: Provider.Model["cost"]
  57. npm?: string
  58. }): Provider.Model {
  59. return {
  60. id: "test-model",
  61. providerID: "test",
  62. name: "Test",
  63. limit: {
  64. context: opts.context,
  65. input: opts.input,
  66. output: opts.output,
  67. },
  68. cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } },
  69. capabilities: {
  70. toolcall: true,
  71. attachment: false,
  72. reasoning: false,
  73. temperature: true,
  74. input: { text: true, image: false, audio: false, video: false },
  75. output: { text: true, image: false, audio: false, video: false },
  76. },
  77. api: { npm: opts.npm ?? "@ai-sdk/anthropic" },
  78. options: {},
  79. } as Provider.Model
  80. }
  81. const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
  82. function createUserMessage(sessionID: SessionID, text: string) {
  83. return Effect.gen(function* () {
  84. const ssn = yield* SessionNs.Service
  85. const msg = yield* ssn.updateMessage({
  86. id: MessageID.ascending(),
  87. role: "user",
  88. sessionID,
  89. agent: "build",
  90. model: ref,
  91. time: { created: Date.now() },
  92. })
  93. yield* ssn.updatePart({
  94. id: PartID.ascending(),
  95. messageID: msg.id,
  96. sessionID,
  97. type: "text",
  98. text,
  99. })
  100. return msg
  101. })
  102. }
  103. function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string) {
  104. return SessionNs.Service.use((ssn) =>
  105. ssn.updateMessage({
  106. id: MessageID.ascending(),
  107. role: "assistant",
  108. sessionID,
  109. mode: "build",
  110. agent: "build",
  111. path: { cwd: root, root },
  112. cost: 0,
  113. tokens: {
  114. output: 0,
  115. input: 0,
  116. reasoning: 0,
  117. cache: { read: 0, write: 0 },
  118. },
  119. modelID: ref.modelID,
  120. providerID: ref.providerID,
  121. parentID,
  122. time: { created: Date.now() },
  123. finish: "end_turn",
  124. }),
  125. )
  126. }
  127. function createSummaryAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string, text: string) {
  128. return SessionNs.Service.use((ssn) =>
  129. Effect.gen(function* () {
  130. const msg = yield* ssn.updateMessage({
  131. id: MessageID.ascending(),
  132. role: "assistant",
  133. sessionID,
  134. mode: "compaction",
  135. agent: "compaction",
  136. path: { cwd: root, root },
  137. cost: 0,
  138. tokens: {
  139. output: 0,
  140. input: 0,
  141. reasoning: 0,
  142. cache: { read: 0, write: 0 },
  143. },
  144. modelID: ref.modelID,
  145. providerID: ref.providerID,
  146. parentID,
  147. summary: true,
  148. time: { created: Date.now() },
  149. finish: "end_turn",
  150. })
  151. yield* ssn.updatePart({
  152. id: PartID.ascending(),
  153. messageID: msg.id,
  154. sessionID,
  155. type: "text",
  156. text,
  157. })
  158. return msg
  159. }),
  160. )
  161. }
  162. function createCompactionMarker(sessionID: SessionID) {
  163. return SessionNs.Service.use((ssn) =>
  164. Effect.gen(function* () {
  165. const msg = yield* ssn.updateMessage({
  166. id: MessageID.ascending(),
  167. role: "user",
  168. model: ref,
  169. sessionID,
  170. agent: "build",
  171. time: { created: Date.now() },
  172. })
  173. yield* ssn.updatePart({
  174. id: PartID.ascending(),
  175. messageID: msg.id,
  176. sessionID: msg.sessionID,
  177. type: "compaction",
  178. auto: false,
  179. })
  180. }),
  181. )
  182. }
  183. function fake(
  184. input: Parameters<SessionProcessorModule.SessionProcessor.Interface["create"]>[0],
  185. result: "continue" | "compact",
  186. ) {
  187. const msg = input.assistantMessage
  188. return {
  189. get message() {
  190. return msg
  191. },
  192. updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)),
  193. completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void),
  194. process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)),
  195. } satisfies SessionProcessorModule.SessionProcessor.Handle
  196. }
  197. function layer(result: "continue" | "compact") {
  198. return Layer.succeed(
  199. SessionProcessorModule.SessionProcessor.Service,
  200. SessionProcessorModule.SessionProcessor.Service.of({
  201. create: Effect.fn("TestSessionProcessor.create")((input) => Effect.succeed(fake(input, result))),
  202. }),
  203. )
  204. }
  205. function cfg(compaction?: Config.Info["compaction"]) {
  206. const base = Schema.decodeUnknownSync(Config.Info)({}) as Config.Info
  207. return TestConfig.layer({
  208. get: () => Effect.succeed({ ...base, compaction }),
  209. })
  210. }
  211. const deps = Layer.mergeAll(
  212. wide().layer,
  213. layer("continue"),
  214. Agent.defaultLayer,
  215. Plugin.defaultLayer,
  216. Bus.layer,
  217. Config.defaultLayer,
  218. SyncEvent.defaultLayer,
  219. RuntimeFlags.layer({ experimentalEventSystem: true }),
  220. EventV2Bridge.defaultLayer,
  221. )
  222. const env = Layer.mergeAll(
  223. SessionNs.defaultLayer,
  224. CrossSpawnSpawner.defaultLayer,
  225. SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)),
  226. )
  227. const it = testEffect(env)
  228. const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer)
  229. const itCompaction = testEffect(compactionEnv)
  230. type CompactionProcessOptions = {
  231. result?: "continue" | "compact"
  232. llm?: Layer.Layer<LLM.Service>
  233. plugin?: Layer.Layer<Plugin.Service>
  234. provider?: ReturnType<typeof ProviderTest.fake>
  235. config?: Layer.Layer<Config.Service>
  236. }
  237. function withCompaction(options?: CompactionProcessOptions) {
  238. return Effect.provide(compactionProcessLayer(options))
  239. }
  240. function compactionProcessLayer(options?: CompactionProcessOptions) {
  241. const bus = Bus.layer
  242. const status = SessionStatus.layer.pipe(Layer.provide(bus))
  243. const processor = options?.llm
  244. ? SessionProcessorModule.SessionProcessor.layer.pipe(
  245. Layer.provide(summary),
  246. Layer.provide(Image.defaultLayer),
  247. Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
  248. Layer.provide(status),
  249. )
  250. : layer(options?.result ?? "continue")
  251. return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
  252. Layer.provide(SessionNs.defaultLayer),
  253. Layer.provide((options?.provider ?? wide()).layer),
  254. Layer.provide(Snapshot.defaultLayer),
  255. Layer.provide(options?.llm ?? LLM.defaultLayer),
  256. Layer.provide(Permission.defaultLayer),
  257. Layer.provide(Agent.defaultLayer),
  258. Layer.provide(options?.plugin ?? Plugin.defaultLayer),
  259. Layer.provide(status),
  260. Layer.provide(bus),
  261. Layer.provide(options?.config ?? Config.defaultLayer),
  262. Layer.provide(SyncEvent.defaultLayer),
  263. Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
  264. Layer.provide(EventV2Bridge.defaultLayer),
  265. )
  266. }
  267. function createSummaryCompaction(sessionID: SessionID) {
  268. return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false })
  269. }
  270. function readCompactionPart(sessionID: SessionID) {
  271. return SessionNs.use
  272. .messages({ sessionID })
  273. .pipe(
  274. Effect.map((messages) =>
  275. messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"),
  276. ),
  277. )
  278. }
  279. function llm() {
  280. const queue: Array<
  281. Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)
  282. > = []
  283. return {
  284. push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
  285. queue.push(stream)
  286. },
  287. layer: Layer.succeed(
  288. LLM.Service,
  289. LLM.Service.of({
  290. stream: (input) => {
  291. const item = queue.shift() ?? Stream.empty
  292. const stream = typeof item === "function" ? item(input) : item
  293. return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event)))
  294. },
  295. }),
  296. ),
  297. }
  298. }
  299. function reply(
  300. text: string,
  301. capture?: (input: LLM.StreamInput) => void,
  302. ): (input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown> {
  303. return (input) => {
  304. capture?.(input)
  305. return Stream.make(
  306. LLMEvent.textStart({ id: "txt-0" }),
  307. LLMEvent.textDelta({ id: "txt-0", text }),
  308. LLMEvent.textEnd({ id: "txt-0" }),
  309. LLMEvent.stepFinish({
  310. index: 0,
  311. reason: "stop",
  312. usage: basicUsage(),
  313. }),
  314. LLMEvent.finish({
  315. reason: "stop",
  316. usage: basicUsage(),
  317. }),
  318. )
  319. }
  320. }
  321. function plugin(ready: Deferred.Deferred<void>) {
  322. return Layer.mock(Plugin.Service)({
  323. trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
  324. if (name !== "experimental.session.compacting") return Effect.succeed(output)
  325. return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe(
  326. Effect.andThen(Effect.never),
  327. Effect.as(output),
  328. )
  329. },
  330. list: () => Effect.succeed([]),
  331. init: () => Effect.void,
  332. })
  333. }
  334. function autocontinue(enabled: boolean) {
  335. return Layer.mock(Plugin.Service)({
  336. trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
  337. if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output)
  338. return Effect.sync(() => {
  339. ;(output as { enabled: boolean }).enabled = enabled
  340. return output
  341. })
  342. },
  343. list: () => Effect.succeed([]),
  344. init: () => Effect.void,
  345. })
  346. }
  347. describe("session.compaction.isOverflow", () => {
  348. it.live(
  349. "returns true when token count exceeds usable context",
  350. provideTmpdirInstance(() =>
  351. Effect.gen(function* () {
  352. const compact = yield* SessionCompaction.Service
  353. const model = createModel({ context: 100_000, output: 32_000 })
  354. const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
  355. expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
  356. }),
  357. ),
  358. )
  359. it.live(
  360. "returns false when token count within usable context",
  361. provideTmpdirInstance(() =>
  362. Effect.gen(function* () {
  363. const compact = yield* SessionCompaction.Service
  364. const model = createModel({ context: 200_000, output: 32_000 })
  365. const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
  366. expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
  367. }),
  368. ),
  369. )
  370. it.live(
  371. "includes cache.read in token count",
  372. provideTmpdirInstance(() =>
  373. Effect.gen(function* () {
  374. const compact = yield* SessionCompaction.Service
  375. const model = createModel({ context: 100_000, output: 32_000 })
  376. const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
  377. expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
  378. }),
  379. ),
  380. )
  381. it.live(
  382. "respects input limit for input caps",
  383. provideTmpdirInstance(() =>
  384. Effect.gen(function* () {
  385. const compact = yield* SessionCompaction.Service
  386. const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
  387. const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } }
  388. expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
  389. }),
  390. ),
  391. )
  392. it.live(
  393. "returns false when input/output are within input caps",
  394. provideTmpdirInstance(() =>
  395. Effect.gen(function* () {
  396. const compact = yield* SessionCompaction.Service
  397. const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
  398. const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
  399. expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
  400. }),
  401. ),
  402. )
  403. it.live(
  404. "returns false when output within limit with input caps",
  405. provideTmpdirInstance(() =>
  406. Effect.gen(function* () {
  407. const compact = yield* SessionCompaction.Service
  408. const model = createModel({ context: 200_000, input: 120_000, output: 10_000 })
  409. const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } }
  410. expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
  411. }),
  412. ),
  413. )
  414. // ─── Bug reproduction tests ───────────────────────────────────────────
  415. // These tests demonstrate that when limit.input is set, isOverflow()
  416. // does not subtract any headroom for the next model response. This means
  417. // compaction only triggers AFTER we've already consumed the full input
  418. // budget, leaving zero room for the next API call's output tokens.
  419. //
  420. // Compare: without limit.input, usable = context - output (reserves space).
  421. // With limit.input, usable = limit.input (reserves nothing).
  422. //
  423. // Related issues: #10634, #8089, #11086, #12621
  424. // Open PRs: #6875, #12924
  425. it.live(
  426. "BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not",
  427. provideTmpdirInstance(() =>
  428. Effect.gen(function* () {
  429. const compact = yield* SessionCompaction.Service
  430. // Simulate Claude with prompt caching: input limit = 200K, output limit = 32K
  431. const model = createModel({ context: 200_000, input: 200_000, output: 32_000 })
  432. // We've used 198K tokens total. Only 2K under the input limit.
  433. // On the next turn, the full conversation (198K) becomes input,
  434. // plus the model needs room to generate output — this WILL overflow.
  435. const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } }
  436. // count = 180K + 3K + 15K = 198K
  437. // usable = limit.input = 200K (no output subtracted!)
  438. // 198K > 200K = false → no compaction triggered
  439. // WITHOUT limit.input: usable = 200K - 32K = 168K, and 198K > 168K = true ✓
  440. // WITH limit.input: usable = 200K, and 198K > 200K = false ✗
  441. // With 198K used and only 2K headroom, the next turn will overflow.
  442. // Compaction MUST trigger here.
  443. expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
  444. }),
  445. ),
  446. )
  447. it.live(
  448. "BUG: without limit.input, same token count correctly triggers compaction",
  449. provideTmpdirInstance(() =>
  450. Effect.gen(function* () {
  451. const compact = yield* SessionCompaction.Service
  452. // Same model but without limit.input — uses context - output instead
  453. const model = createModel({ context: 200_000, output: 32_000 })
  454. // Same token usage as above
  455. const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } }
  456. // count = 198K
  457. // usable = context - output = 200K - 32K = 168K
  458. // 198K > 168K = true → compaction correctly triggered
  459. const result = yield* compact.isOverflow({ tokens, model })
  460. expect(result).toBe(true) // ← Correct: headroom is reserved
  461. }),
  462. ),
  463. )
  464. it.live(
  465. "BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it",
  466. provideTmpdirInstance(() =>
  467. Effect.gen(function* () {
  468. const compact = yield* SessionCompaction.Service
  469. // Two models with identical context/output limits, differing only in limit.input
  470. const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
  471. const withoutInputLimit = createModel({ context: 200_000, output: 32_000 })
  472. // 170K total tokens — well above context-output (168K) but below input limit (200K)
  473. const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
  474. const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit })
  475. const withoutLimit = yield* compact.isOverflow({ tokens, model: withoutInputLimit })
  476. // Both models have identical real capacity — they should agree:
  477. expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output)
  478. expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K)
  479. }),
  480. ),
  481. )
  482. it.live(
  483. "returns false when model context limit is 0",
  484. provideTmpdirInstance(() =>
  485. Effect.gen(function* () {
  486. const compact = yield* SessionCompaction.Service
  487. const model = createModel({ context: 0, output: 32_000 })
  488. const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
  489. expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
  490. }),
  491. ),
  492. )
  493. it.live(
  494. "returns false when compaction.auto is disabled",
  495. provideTmpdirInstance(
  496. () =>
  497. Effect.gen(function* () {
  498. const compact = yield* SessionCompaction.Service
  499. const model = createModel({ context: 100_000, output: 32_000 })
  500. const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
  501. expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
  502. }),
  503. {
  504. config: {
  505. compaction: { auto: false },
  506. },
  507. },
  508. ),
  509. )
  510. })
  511. describe("session.compaction.create", () => {
  512. it.live(
  513. "creates a compaction user message and part",
  514. provideTmpdirInstance(() =>
  515. Effect.gen(function* () {
  516. const compact = yield* SessionCompaction.Service
  517. const ssn = yield* SessionNs.Service
  518. const info = yield* ssn.create({})
  519. yield* compact.create({
  520. sessionID: info.id,
  521. agent: "build",
  522. model: ref,
  523. auto: true,
  524. overflow: true,
  525. })
  526. const msgs = yield* ssn.messages({ sessionID: info.id })
  527. expect(msgs).toHaveLength(1)
  528. expect(msgs[0].info.role).toBe("user")
  529. expect(msgs[0].parts).toHaveLength(1)
  530. expect(msgs[0].parts[0]).toMatchObject({
  531. type: "compaction",
  532. auto: true,
  533. overflow: true,
  534. })
  535. const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe(
  536. Effect.provide(SessionV2.defaultLayer),
  537. )
  538. expect(v2.at(-1)).toMatchObject({
  539. type: "compaction",
  540. reason: "auto",
  541. summary: "",
  542. })
  543. }),
  544. ),
  545. )
  546. })
  547. describe("session.compaction.prune", () => {
  548. it.live(
  549. "compacts old completed tool output",
  550. provideTmpdirInstance(
  551. (dir) =>
  552. Effect.gen(function* () {
  553. const compact = yield* SessionCompaction.Service
  554. const ssn = yield* SessionNs.Service
  555. const info = yield* ssn.create({})
  556. const a = yield* ssn.updateMessage({
  557. id: MessageID.ascending(),
  558. role: "user",
  559. sessionID: info.id,
  560. agent: "build",
  561. model: ref,
  562. time: { created: Date.now() },
  563. })
  564. yield* ssn.updatePart({
  565. id: PartID.ascending(),
  566. messageID: a.id,
  567. sessionID: info.id,
  568. type: "text",
  569. text: "first",
  570. })
  571. const b: MessageV2.Assistant = {
  572. id: MessageID.ascending(),
  573. role: "assistant",
  574. sessionID: info.id,
  575. mode: "build",
  576. agent: "build",
  577. path: { cwd: dir, root: dir },
  578. cost: 0,
  579. tokens: {
  580. output: 0,
  581. input: 0,
  582. reasoning: 0,
  583. cache: { read: 0, write: 0 },
  584. },
  585. modelID: ref.modelID,
  586. providerID: ref.providerID,
  587. parentID: a.id,
  588. time: { created: Date.now() },
  589. finish: "end_turn",
  590. }
  591. yield* ssn.updateMessage(b)
  592. yield* ssn.updatePart({
  593. id: PartID.ascending(),
  594. messageID: b.id,
  595. sessionID: info.id,
  596. type: "tool",
  597. callID: crypto.randomUUID(),
  598. tool: "bash",
  599. state: {
  600. status: "completed",
  601. input: {},
  602. output: "x".repeat(200_000),
  603. title: "done",
  604. metadata: {},
  605. time: { start: Date.now(), end: Date.now() },
  606. },
  607. })
  608. for (const text of ["second", "third"]) {
  609. const msg = yield* ssn.updateMessage({
  610. id: MessageID.ascending(),
  611. role: "user",
  612. sessionID: info.id,
  613. agent: "build",
  614. model: ref,
  615. time: { created: Date.now() },
  616. })
  617. yield* ssn.updatePart({
  618. id: PartID.ascending(),
  619. messageID: msg.id,
  620. sessionID: info.id,
  621. type: "text",
  622. text,
  623. })
  624. }
  625. yield* compact.prune({ sessionID: info.id })
  626. const msgs = yield* ssn.messages({ sessionID: info.id })
  627. const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
  628. expect(part?.type).toBe("tool")
  629. expect(part?.state.status).toBe("completed")
  630. if (part?.type === "tool" && part.state.status === "completed") {
  631. expect(part.state.time.compacted).toBeNumber()
  632. }
  633. }),
  634. {
  635. config: {
  636. compaction: { prune: true },
  637. },
  638. },
  639. ),
  640. )
  641. it.live(
  642. "skips protected skill tool output",
  643. provideTmpdirInstance((dir) =>
  644. Effect.gen(function* () {
  645. const compact = yield* SessionCompaction.Service
  646. const ssn = yield* SessionNs.Service
  647. const info = yield* ssn.create({})
  648. const a = yield* ssn.updateMessage({
  649. id: MessageID.ascending(),
  650. role: "user",
  651. sessionID: info.id,
  652. agent: "build",
  653. model: ref,
  654. time: { created: Date.now() },
  655. })
  656. yield* ssn.updatePart({
  657. id: PartID.ascending(),
  658. messageID: a.id,
  659. sessionID: info.id,
  660. type: "text",
  661. text: "first",
  662. })
  663. const b: MessageV2.Assistant = {
  664. id: MessageID.ascending(),
  665. role: "assistant",
  666. sessionID: info.id,
  667. mode: "build",
  668. agent: "build",
  669. path: { cwd: dir, root: dir },
  670. cost: 0,
  671. tokens: {
  672. output: 0,
  673. input: 0,
  674. reasoning: 0,
  675. cache: { read: 0, write: 0 },
  676. },
  677. modelID: ref.modelID,
  678. providerID: ref.providerID,
  679. parentID: a.id,
  680. time: { created: Date.now() },
  681. finish: "end_turn",
  682. }
  683. yield* ssn.updateMessage(b)
  684. yield* ssn.updatePart({
  685. id: PartID.ascending(),
  686. messageID: b.id,
  687. sessionID: info.id,
  688. type: "tool",
  689. callID: crypto.randomUUID(),
  690. tool: "skill",
  691. state: {
  692. status: "completed",
  693. input: {},
  694. output: "x".repeat(200_000),
  695. title: "done",
  696. metadata: {},
  697. time: { start: Date.now(), end: Date.now() },
  698. },
  699. })
  700. for (const text of ["second", "third"]) {
  701. const msg = yield* ssn.updateMessage({
  702. id: MessageID.ascending(),
  703. role: "user",
  704. sessionID: info.id,
  705. agent: "build",
  706. model: ref,
  707. time: { created: Date.now() },
  708. })
  709. yield* ssn.updatePart({
  710. id: PartID.ascending(),
  711. messageID: msg.id,
  712. sessionID: info.id,
  713. type: "text",
  714. text,
  715. })
  716. }
  717. yield* compact.prune({ sessionID: info.id })
  718. const msgs = yield* ssn.messages({ sessionID: info.id })
  719. const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
  720. expect(part?.type).toBe("tool")
  721. if (part?.type === "tool" && part.state.status === "completed") {
  722. expect(part.state.time.compacted).toBeUndefined()
  723. }
  724. }),
  725. ),
  726. )
  727. })
  728. describe("session.compaction.process", () => {
  729. it.instance(
  730. "throws when parent is not a user message",
  731. Effect.gen(function* () {
  732. const test = yield* TestInstance
  733. const ssn = yield* SessionNs.Service
  734. const session = yield* ssn.create({})
  735. const msg = yield* createUserMessage(session.id, "hello")
  736. const reply = yield* createAssistantMessage(session.id, msg.id, test.directory)
  737. const msgs = yield* ssn.messages({ sessionID: session.id })
  738. const exit = yield* Effect.exit(
  739. SessionCompaction.use.process({
  740. parentID: reply.id,
  741. messages: msgs,
  742. sessionID: session.id,
  743. auto: false,
  744. }),
  745. )
  746. expect(Exit.isFailure(exit)).toBe(true)
  747. if (Exit.isFailure(exit)) {
  748. const error = Cause.squash(exit.cause)
  749. expect(error).toBeInstanceOf(Error)
  750. if (error instanceof Error) {
  751. expect(error.message).toContain(`Compaction parent must be a user message: ${reply.id}`)
  752. }
  753. }
  754. }),
  755. )
  756. it.instance(
  757. "publishes compacted event on continue",
  758. Effect.gen(function* () {
  759. const bus = yield* Bus.Service
  760. const ssn = yield* SessionNs.Service
  761. const session = yield* ssn.create({})
  762. const msg = yield* createUserMessage(session.id, "hello")
  763. const msgs = yield* ssn.messages({ sessionID: session.id })
  764. const done = yield* Deferred.make<void, Error>()
  765. let seen = false
  766. const unsub = yield* bus.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => {
  767. if (evt.properties.sessionID !== session.id) return
  768. seen = true
  769. Deferred.doneUnsafe(done, Effect.void)
  770. })
  771. yield* Effect.addFinalizer(() => Effect.sync(unsub))
  772. const result = yield* SessionCompaction.use.process({
  773. parentID: msg.id,
  774. messages: msgs,
  775. sessionID: session.id,
  776. auto: false,
  777. })
  778. yield* Deferred.await(done).pipe(Effect.timeout("500 millis"))
  779. expect(result).toBe("continue")
  780. expect(seen).toBe(true)
  781. }),
  782. )
  783. itCompaction.instance(
  784. "marks summary message as errored on compact result",
  785. Effect.gen(function* () {
  786. const ssn = yield* SessionNs.Service
  787. const session = yield* ssn.create({})
  788. const msg = yield* createUserMessage(session.id, "hello")
  789. const msgs = yield* ssn.messages({ sessionID: session.id })
  790. const result = yield* SessionCompaction.use.process({
  791. parentID: msg.id,
  792. messages: msgs,
  793. sessionID: session.id,
  794. auto: false,
  795. })
  796. const summary = (yield* ssn.messages({ sessionID: session.id })).find(
  797. (msg) => msg.info.role === "assistant" && msg.info.summary,
  798. )
  799. expect(result).toBe("stop")
  800. expect(summary?.info.role).toBe("assistant")
  801. if (summary?.info.role === "assistant") {
  802. expect(summary.info.finish).toBe("error")
  803. expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact")
  804. }
  805. }).pipe(withCompaction({ result: "compact" })),
  806. )
  807. it.instance(
  808. "adds synthetic continue prompt when auto is enabled",
  809. Effect.gen(function* () {
  810. const ssn = yield* SessionNs.Service
  811. const session = yield* ssn.create({})
  812. const msg = yield* createUserMessage(session.id, "hello")
  813. const msgs = yield* ssn.messages({ sessionID: session.id })
  814. const result = yield* SessionCompaction.use.process({
  815. parentID: msg.id,
  816. messages: msgs,
  817. sessionID: session.id,
  818. auto: true,
  819. })
  820. const all = yield* ssn.messages({ sessionID: session.id })
  821. const last = all.at(-1)
  822. expect(result).toBe("continue")
  823. expect(last?.info.role).toBe("user")
  824. expect(last?.parts[0]).toMatchObject({
  825. type: "text",
  826. synthetic: true,
  827. metadata: { compaction_continue: true },
  828. })
  829. if (last?.parts[0]?.type === "text") {
  830. expect(last.parts[0].text).toContain("Continue if you have next steps")
  831. }
  832. }),
  833. )
  834. itCompaction.instance(
  835. "persists tail_start_id for retained recent turns",
  836. Effect.gen(function* () {
  837. const ssn = yield* SessionNs.Service
  838. const session = yield* ssn.create({})
  839. yield* createUserMessage(session.id, "first")
  840. const keep = yield* createUserMessage(session.id, "second")
  841. yield* createUserMessage(session.id, "third")
  842. yield* createSummaryCompaction(session.id)
  843. const msgs = yield* ssn.messages({ sessionID: session.id })
  844. const parent = msgs.at(-1)?.info.id
  845. expect(parent).toBeTruthy()
  846. yield* SessionCompaction.use.process({
  847. parentID: parent!,
  848. messages: msgs,
  849. sessionID: session.id,
  850. auto: false,
  851. })
  852. const part = yield* readCompactionPart(session.id)
  853. expect(part?.type).toBe("compaction")
  854. expect(part?.tail_start_id).toBe(keep.id)
  855. }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
  856. )
  857. itCompaction.instance(
  858. "shrinks retained tail to fit preserve token budget",
  859. Effect.gen(function* () {
  860. const ssn = yield* SessionNs.Service
  861. const session = yield* ssn.create({})
  862. yield* createUserMessage(session.id, "first")
  863. yield* createUserMessage(session.id, "x".repeat(2_000))
  864. const keep = yield* createUserMessage(session.id, "tiny")
  865. yield* createSummaryCompaction(session.id)
  866. const msgs = yield* ssn.messages({ sessionID: session.id })
  867. const parent = msgs.at(-1)?.info.id
  868. expect(parent).toBeTruthy()
  869. yield* SessionCompaction.use.process({
  870. parentID: parent!,
  871. messages: msgs,
  872. sessionID: session.id,
  873. auto: false,
  874. })
  875. const part = yield* readCompactionPart(session.id)
  876. expect(part?.type).toBe("compaction")
  877. expect(part?.tail_start_id).toBe(keep.id)
  878. }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
  879. )
  880. itCompaction.instance(
  881. "falls back to full summary when even one recent turn exceeds preserve token budget",
  882. () => {
  883. const stub = llm()
  884. let captured = ""
  885. stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages))))
  886. return Effect.gen(function* () {
  887. const ssn = yield* SessionNs.Service
  888. const session = yield* ssn.create({})
  889. yield* createUserMessage(session.id, "first")
  890. yield* createUserMessage(session.id, "y".repeat(2_000))
  891. yield* createSummaryCompaction(session.id)
  892. const msgs = yield* ssn.messages({ sessionID: session.id })
  893. const parent = msgs.at(-1)?.info.id
  894. expect(parent).toBeTruthy()
  895. yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
  896. const part = yield* readCompactionPart(session.id)
  897. expect(part?.type).toBe("compaction")
  898. expect(part?.tail_start_id).toBeUndefined()
  899. expect(captured).toContain("yyyy")
  900. }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) }))
  901. },
  902. { git: true },
  903. )
  904. itCompaction.instance(
  905. "falls back to full summary when retained tail media exceeds preserve token budget",
  906. () => {
  907. const stub = llm()
  908. let captured = ""
  909. stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages))))
  910. return Effect.gen(function* () {
  911. const ssn = yield* SessionNs.Service
  912. const session = yield* ssn.create({})
  913. yield* createUserMessage(session.id, "older")
  914. const recent = yield* createUserMessage(session.id, "recent image turn")
  915. yield* ssn.updatePart({
  916. id: PartID.ascending(),
  917. messageID: recent.id,
  918. sessionID: session.id,
  919. type: "file",
  920. mime: "image/png",
  921. filename: "big.png",
  922. url: `data:image/png;base64,${"a".repeat(4_000)}`,
  923. })
  924. yield* createSummaryCompaction(session.id)
  925. const msgs = yield* ssn.messages({ sessionID: session.id })
  926. const parent = msgs.at(-1)?.info.id
  927. expect(parent).toBeTruthy()
  928. yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
  929. const part = yield* readCompactionPart(session.id)
  930. expect(part?.type).toBe("compaction")
  931. expect(part?.tail_start_id).toBeUndefined()
  932. expect(captured).toContain("recent image turn")
  933. expect(captured).toContain("Attached image/png: big.png")
  934. }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
  935. },
  936. { git: true },
  937. )
  938. itCompaction.instance(
  939. "retains a split turn suffix when a later message fits the preserve token budget",
  940. () => {
  941. const stub = llm()
  942. let captured = ""
  943. stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages))))
  944. return Effect.gen(function* () {
  945. const test = yield* TestInstance
  946. const ssn = yield* SessionNs.Service
  947. const session = yield* ssn.create({})
  948. yield* createUserMessage(session.id, "older")
  949. const recent = yield* createUserMessage(session.id, "recent turn")
  950. const large = yield* createAssistantMessage(session.id, recent.id, test.directory)
  951. yield* ssn.updatePart({
  952. id: PartID.ascending(),
  953. messageID: large.id,
  954. sessionID: session.id,
  955. type: "text",
  956. text: "z".repeat(2_000),
  957. })
  958. const keep = yield* createAssistantMessage(session.id, recent.id, test.directory)
  959. yield* ssn.updatePart({
  960. id: PartID.ascending(),
  961. messageID: keep.id,
  962. sessionID: session.id,
  963. type: "text",
  964. text: "keep tail",
  965. })
  966. yield* createSummaryCompaction(session.id)
  967. const msgs = yield* ssn.messages({ sessionID: session.id })
  968. const parent = msgs.at(-1)?.info.id
  969. expect(parent).toBeTruthy()
  970. yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
  971. const part = yield* readCompactionPart(session.id)
  972. expect(part?.type).toBe("compaction")
  973. expect(part?.tail_start_id).toBe(keep.id)
  974. expect(captured).toContain("zzzz")
  975. expect(captured).not.toContain("keep tail")
  976. const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
  977. expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id])
  978. expect(filtered[1]?.info.role).toBe("assistant")
  979. expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
  980. expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
  981. }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
  982. },
  983. { git: true },
  984. )
  985. itCompaction.instance(
  986. "allows plugins to disable synthetic continue prompt",
  987. Effect.gen(function* () {
  988. const ssn = yield* SessionNs.Service
  989. const session = yield* ssn.create({})
  990. const msg = yield* createUserMessage(session.id, "hello")
  991. const msgs = yield* ssn.messages({ sessionID: session.id })
  992. const result = yield* SessionCompaction.use.process({
  993. parentID: msg.id,
  994. messages: msgs,
  995. sessionID: session.id,
  996. auto: true,
  997. })
  998. const all = yield* ssn.messages({ sessionID: session.id })
  999. const last = all.at(-1)
  1000. expect(result).toBe("continue")
  1001. expect(last?.info.role).toBe("assistant")
  1002. expect(
  1003. all.some(
  1004. (msg) =>
  1005. msg.info.role === "user" &&
  1006. msg.parts.some(
  1007. (part) => part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"),
  1008. ),
  1009. ),
  1010. ).toBe(false)
  1011. }).pipe(withCompaction({ plugin: autocontinue(false) })),
  1012. )
  1013. it.instance(
  1014. "replays the prior user turn on overflow when earlier context exists",
  1015. Effect.gen(function* () {
  1016. const ssn = yield* SessionNs.Service
  1017. const session = yield* ssn.create({})
  1018. yield* createUserMessage(session.id, "root")
  1019. const replay = yield* createUserMessage(session.id, "image")
  1020. yield* ssn.updatePart({
  1021. id: PartID.ascending(),
  1022. messageID: replay.id,
  1023. sessionID: session.id,
  1024. type: "file",
  1025. mime: "image/png",
  1026. filename: "cat.png",
  1027. url: "https://example.com/cat.png",
  1028. })
  1029. const msg = yield* createUserMessage(session.id, "current")
  1030. const msgs = yield* ssn.messages({ sessionID: session.id })
  1031. const result = yield* SessionCompaction.use.process({
  1032. parentID: msg.id,
  1033. messages: msgs,
  1034. sessionID: session.id,
  1035. auto: true,
  1036. overflow: true,
  1037. })
  1038. const last = (yield* ssn.messages({ sessionID: session.id })).at(-1)
  1039. expect(result).toBe("continue")
  1040. expect(last?.info.role).toBe("user")
  1041. expect(last?.parts.some((part) => part.type === "file")).toBe(false)
  1042. expect(
  1043. last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")),
  1044. ).toBe(true)
  1045. }),
  1046. )
  1047. it.instance(
  1048. "falls back to overflow guidance when no replayable turn exists",
  1049. Effect.gen(function* () {
  1050. const ssn = yield* SessionNs.Service
  1051. const session = yield* ssn.create({})
  1052. yield* createUserMessage(session.id, "earlier")
  1053. const msg = yield* createUserMessage(session.id, "current")
  1054. const msgs = yield* ssn.messages({ sessionID: session.id })
  1055. const result = yield* SessionCompaction.use.process({
  1056. parentID: msg.id,
  1057. messages: msgs,
  1058. sessionID: session.id,
  1059. auto: true,
  1060. overflow: true,
  1061. })
  1062. const last = (yield* ssn.messages({ sessionID: session.id })).at(-1)
  1063. expect(result).toBe("continue")
  1064. expect(last?.info.role).toBe("user")
  1065. if (last?.parts[0]?.type === "text") {
  1066. expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit")
  1067. }
  1068. }),
  1069. )
  1070. itCompaction.instance(
  1071. "stops quickly when aborted during retry backoff",
  1072. () => {
  1073. const stub = llm()
  1074. stub.push(
  1075. Stream.fromAsyncIterable(
  1076. {
  1077. async *[Symbol.asyncIterator]() {
  1078. yield LLMEvent.stepStart({ index: 0 })
  1079. throw new APICallError({
  1080. message: "boom",
  1081. url: "https://example.com/v1/chat/completions",
  1082. requestBodyValues: {},
  1083. statusCode: 503,
  1084. responseHeaders: { "retry-after-ms": "10000" },
  1085. responseBody: '{"error":"boom"}',
  1086. isRetryable: true,
  1087. })
  1088. },
  1089. },
  1090. (err) => err,
  1091. ),
  1092. )
  1093. return Effect.gen(function* () {
  1094. const ssn = yield* SessionNs.Service
  1095. const bus = yield* Bus.Service
  1096. const ready = yield* Deferred.make<void>()
  1097. const session = yield* ssn.create({})
  1098. const msg = yield* createUserMessage(session.id, "hello")
  1099. const msgs = yield* ssn.messages({ sessionID: session.id })
  1100. const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
  1101. if (evt.properties.sessionID !== session.id) return
  1102. if (evt.properties.status.type !== "retry") return
  1103. Deferred.doneUnsafe(ready, Effect.void)
  1104. })
  1105. yield* Effect.addFinalizer(() => Effect.sync(off))
  1106. const fiber = yield* SessionCompaction.use
  1107. .process({
  1108. parentID: msg.id,
  1109. messages: msgs,
  1110. sessionID: session.id,
  1111. auto: false,
  1112. })
  1113. .pipe(Effect.forkChild)
  1114. yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
  1115. const start = Date.now()
  1116. yield* Fiber.interrupt(fiber)
  1117. const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
  1118. expect(Exit.isFailure(exit)).toBe(true)
  1119. if (Exit.isFailure(exit)) {
  1120. expect(Cause.hasInterrupts(exit.cause)).toBe(true)
  1121. expect(Date.now() - start).toBeLessThan(250)
  1122. }
  1123. }).pipe(withCompaction({ llm: stub.layer }))
  1124. },
  1125. { git: true },
  1126. )
  1127. itCompaction.instance(
  1128. "does not leave a summary assistant when aborted before processor setup",
  1129. () =>
  1130. Effect.gen(function* () {
  1131. const ready = yield* Deferred.make<void>()
  1132. return yield* Effect.gen(function* () {
  1133. const ssn = yield* SessionNs.Service
  1134. const session = yield* ssn.create({})
  1135. const msg = yield* createUserMessage(session.id, "hello")
  1136. const msgs = yield* ssn.messages({ sessionID: session.id })
  1137. const fiber = yield* SessionCompaction.use
  1138. .process({
  1139. parentID: msg.id,
  1140. messages: msgs,
  1141. sessionID: session.id,
  1142. auto: false,
  1143. })
  1144. .pipe(Effect.forkChild)
  1145. yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
  1146. yield* Fiber.interrupt(fiber)
  1147. const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
  1148. const all = yield* ssn.messages({ sessionID: session.id })
  1149. expect(Exit.isFailure(exit)).toBe(true)
  1150. if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true)
  1151. expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false)
  1152. }).pipe(withCompaction({ plugin: plugin(ready) }))
  1153. }),
  1154. { git: true },
  1155. )
  1156. itCompaction.instance(
  1157. "silently drops reasoning-delta arriving without prior reasoning-start",
  1158. () => {
  1159. // Regression: PR initially auto-created a reasoning Part for orphan deltas (no preceding
  1160. // reasoning-start). Reverted to match dev — drop silently. Pinned here so any future
  1161. // change to processor.ts reasoning-delta handling triggers this test.
  1162. const stub = llm()
  1163. stub.push(
  1164. Stream.make(
  1165. LLMEvent.reasoningDelta({ id: "orphan-1", text: "stray reasoning" }),
  1166. LLMEvent.textStart({ id: "txt-0" }),
  1167. LLMEvent.textDelta({ id: "txt-0", text: "summary" }),
  1168. LLMEvent.textEnd({ id: "txt-0" }),
  1169. LLMEvent.stepFinish({ index: 0, reason: "stop", usage: basicUsage() }),
  1170. LLMEvent.finish({ reason: "stop", usage: basicUsage() }),
  1171. ),
  1172. )
  1173. return Effect.gen(function* () {
  1174. const ssn = yield* SessionNs.Service
  1175. const session = yield* ssn.create({})
  1176. const msg = yield* createUserMessage(session.id, "hello")
  1177. const msgs = yield* ssn.messages({ sessionID: session.id })
  1178. yield* SessionCompaction.use.process({
  1179. parentID: msg.id,
  1180. messages: msgs,
  1181. sessionID: session.id,
  1182. auto: false,
  1183. })
  1184. const summary = (yield* ssn.messages({ sessionID: session.id })).find(
  1185. (item) => item.info.role === "assistant" && item.info.summary,
  1186. )
  1187. expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false)
  1188. // Sanity: the text part still got through.
  1189. expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true)
  1190. }).pipe(withCompaction({ llm: stub.layer }))
  1191. },
  1192. { git: true },
  1193. )
  1194. itCompaction.instance(
  1195. "does not allow tool calls while generating the summary",
  1196. () => {
  1197. const stub = llm()
  1198. stub.push(
  1199. Stream.make(
  1200. LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }),
  1201. LLMEvent.stepFinish({
  1202. index: 0,
  1203. reason: "tool-calls",
  1204. usage: basicUsage(),
  1205. }),
  1206. LLMEvent.finish({
  1207. reason: "tool-calls",
  1208. usage: basicUsage(),
  1209. }),
  1210. ),
  1211. )
  1212. return Effect.gen(function* () {
  1213. const ssn = yield* SessionNs.Service
  1214. const session = yield* ssn.create({})
  1215. const msg = yield* createUserMessage(session.id, "hello")
  1216. const msgs = yield* ssn.messages({ sessionID: session.id })
  1217. yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false })
  1218. const summary = (yield* ssn.messages({ sessionID: session.id })).find(
  1219. (item) => item.info.role === "assistant" && item.info.summary,
  1220. )
  1221. expect(summary?.info.role).toBe("assistant")
  1222. expect(summary?.parts.some((part) => part.type === "tool")).toBe(false)
  1223. }).pipe(withCompaction({ llm: stub.layer }))
  1224. },
  1225. { git: true },
  1226. )
  1227. itCompaction.instance(
  1228. "summarizes only the head while keeping recent tail out of summary input",
  1229. () => {
  1230. const stub = llm()
  1231. let captured = ""
  1232. stub.push(
  1233. reply("summary", (input) => {
  1234. captured = JSON.stringify(input.messages)
  1235. }),
  1236. )
  1237. return Effect.gen(function* () {
  1238. const ssn = yield* SessionNs.Service
  1239. const session = yield* ssn.create({})
  1240. yield* createUserMessage(session.id, "older context")
  1241. yield* createUserMessage(session.id, "keep this turn")
  1242. yield* createUserMessage(session.id, "and this one too")
  1243. yield* createCompactionMarker(session.id)
  1244. const msgs = yield* ssn.messages({ sessionID: session.id })
  1245. const parent = msgs.at(-1)?.info.id
  1246. expect(parent).toBeTruthy()
  1247. yield* SessionCompaction.use.process({
  1248. parentID: parent!,
  1249. messages: msgs,
  1250. sessionID: session.id,
  1251. auto: false,
  1252. })
  1253. expect(captured).toContain("older context")
  1254. expect(captured).not.toContain("keep this turn")
  1255. expect(captured).not.toContain("and this one too")
  1256. expect(captured).not.toContain("What did we do so far?")
  1257. }).pipe(withCompaction({ llm: stub.layer }))
  1258. },
  1259. { git: true },
  1260. )
  1261. itCompaction.instance(
  1262. "anchors repeated compactions with the previous summary",
  1263. () => {
  1264. const stub = llm()
  1265. let captured = ""
  1266. stub.push(reply("summary one"))
  1267. stub.push(
  1268. reply("summary two", (input) => {
  1269. captured = JSON.stringify(input.messages)
  1270. }),
  1271. )
  1272. return Effect.gen(function* () {
  1273. const ssn = yield* SessionNs.Service
  1274. const session = yield* ssn.create({})
  1275. yield* createUserMessage(session.id, "older context")
  1276. yield* createUserMessage(session.id, "keep this turn")
  1277. yield* createCompactionMarker(session.id)
  1278. let msgs = yield* ssn.messages({ sessionID: session.id })
  1279. let parent = msgs.at(-1)?.info.id
  1280. expect(parent).toBeTruthy()
  1281. yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
  1282. yield* createUserMessage(session.id, "latest turn")
  1283. yield* createCompactionMarker(session.id)
  1284. msgs = MessageV2.filterCompacted(MessageV2.stream(session.id))
  1285. parent = msgs.at(-1)?.info.id
  1286. expect(parent).toBeTruthy()
  1287. yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
  1288. expect(captured).toContain("<previous-summary>")
  1289. expect(captured).toContain("summary one")
  1290. expect(captured.match(/summary one/g)?.length).toBe(1)
  1291. expect(captured).toContain("## Constraints & Preferences")
  1292. expect(captured).toContain("## Progress")
  1293. }).pipe(withCompaction({ llm: stub.layer }))
  1294. },
  1295. { git: true },
  1296. )
  1297. itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
  1298. const stub = llm()
  1299. stub.push(reply("summary one"))
  1300. stub.push(reply("summary two"))
  1301. return Effect.gen(function* () {
  1302. const ssn = yield* SessionNs.Service
  1303. const session = yield* ssn.create({})
  1304. const u1 = yield* createUserMessage(session.id, "one")
  1305. const u2 = yield* createUserMessage(session.id, "two")
  1306. const u3 = yield* createUserMessage(session.id, "three")
  1307. yield* createCompactionMarker(session.id)
  1308. let msgs = yield* ssn.messages({ sessionID: session.id })
  1309. let parent = msgs.at(-1)?.info.id
  1310. expect(parent).toBeTruthy()
  1311. yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
  1312. const u4 = yield* createUserMessage(session.id, "four")
  1313. yield* createCompactionMarker(session.id)
  1314. msgs = MessageV2.filterCompacted(MessageV2.stream(session.id))
  1315. parent = msgs.at(-1)?.info.id
  1316. expect(parent).toBeTruthy()
  1317. yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
  1318. const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
  1319. const ids = filtered.map((msg) => msg.info.id)
  1320. expect(ids).not.toContain(u1.id)
  1321. expect(ids).not.toContain(u2.id)
  1322. expect(ids).toContain(u3.id)
  1323. expect(ids).toContain(u4.id)
  1324. expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
  1325. expect(
  1326. filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
  1327. ).toBe(true)
  1328. }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))
  1329. })
  1330. itCompaction.instance(
  1331. "ignores previous summaries when sizing the retained tail",
  1332. Effect.gen(function* () {
  1333. const ssn = yield* SessionNs.Service
  1334. const test = yield* TestInstance
  1335. const session = yield* ssn.create({})
  1336. yield* createUserMessage(session.id, "older")
  1337. const keep = yield* createUserMessage(session.id, "keep this turn")
  1338. const keepReply = yield* createAssistantMessage(session.id, keep.id, test.directory)
  1339. yield* ssn.updatePart({
  1340. id: PartID.ascending(),
  1341. messageID: keepReply.id,
  1342. sessionID: session.id,
  1343. type: "text",
  1344. text: "keep reply",
  1345. })
  1346. yield* createCompactionMarker(session.id)
  1347. const firstCompaction = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id
  1348. expect(firstCompaction).toBeTruthy()
  1349. yield* createSummaryAssistantMessage(session.id, firstCompaction!, test.directory, "summary ".repeat(800))
  1350. const recent = yield* createUserMessage(session.id, "recent turn")
  1351. const recentReply = yield* createAssistantMessage(session.id, recent.id, test.directory)
  1352. yield* ssn.updatePart({
  1353. id: PartID.ascending(),
  1354. messageID: recentReply.id,
  1355. sessionID: session.id,
  1356. type: "text",
  1357. text: "recent reply",
  1358. })
  1359. yield* createCompactionMarker(session.id)
  1360. const msgs = yield* ssn.messages({ sessionID: session.id })
  1361. const parent = msgs.at(-1)?.info.id
  1362. expect(parent).toBeTruthy()
  1363. yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
  1364. const part = yield* readCompactionPart(session.id)
  1365. expect(part?.type).toBe("compaction")
  1366. expect(part?.tail_start_id).toBe(keep.id)
  1367. }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
  1368. )
  1369. })
  1370. describe("util.token.estimate", () => {
  1371. test("estimates tokens from text (4 chars per token)", () => {
  1372. const text = "x".repeat(4000)
  1373. expect(Token.estimate(text)).toBe(1000)
  1374. })
  1375. test("estimates tokens from larger text", () => {
  1376. const text = "y".repeat(20_000)
  1377. expect(Token.estimate(text)).toBe(5000)
  1378. })
  1379. test("returns 0 for empty string", () => {
  1380. expect(Token.estimate("")).toBe(0)
  1381. })
  1382. })
  1383. describe("SessionNs.getUsage", () => {
  1384. test("normalizes standard usage to token format", () => {
  1385. const model = createModel({ context: 100_000, output: 32_000 })
  1386. const result = SessionNs.getUsage({
  1387. model,
  1388. usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
  1389. })
  1390. expect(result.tokens.input).toBe(1000)
  1391. expect(result.tokens.output).toBe(500)
  1392. expect(result.tokens.reasoning).toBe(0)
  1393. expect(result.tokens.cache.read).toBe(0)
  1394. expect(result.tokens.cache.write).toBe(0)
  1395. })
  1396. test("extracts cached tokens to cache.read", () => {
  1397. const model = createModel({ context: 100_000, output: 32_000 })
  1398. const result = SessionNs.getUsage({
  1399. model,
  1400. usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
  1401. })
  1402. expect(result.tokens.input).toBe(800)
  1403. expect(result.tokens.cache.read).toBe(200)
  1404. })
  1405. test("handles anthropic cache write metadata", () => {
  1406. const model = createModel({ context: 100_000, output: 32_000 })
  1407. const result = SessionNs.getUsage({
  1408. model,
  1409. usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
  1410. metadata: {
  1411. anthropic: {
  1412. cacheCreationInputTokens: 300,
  1413. },
  1414. },
  1415. })
  1416. expect(result.tokens.cache.write).toBe(300)
  1417. })
  1418. test("subtracts cached tokens for anthropic provider", () => {
  1419. const model = createModel({ context: 100_000, output: 32_000 })
  1420. // AI SDK v6 normalizes inputTokens to include cached tokens for all providers
  1421. const result = SessionNs.getUsage({
  1422. model,
  1423. usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
  1424. metadata: {
  1425. anthropic: {},
  1426. },
  1427. })
  1428. expect(result.tokens.input).toBe(800)
  1429. expect(result.tokens.cache.read).toBe(200)
  1430. })
  1431. test("separates reasoning tokens from output tokens", () => {
  1432. const model = createModel({ context: 100_000, output: 32_000 })
  1433. const result = SessionNs.getUsage({
  1434. model,
  1435. usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }),
  1436. })
  1437. expect(result.tokens.input).toBe(1000)
  1438. expect(result.tokens.output).toBe(400)
  1439. expect(result.tokens.reasoning).toBe(100)
  1440. expect(result.tokens.total).toBe(1500)
  1441. })
  1442. test("does not double count reasoning tokens in cost", () => {
  1443. const model = createModel({
  1444. context: 100_000,
  1445. output: 32_000,
  1446. cost: {
  1447. input: 0,
  1448. output: 15,
  1449. cache: { read: 0, write: 0 },
  1450. },
  1451. })
  1452. const result = SessionNs.getUsage({
  1453. model,
  1454. usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }),
  1455. })
  1456. expect(result.tokens.output).toBe(750_000)
  1457. expect(result.tokens.reasoning).toBe(250_000)
  1458. expect(result.cost).toBe(15)
  1459. })
  1460. test("handles undefined optional values gracefully", () => {
  1461. const model = createModel({ context: 100_000, output: 32_000 })
  1462. const result = SessionNs.getUsage({
  1463. model,
  1464. usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
  1465. })
  1466. expect(result.tokens.input).toBe(0)
  1467. expect(result.tokens.output).toBe(0)
  1468. expect(result.tokens.reasoning).toBe(0)
  1469. expect(result.tokens.cache.read).toBe(0)
  1470. expect(result.tokens.cache.write).toBe(0)
  1471. expect(Number.isNaN(result.cost)).toBe(false)
  1472. })
  1473. test("calculates cost correctly", () => {
  1474. const model = createModel({
  1475. context: 100_000,
  1476. output: 32_000,
  1477. cost: {
  1478. input: 3,
  1479. output: 15,
  1480. cache: { read: 0.3, write: 3.75 },
  1481. },
  1482. })
  1483. const result = SessionNs.getUsage({
  1484. model,
  1485. usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
  1486. })
  1487. expect(result.cost).toBe(3 + 1.5)
  1488. })
  1489. test("uses matching context cost tier before over-200k fallback", () => {
  1490. const model = createModel({
  1491. context: 1_000_000,
  1492. output: 32_000,
  1493. cost: {
  1494. input: 1,
  1495. output: 2,
  1496. cache: { read: 0.1, write: 0.5 },
  1497. tiers: [
  1498. {
  1499. input: 3,
  1500. output: 4,
  1501. cache: { read: 0.3, write: 1.5 },
  1502. tier: { type: "context", size: 200_000 },
  1503. },
  1504. {
  1505. input: 5,
  1506. output: 6,
  1507. cache: { read: 0.5, write: 2.5 },
  1508. tier: { type: "context", size: 500_000 },
  1509. },
  1510. ],
  1511. experimentalOver200K: {
  1512. input: 100,
  1513. output: 100,
  1514. cache: { read: 100, write: 100 },
  1515. },
  1516. },
  1517. })
  1518. const result = SessionNs.getUsage({
  1519. model,
  1520. usage: usage({
  1521. inputTokens: 650_000,
  1522. outputTokens: 100_000,
  1523. totalTokens: 750_000,
  1524. cacheReadInputTokens: 100_000,
  1525. }),
  1526. })
  1527. expect(result.tokens.input).toBe(550_000)
  1528. expect(result.cost).toBe(2.75 + 0.6 + 0.05)
  1529. })
  1530. test("falls back to over-200k pricing when no cost tier matches", () => {
  1531. const model = createModel({
  1532. context: 1_000_000,
  1533. output: 32_000,
  1534. cost: {
  1535. input: 1,
  1536. output: 2,
  1537. cache: { read: 0.1, write: 0.5 },
  1538. tiers: [
  1539. {
  1540. input: 5,
  1541. output: 6,
  1542. cache: { read: 0.5, write: 2.5 },
  1543. tier: { type: "context", size: 500_000 },
  1544. },
  1545. ],
  1546. experimentalOver200K: {
  1547. input: 3,
  1548. output: 4,
  1549. cache: { read: 0.3, write: 1.5 },
  1550. },
  1551. },
  1552. })
  1553. const result = SessionNs.getUsage({
  1554. model,
  1555. usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }),
  1556. })
  1557. expect(result.cost).toBe(0.9 + 0.4)
  1558. })
  1559. test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])(
  1560. "computes total from components for %s models",
  1561. (npm) => {
  1562. const model = createModel({ context: 100_000, output: 32_000, npm })
  1563. // AI SDK v6: inputTokens includes cached tokens for all providers
  1564. const item = usage({
  1565. inputTokens: 1000,
  1566. outputTokens: 500,
  1567. totalTokens: 1500,
  1568. cacheReadInputTokens: 200,
  1569. })
  1570. if (npm === "@ai-sdk/amazon-bedrock") {
  1571. const result = SessionNs.getUsage({
  1572. model,
  1573. usage: item,
  1574. metadata: {
  1575. bedrock: {
  1576. usage: {
  1577. cacheWriteInputTokens: 300,
  1578. },
  1579. },
  1580. },
  1581. })
  1582. // inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500
  1583. expect(result.tokens.input).toBe(500)
  1584. expect(result.tokens.cache.read).toBe(200)
  1585. expect(result.tokens.cache.write).toBe(300)
  1586. // total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300)
  1587. expect(result.tokens.total).toBe(1500)
  1588. return
  1589. }
  1590. const result = SessionNs.getUsage({
  1591. model,
  1592. usage: item,
  1593. metadata: {
  1594. anthropic: {
  1595. cacheCreationInputTokens: 300,
  1596. },
  1597. },
  1598. })
  1599. // inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500
  1600. expect(result.tokens.input).toBe(500)
  1601. expect(result.tokens.cache.read).toBe(200)
  1602. expect(result.tokens.cache.write).toBe(300)
  1603. // total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300)
  1604. expect(result.tokens.total).toBe(1500)
  1605. },
  1606. )
  1607. test("extracts cache write tokens from vertex metadata key", () => {
  1608. const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
  1609. const result = SessionNs.getUsage({
  1610. model,
  1611. usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
  1612. metadata: {
  1613. vertex: {
  1614. cacheCreationInputTokens: 300,
  1615. },
  1616. },
  1617. })
  1618. expect(result.tokens.input).toBe(500)
  1619. expect(result.tokens.cache.read).toBe(200)
  1620. expect(result.tokens.cache.write).toBe(300)
  1621. })
  1622. })