compaction.test.ts 61 KB

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