compaction.test.ts 69 KB

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