compaction-basic.spec.ts 77 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082
  1. import { describe, expect, expectTypeOf, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { AttachmentId } from '@deepseek-ai/dsh-attachment'
  4. import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic'
  5. import type { BasicCompactionConfig } from '@deepseek-ai/dsh-compaction-basic'
  6. import { selectCompactableRange } from '@deepseek-ai/dsh-compaction-basic/src/region.ts'
  7. import { frameSummary } from '@deepseek-ai/dsh-compaction-basic/src/summarizer.ts'
  8. import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compaction-basic/src/summarizer.ts'
  9. import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction'
  10. import {
  11. resolveCompactSpec,
  12. resolveConfig,
  13. resolveTargetPolicy,
  14. } from '@deepseek-ai/dsh-compaction-basic/src/config.ts'
  15. import type { CompactionResult } from '@deepseek-ai/dsh-compaction'
  16. import LlmRuntime, { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, createToolResultMessage, LlmAdapter , createMessage } from '@deepseek-ai/dsh-llm'
  17. import type {
  18. ContentBlock,
  19. GenerateOptions,
  20. LlmFailure,
  21. LlmResolvedModelInfo,
  22. Message,
  23. StreamChunk,
  24. TokenUsage,
  25. } from '@deepseek-ai/dsh-llm'
  26. import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
  27. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  28. import TokenMeter from '@deepseek-ai/dsh-token-meter'
  29. import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent'
  30. import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner'
  31. const SIGNAL = new AbortController().signal
  32. const MODEL = 'test-model'
  33. class ContextAdapter extends LlmAdapter {
  34. constructor(private readonly contextWindow: number) {
  35. super()
  36. }
  37. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  38. return Promise.resolve({
  39. provider,
  40. id: model,
  41. name: model,
  42. context: { contextWindow: this.contextWindow },
  43. })
  44. }
  45. override async * stream(): AsyncIterable<StreamChunk> {
  46. yield { type: 'finish', reason: { kind: 'stop' } }
  47. }
  48. }
  49. class RoutedContextAdapter extends LlmAdapter {
  50. constructor(private readonly windows: Readonly<Record<string, number>>) {
  51. super()
  52. }
  53. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  54. const contextWindow = this.windows[provider]
  55. return Promise.resolve({
  56. provider,
  57. id: model,
  58. name: model,
  59. ...contextWindow === undefined ? {} : { context: { contextWindow } },
  60. })
  61. }
  62. override async * stream(): AsyncIterable<StreamChunk> {
  63. yield { type: 'finish', reason: { kind: 'stop' } }
  64. }
  65. }
  66. function createContext(contextWindow = 1_000): Context {
  67. const ctx = new Context()
  68. void new LlmRuntime(ctx)
  69. // The registry is a required injection of TokenMeter (its three
  70. // projection units register in the constructor); mount it synchronously.
  71. new SessionProjectionRegistry(ctx)
  72. void new TokenMeter(ctx)
  73. ctx.llm.registerAdapter([MODEL, 'actual', 'unlisted-provider'], new ContextAdapter(contextWindow))
  74. return ctx
  75. }
  76. function agent(session: Session, model?: string): Agent {
  77. return {
  78. session,
  79. options: model === undefined ? {} : { provider: model, model },
  80. } as Agent
  81. }
  82. /** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
  83. function summarizedText(input: SummarizationInput): string {
  84. const collect = (blocks: readonly ContentBlock[]): string =>
  85. blocks.map(block =>
  86. block.type === 'text' ? block.text
  87. : block.type === 'tool-result' ? collect(block.content)
  88. : '').join('\n')
  89. return input.messages.map(message => collect(message.content)).join('\n')
  90. }
  91. /** A minimal replayed prefix carrying one user message of the given text. */
  92. function promptInput(text: string): SummarizationInput {
  93. return { messages: [createUserMessage({
  94. content: [{ type: 'text', text }],
  95. source: { kind: 'plugin', plugin: 'test' },
  96. })] }
  97. }
  98. /** Closed two-message turns followed by one open turn for durable compaction events. */
  99. function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
  100. const session = Session.create(SessionId(`conversation-${turns}`))
  101. for (let turn = 1; turn <= turns; turn += 1) {
  102. session.append('turn/start', { turn })
  103. session.append('user/message', createUserMessage({
  104. content: [{ type: 'text', text: `${text} user ${turn}` }],
  105. source: { kind: 'user' },
  106. }), { surfaceOp: 'append' })
  107. session.append('step/start', { turn, step: 1 })
  108. if (turn === 1) {
  109. session.append('request/header', {
  110. header: { config: { provider: MODEL, model: MODEL } },
  111. reason: 'initial',
  112. })
  113. }
  114. session.append('assistant/message', {
  115. stream: [],
  116. turn,
  117. step: 1,
  118. message: createMessage({
  119. role: 'assistant',
  120. content: [{ type: 'text', text: `${text} assistant ${turn}` }],
  121. source: {
  122. kind: 'model',
  123. ...{ provider: MODEL, model: MODEL },
  124. },
  125. }),
  126. }, { surfaceOp: 'append' })
  127. session.append('step/end', { turn, step: 1 })
  128. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  129. }
  130. session.append('turn/start', {
  131. turn: turns + 1,
  132. })
  133. return session
  134. }
  135. function toolConversation(): Session {
  136. const session = Session.create(SessionId('tools'))
  137. for (let turn = 1; turn <= 3; turn += 1) {
  138. const callId = ToolCallId(`call-${turn}`)
  139. session.append('turn/start', { turn })
  140. session.append('user/message', createUserMessage({
  141. content: [{ type: 'text', text: `request ${turn} `.repeat(300) }],
  142. source: { kind: 'user' },
  143. }), { surfaceOp: 'append' })
  144. session.append('step/start', { turn, step: 1 })
  145. if (turn === 1) {
  146. session.append('request/header', {
  147. header: { config: { provider: MODEL, model: MODEL } },
  148. reason: 'initial',
  149. })
  150. }
  151. session.append('assistant/message', {
  152. stream: [],
  153. turn,
  154. step: 1,
  155. message: createMessage({
  156. role: 'assistant',
  157. content: [
  158. { type: 'text', text: `calling ${turn} `.repeat(300) },
  159. { type: 'tool-call', id: callId, name: 'read', arguments: '{}' },
  160. ],
  161. source: {
  162. kind: 'model',
  163. ...{ provider: MODEL, model: MODEL },
  164. },
  165. }),
  166. }, { surfaceOp: 'append' })
  167. session.append('tool/call', { turn, step: 1, callId, name: 'read', arguments: '{}' })
  168. session.append('tool/result', {
  169. turn,
  170. step: 1,
  171. message: createToolResultMessage({
  172. callId,
  173. content: [{ type: 'text', text: `result ${turn} `.repeat(300) }],
  174. isError: false,
  175. }),
  176. }, { surfaceOp: 'append' })
  177. session.append('step/end', { turn, step: 1 })
  178. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  179. }
  180. session.append('turn/start', { turn: 4 })
  181. return session
  182. }
  183. /** One closed routed tool step followed by an open turn for rewrite events. */
  184. function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
  185. const session = Session.create(SessionId(`oversized-tool-${chars}`))
  186. const callId = ToolCallId('oversized')
  187. session.append('turn/start', { turn: 1 })
  188. if (withCompactablePrompt) {
  189. session.append('user/message', createUserMessage({
  190. content: [{ type: 'text', text: 'older history '.repeat(200) }],
  191. source: { kind: 'user' },
  192. }), { surfaceOp: 'append' })
  193. }
  194. session.append('step/start', { turn: 1, step: 1 })
  195. session.append('request/header', {
  196. header: { config: { provider: MODEL, model: MODEL } },
  197. reason: 'initial',
  198. })
  199. session.append('assistant/message', {
  200. stream: [],
  201. turn: 1,
  202. step: 1,
  203. message: createMessage({
  204. role: 'assistant',
  205. content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
  206. source: {
  207. kind: 'model',
  208. ...{ provider: MODEL, model: MODEL },
  209. },
  210. }),
  211. }, { surfaceOp: 'append' })
  212. session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
  213. session.append('tool/result', {
  214. turn: 1,
  215. step: 1,
  216. message: createToolResultMessage({
  217. callId,
  218. content: [{ type: 'text', text: 'X'.repeat(chars) }],
  219. isError: false,
  220. }),
  221. meta: { presentation: 'preserved' },
  222. }, { surfaceOp: 'append' })
  223. session.append('step/end', { turn: 1, step: 1 })
  224. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  225. session.append('turn/start', { turn: 2 })
  226. return session
  227. }
  228. class TestCompactionEngine extends BasicCompactionEngine {
  229. summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
  230. rawOutput: ContentBlock[] | undefined
  231. usage: TokenUsage | undefined
  232. summaryProvider = 'summary-provider'
  233. summaryModel = 'summary-model'
  234. error: unknown
  235. mutateDuringSummary: (() => void) | undefined
  236. calls: Array<{ input: SummarizationInput; signal: AbortSignal | undefined }> = []
  237. override async summarize(
  238. input: SummarizationInput,
  239. _agent: Agent,
  240. signal?: AbortSignal,
  241. ): Promise<{
  242. summary: ContentBlock[]
  243. rawOutput?: ContentBlock[]
  244. provider: string
  245. model: string
  246. maxTokens?: number
  247. usage?: TokenUsage
  248. }> {
  249. this.calls.push({ input, signal })
  250. this.mutateDuringSummary?.()
  251. if (this.error !== undefined) throw this.error
  252. return {
  253. summary: this.summary,
  254. ...this.rawOutput === undefined ? {} : { rawOutput: this.rawOutput },
  255. provider: this.summaryProvider,
  256. model: this.summaryModel,
  257. maxTokens: 123,
  258. ...this.usage === undefined ? {} : { usage: this.usage },
  259. }
  260. }
  261. }
  262. function service(
  263. config: BasicCompactionConfig = { auto: false },
  264. ctx = createContext(),
  265. ): TestCompactionEngine {
  266. return new TestCompactionEngine(ctx, config)
  267. }
  268. async function compactIfNeeded(
  269. compact: BasicCompactionEngine,
  270. session: Session,
  271. trigger: 'pressure' | 'context-overflow' = 'pressure',
  272. model: string | undefined = MODEL,
  273. ): Promise<CompactionResult | null> {
  274. return compact.compactIfNeeded(agent(session, model), trigger, SIGNAL)
  275. }
  276. describe('compact configuration and defaults', () => {
  277. it('uses low-friction service-wide defaults', () => {
  278. const resolved = resolveConfig({})
  279. expect(resolved).toEqual({
  280. thresholdRatio: 0.8,
  281. retainRatio: 0.16,
  282. summarizationProvider: '',
  283. summarizationModel: '',
  284. maxTokens: 8192,
  285. compactionRetries: 1,
  286. maxOverflowRetries: 1,
  287. modelPolicies: [],
  288. auto: true,
  289. })
  290. expect(Object.isFrozen(resolved)).toBe(true)
  291. })
  292. it('resolves threshold and retention overrides independently', () => {
  293. const thresholdOnly = resolveConfig({
  294. thresholdRatio: 0.5,
  295. })
  296. expect(thresholdOnly).toMatchObject({
  297. thresholdRatio: 0.5,
  298. retainRatio: 0.16,
  299. })
  300. const retentionOnly = resolveConfig({
  301. retainTokens: 70,
  302. })
  303. expect(retentionOnly).toMatchObject({
  304. thresholdRatio: 0.8,
  305. retainTokens: 70,
  306. })
  307. expect(retentionOnly).not.toHaveProperty('retainRatio')
  308. })
  309. it('merges exact provider/model policy overrides and scales ratios per model', () => {
  310. const config = resolveConfig({
  311. thresholdRatio: 0.8,
  312. retainRatio: 0.1,
  313. modelPolicies: [{
  314. provider: 'small-provider',
  315. model: 'shared-id',
  316. thresholdRatio: 0.5,
  317. retainTokens: 120,
  318. }],
  319. })
  320. const small = resolveTargetPolicy(config, {
  321. provider: 'small-provider',
  322. model: 'shared-id',
  323. })
  324. const otherProvider = resolveTargetPolicy(config, {
  325. provider: 'large-provider',
  326. model: 'shared-id',
  327. })
  328. expect(resolveCompactSpec(small, 1_000)).toMatchObject({
  329. thresholdTokens: 500,
  330. retainTokens: 120,
  331. })
  332. expect(resolveCompactSpec(otherProvider, 2_000)).toMatchObject({
  333. thresholdTokens: 1_600,
  334. retainTokens: 200,
  335. })
  336. const ratioOverride = resolveTargetPolicy(resolveConfig({
  337. retainTokens: 200,
  338. modelPolicies: [{
  339. provider: 'ratio-provider',
  340. model: 'ratio-model',
  341. thresholdRatio: 0.6,
  342. retainRatio: 0.2,
  343. summarizationProvider: 'summary-provider',
  344. summarizationModel: 'summary-model',
  345. maxTokens: 512,
  346. compactionRetries: 2,
  347. maxOverflowRetries: 3,
  348. }],
  349. }), { provider: 'ratio-provider', model: 'ratio-model' })
  350. expect(resolveCompactSpec(ratioOverride, 2_000)).toMatchObject({
  351. thresholdTokens: 1_200,
  352. retainTokens: 400,
  353. summarizationProvider: 'summary-provider',
  354. summarizationModel: 'summary-model',
  355. maxTokens: 512,
  356. compactionRetries: 2,
  357. maxOverflowRetries: 3,
  358. })
  359. })
  360. it('inherits, clears, and replaces the summarization target as a pair', () => {
  361. const config = resolveConfig({
  362. summarizationProvider: 'default-provider',
  363. summarizationModel: 'default-model',
  364. modelPolicies: [
  365. { provider: 'inherit-provider', model: MODEL },
  366. {
  367. provider: 'clear-provider',
  368. model: MODEL,
  369. summarizationProvider: '',
  370. summarizationModel: '',
  371. },
  372. {
  373. provider: 'replace-provider',
  374. model: MODEL,
  375. summarizationProvider: 'replacement-provider',
  376. summarizationModel: 'replacement-model',
  377. },
  378. ],
  379. })
  380. expect(resolveTargetPolicy(config, { provider: 'inherit-provider', model: MODEL }))
  381. .toMatchObject({
  382. summarizationProvider: 'default-provider',
  383. summarizationModel: 'default-model',
  384. })
  385. expect(resolveTargetPolicy(config, { provider: 'clear-provider', model: MODEL }))
  386. .toMatchObject({ summarizationProvider: '', summarizationModel: '' })
  387. expect(resolveTargetPolicy(config, { provider: 'replace-provider', model: MODEL }))
  388. .toMatchObject({
  389. summarizationProvider: 'replacement-provider',
  390. summarizationModel: 'replacement-model',
  391. })
  392. })
  393. it('validates common values and pressure-policy invariants', () => {
  394. const bad = [
  395. [{ maxTokens: 0 }, /maxTokens/],
  396. [{ compactionRetries: -1 }, /compactionRetries/],
  397. [{ maxOverflowRetries: -1 }, /maxOverflowRetries/],
  398. [{ auto: 'yes' }, /auto must be a boolean/],
  399. [{ summarizationProvider: 1 }, /summarizationProvider must be a string/],
  400. [{ summarizationModel: 1 }, /summarizationModel must be a string/],
  401. [{ summarizationProvider: MODEL }, /must be set together/],
  402. [{ summarizationModel: MODEL }, /must be set together/],
  403. [{ summarizationProvider: '' }, /must be set together/],
  404. [{ summarizationModel: '' }, /must be set together/],
  405. [{ thresholdRatio: 0 }, /number in \(0, 1\]/],
  406. [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
  407. [{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/],
  408. [{ thresholdRatio: 0.1 }, /retainRatio \(0.16\) must be less than the resolved thresholdRatio \(0.1\)/],
  409. [{ retainTokens: -1 }, /non-negative integer/],
  410. [{ retainRatio: 0.2, retainTokens: 100 }, /mutually exclusive/],
  411. [{ modelPolicies: {} }, /modelPolicies must be an array/],
  412. [{ modelPolicies: [1] }, /modelPolicies\[0\] must be an object/],
  413. [{ modelPolicies: [null] }, /modelPolicies\[0\] must be an object/],
  414. [{ modelPolicies: [[]] }, /modelPolicies\[0\] must be an object/],
  415. [{ modelPolicies: [{ provider: 1, model: MODEL }] }, /provider must be a non-empty string/],
  416. [{ modelPolicies: [{ provider: '', model: MODEL }] }, /provider must be a non-empty string/],
  417. [{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/],
  418. [{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/],
  419. [{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/],
  420. [{
  421. summarizationProvider: 'default-provider',
  422. summarizationModel: 'default-model',
  423. modelPolicies: [{ provider: MODEL, model: MODEL, summarizationModel: '' }],
  424. }, /modelPolicies\[0\].*must be set together/],
  425. [{
  426. summarizationProvider: 'default-provider',
  427. summarizationModel: 'default-model',
  428. modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: '' }],
  429. }, /modelPolicies\[0\].*must be set together/],
  430. [{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/],
  431. [
  432. { modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] },
  433. /modelPolicies\[0\]: retainRatio \(0.16\).*thresholdRatio \(0.1\)/,
  434. ],
  435. [
  436. { modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.9 }] },
  437. /modelPolicies\[0\]: retainRatio \(0.9\).*thresholdRatio \(0.8\)/,
  438. ],
  439. [{ modelPolicies: [{ provider: MODEL, model: MODEL }, { provider: MODEL, model: MODEL }] }, /duplicate model policy/],
  440. [{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactionConfig: unknown key "models"/],
  441. [{ thresholdRato: 0.5 }, /BasicCompactionConfig: unknown key "thresholdRato"/],
  442. ] as Array<[unknown, RegExp]>
  443. for (const [config, pattern] of bad) {
  444. expect(() => resolveConfig(config as BasicCompactionConfig)).toThrow(pattern)
  445. }
  446. const invalidPressure = resolveTargetPolicy(resolveConfig({
  447. thresholdRatio: 0.5,
  448. retainTokens: 500,
  449. }), { provider: MODEL, model: MODEL })
  450. expect(() => resolveCompactSpec(invalidPressure, 1_000)).toThrow(/less than threshold/)
  451. expect(() => resolveCompactSpec(invalidPressure, 1.5)).toThrow(/positive integer/)
  452. expect(() => resolveCompactSpec(invalidPressure, 0)).toThrow(/positive integer/)
  453. })
  454. })
  455. describe('pressure measurement and retention', () => {
  456. const compactConfig: BasicCompactionConfig = {
  457. auto: false,
  458. thresholdRatio: 0.5,
  459. retainTokens: 180,
  460. }
  461. it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
  462. const compact = service(compactConfig)
  463. const session = Session.create(SessionId('headerless'))
  464. session.append('turn/start', { turn: 1 })
  465. await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL))
  466. .resolves.toBeNull()
  467. expect(compact.calls).toHaveLength(0)
  468. })
  469. it('meters an unlisted model when its provider adapter supplies context metadata', async () => {
  470. const compact = service(compactConfig)
  471. const session = conversation()
  472. session.append('request/header', {
  473. header: { config: { provider: 'unlisted-provider', model: 'unlisted-model' } },
  474. reason: 'resume',
  475. })
  476. await expect(compactIfNeeded(compact, session))
  477. .resolves.not.toBeNull()
  478. })
  479. it('forwards turn cancellation to proactive model metadata resolution', async () => {
  480. const ctx = createContext()
  481. const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo')
  482. const compact = service(compactConfig, ctx)
  483. const session = conversation()
  484. const signal = new AbortController().signal
  485. await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', signal))
  486. .resolves.not.toBeNull()
  487. expect(resolveModelInfo).toHaveBeenCalledWith(MODEL, MODEL, signal)
  488. })
  489. it('re-resolves capacity after a same-model-id provider switch in one session', async () => {
  490. const ctx = new Context()
  491. void new LlmRuntime(ctx)
  492. new SessionProjectionRegistry(ctx)
  493. void new TokenMeter(ctx)
  494. ctx.llm.registerAdapter(['large', 'small'], new RoutedContextAdapter({
  495. large: 10_000,
  496. small: 1_000,
  497. }))
  498. const compact = service({
  499. auto: false,
  500. thresholdRatio: 0.5,
  501. retainRatio: 0.1,
  502. }, ctx)
  503. const session = conversation(4)
  504. session.append('request/header', {
  505. header: { config: { provider: 'large', model: 'shared-id' } },
  506. reason: 'resume',
  507. })
  508. await expect(compactIfNeeded(compact, session)).resolves.toBeNull()
  509. session.append('request/header', {
  510. header: { config: { provider: 'small', model: 'shared-id' } },
  511. reason: 'change',
  512. })
  513. await expect(compactIfNeeded(compact, session)).resolves.not.toBeNull()
  514. })
  515. it('requires capacity only for proactive pressure, not provider-confirmed overflow', async () => {
  516. const ctx = new Context()
  517. void new LlmRuntime(ctx)
  518. new SessionProjectionRegistry(ctx)
  519. void new TokenMeter(ctx)
  520. ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000))
  521. vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
  522. provider,
  523. id: model,
  524. name: model,
  525. }))
  526. const compact = service(compactConfig, ctx)
  527. const session = conversation(4)
  528. session.append('request/header', {
  529. header: { config: { provider: 'unknown-context', model: 'model' } },
  530. reason: 'resume',
  531. })
  532. await expect(compactIfNeeded(compact, session, 'pressure'))
  533. .rejects.toThrow(/no context capacity for unknown-context\/model/)
  534. await expect(compactIfNeeded(compact, session, 'context-overflow'))
  535. .resolves.not.toBeNull()
  536. })
  537. it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
  538. const compact = service(compactConfig)
  539. const session = Session.create(SessionId('single-tool-pair'))
  540. const callId = ToolCallId('single-call')
  541. session.append('turn/start', { turn: 1 })
  542. session.append('step/start', { turn: 1, step: 1 })
  543. session.append('request/header', {
  544. header: { config: { provider: MODEL, model: MODEL } },
  545. reason: 'initial',
  546. })
  547. session.append('assistant/message', {
  548. stream: [],
  549. turn: 1,
  550. step: 1,
  551. message: createMessage({
  552. role: 'assistant',
  553. content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
  554. source: {
  555. kind: 'model',
  556. ...{ provider: MODEL, model: MODEL },
  557. },
  558. }),
  559. }, { surfaceOp: 'append' })
  560. session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
  561. session.append('tool/result', {
  562. turn: 1,
  563. step: 1,
  564. message: createToolResultMessage({
  565. callId,
  566. content: [{ type: 'text', text: 'result' }],
  567. isError: false,
  568. }),
  569. }, { surfaceOp: 'append' })
  570. session.append('step/end', { turn: 1, step: 1 })
  571. const generation = session.surface.replaceGeneration
  572. await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull()
  573. expect(session.surface.replaceGeneration).toBe(generation)
  574. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
  575. })
  576. it('does nothing below threshold and compacts a priced head above threshold', async () => {
  577. const compact = service(compactConfig)
  578. expect(await compactIfNeeded(compact, conversation(2))).toBeNull()
  579. const session = conversation(4)
  580. const result = await compactIfNeeded(compact, session)
  581. expect(result).not.toBeNull()
  582. expect(result?.shadowedSeqs.length).toBeGreaterThan(2)
  583. expect(session.surface.nodes.length).toBeLessThan(8)
  584. })
  585. it('counts the durable routed request envelope without putting it on the surface', async () => {
  586. const compact = service({
  587. auto: false,
  588. thresholdRatio: 0.9,
  589. retainTokens: 50,
  590. })
  591. const session = conversation(2, 'x'.repeat(600))
  592. expect(await compactIfNeeded(compact, session)).toBeNull()
  593. session.append('request/header', {
  594. header: {
  595. config: { provider: MODEL, model: MODEL },
  596. system: 's'.repeat(2_000),
  597. },
  598. reason: 'resume',
  599. })
  600. const result = await compactIfNeeded(compact, session)
  601. expect(result).not.toBeNull()
  602. })
  603. it('uses the latest logged request envelope without an AgentOptions override', async () => {
  604. const ctx = createContext()
  605. const compact = service({
  606. auto: false,
  607. thresholdRatio: 0.5,
  608. retainTokens: 180,
  609. }, ctx)
  610. const session = conversation(4)
  611. session.append('request/header', {
  612. header: { config: { provider: 'actual', model: 'actual' } },
  613. reason: 'initial',
  614. })
  615. const measure = vi.spyOn(ctx.tokenMeter, 'measure')
  616. const result = await compactIfNeeded(compact, session, 'pressure', 'fallback')
  617. expect(result).not.toBeNull()
  618. expect(session.requestHeader()?.config.model).toBe('actual')
  619. expect(measure.mock.calls[0]).toEqual([session])
  620. })
  621. it('declines when envelope pressure is high but the surface has no compactable range', async () => {
  622. const compact = service(compactConfig)
  623. const empty = Session.create(SessionId('empty'))
  624. empty.append('turn/start', { turn: 1 })
  625. empty.append('request/header', {
  626. header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
  627. reason: 'initial',
  628. })
  629. expect(await compactIfNeeded(compact, empty)).toBeNull()
  630. const retained = conversation(1)
  631. retained.append('request/header', {
  632. header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
  633. reason: 'resume',
  634. })
  635. expect(await compactIfNeeded(compact, retained)).toBeNull()
  636. })
  637. it('uses one unified measurement for each pressure-and-retention decision', async () => {
  638. const ctx = createContext()
  639. const compact = service(compactConfig, ctx)
  640. const measure = vi.spyOn(ctx.tokenMeter, 'measure')
  641. const stop = new Error('stop after first decision')
  642. vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop)
  643. await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop)
  644. expect(measure).toHaveBeenCalledTimes(1)
  645. })
  646. it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
  647. const compact = service({
  648. auto: false,
  649. compactionRetries: 0,
  650. thresholdRatio: 0.3,
  651. retainTokens: 180,
  652. })
  653. compact.summary = Array.from({ length: 7 }, (_, index) => ({
  654. type: 'text',
  655. text: `summary ${index}`,
  656. }))
  657. await expect(compactIfNeeded(compact, conversation(4)))
  658. .rejects.toThrow(/still above threshold after 1 compaction attempts/)
  659. })
  660. it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => {
  661. const compact = service({
  662. auto: false,
  663. thresholdRatio: 0.8,
  664. retainTokens: 80,
  665. }, createContext(4_000))
  666. const session = toolConversation()
  667. const result = await compactIfNeeded(compact, session)
  668. expect(result).not.toBeNull()
  669. const messages = session.deriveMessages()
  670. const calls = new Set<string>()
  671. for (const message of messages) {
  672. for (const block of message.content) {
  673. if (block.type === 'tool-call') calls.add(block.id)
  674. if (block.type === 'tool-result') expect(calls.has(block.toolCallId)).toBe(true)
  675. }
  676. }
  677. })
  678. it('rejects a priced surface that is not the current positional surface', () => {
  679. const ctx = createContext()
  680. const session = conversation(2)
  681. const priced = ctx.tokenMeter.measure(session)
  682. expect(() => selectCompactableRange(session, {
  683. ...priced,
  684. nodes: priced.nodes.slice(1),
  685. }, 1)).toThrow(/does not match/)
  686. })
  687. it('declines when rounding a cut would consume the only tool pair', () => {
  688. const ctx = createContext()
  689. const session = Session.create(SessionId('one-tool-pair'))
  690. const callId = ToolCallId('only')
  691. session.append('turn/start', { turn: 1 })
  692. session.append('step/start', { turn: 1, step: 1 })
  693. session.append('assistant/message', {
  694. stream: [],
  695. turn: 1,
  696. step: 1,
  697. message: createMessage({
  698. role: 'assistant',
  699. content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
  700. source: {
  701. kind: 'model',
  702. ...{ provider: MODEL, model: MODEL },
  703. },
  704. }),
  705. }, { surfaceOp: 'append' })
  706. session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
  707. session.append('tool/result', {
  708. turn: 1,
  709. step: 1,
  710. message: createToolResultMessage({
  711. callId,
  712. content: [{ type: 'text', text: 'result' }],
  713. isError: false,
  714. }),
  715. }, { surfaceOp: 'append' })
  716. session.append('step/end', { turn: 1, step: 1 })
  717. const priced = ctx.tokenMeter.measure(session)
  718. expect(selectCompactableRange(session, priced, 1)).toBeNull()
  719. })
  720. })
  721. describe('optional model-free tool-result pruning', () => {
  722. const pruneConfig = { thresholdChars: 100, headChars: 20, tailChars: 10 }
  723. it('does not prune a below-pressure session opportunistically', async () => {
  724. const ctx = createContext(10_000)
  725. const prune = new ToolResultPruner(ctx, pruneConfig)
  726. const compact = new TestCompactionEngine(ctx, {
  727. auto: false,
  728. thresholdRatio: 0.8,
  729. retainTokens: 100,
  730. })
  731. const session = oversizedToolResult()
  732. const pruneSession = vi.spyOn(prune, 'pruneSession')
  733. expect(await compactIfNeeded(compact, session)).toBeNull()
  734. expect(pruneSession).not.toHaveBeenCalled()
  735. expect(compact.calls).toHaveLength(0)
  736. expect(session.surface.replaceGeneration).toBe(0)
  737. })
  738. it('skips LLM summarization when pruning alone clears pressure', async () => {
  739. const ctx = createContext(1_000)
  740. void new ToolResultPruner(ctx, pruneConfig)
  741. const compact = new TestCompactionEngine(ctx, {
  742. auto: false,
  743. thresholdRatio: 0.5,
  744. retainTokens: 50,
  745. })
  746. const session = oversizedToolResult()
  747. expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(500)
  748. expect(await compactIfNeeded(compact, session)).toBeNull()
  749. expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(500)
  750. expect(compact.calls).toHaveLength(0)
  751. expect(session.surface.replaceGeneration).toBe(1)
  752. })
  753. it('summarizes the pruned surface when pruning is insufficient', async () => {
  754. const ctx = createContext(2_000)
  755. void new ToolResultPruner(ctx, pruneConfig)
  756. const compact = new TestCompactionEngine(ctx, {
  757. auto: false,
  758. thresholdRatio: 0.5,
  759. retainTokens: 50,
  760. })
  761. const session = toolConversation()
  762. expect(await compactIfNeeded(compact, session)).not.toBeNull()
  763. expect(compact.calls).toHaveLength(1)
  764. expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
  765. expect(summarizedText(compact.calls[0]!.input)).not.toContain('result 1 '.repeat(300))
  766. })
  767. it('retains the original compaction-basic behavior without the optional plugin', async () => {
  768. const ctx = createContext(2_000)
  769. const compact = new TestCompactionEngine(ctx, {
  770. auto: false,
  771. thresholdRatio: 0.5,
  772. retainTokens: 50,
  773. })
  774. const session = oversizedToolResult(3_000, true)
  775. expect(await compactIfNeeded(compact, session)).not.toBeNull()
  776. expect(compact.calls).toHaveLength(1)
  777. const original = session.snapshotEvents().find(event => event.type === 'tool/result')
  778. expect(original?.type === 'tool/result' && original.data.message.content[0].content[0])
  779. .toEqual({ type: 'text', text: 'X'.repeat(3_000) })
  780. expect(session.snapshotEvents().filter(event =>
  781. event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
  782. })
  783. })
  784. describe('compaction region transaction', () => {
  785. it('lands a framed, replayable checkpoint with exact source seqs and token price', async () => {
  786. const compact = service()
  787. compact.rawOutput = [
  788. { type: 'reasoning', text: 'private compact thought' },
  789. ...compact.summary,
  790. ]
  791. compact.usage = { inputTokens: 40, outputTokens: 5 }
  792. const session = conversation(3)
  793. const before = [...session.surface.nodes]
  794. const result = await compact.compactRegion(
  795. before[0]!,
  796. before[3]!,
  797. agent(session, MODEL),
  798. SIGNAL,
  799. )
  800. expect(result.shadowedSeqs).toEqual(before.slice(0, 4))
  801. expect(result.shadowedTokenCount).toBeGreaterThan(0)
  802. expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
  803. expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1')
  804. const summary = session.snapshotEvents().findLast(event => event.type === 'compaction/summary')
  805. expect(summary?.data).toMatchObject({
  806. shadowedSeqs: result.shadowedSeqs,
  807. shadowedTokenCount: result.shadowedTokenCount,
  808. provider: 'summary-provider',
  809. model: 'summary-model',
  810. maxTokens: 123,
  811. rawOutput: compact.rawOutput,
  812. usage: compact.usage,
  813. })
  814. expect(summary?.data).not.toHaveProperty('llmStreamCall')
  815. const head = session.deriveMessages()[0]!
  816. expect(head.content[0]?.type).toBe('text')
  817. expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
  818. expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
  819. const replay = Session.create(SessionId('replay'), session.snapshotEvents())
  820. expect(replay.deriveMessages()).toEqual(session.deriveMessages())
  821. })
  822. it('replays the latest routed header so the summarizer reuses the cache', async () => {
  823. const compact = service()
  824. const session = conversation(3)
  825. const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
  826. session.append('request/header', {
  827. header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools },
  828. reason: 'resume',
  829. })
  830. const nodes = session.surface.nodes
  831. await compact.compactRegion(nodes[0]!, nodes[1]!, agent(session, MODEL), SIGNAL)
  832. const { input } = compact.calls[0]!
  833. expect(input.system).toBe('CONVERSATION SYSTEM')
  834. expect(input.tools).toEqual(tools)
  835. expect(summarizedText(input)).toContain('fixture user 1')
  836. })
  837. it('applies the durable image watermark to the replayed summarization prefix', async () => {
  838. const compact = service()
  839. const session = conversation(3)
  840. const replaced = session.surface.nodes[0]!
  841. const replacement = session.append('user/message', createUserMessage({
  842. content: [
  843. { type: 'text', text: 'large image context '.repeat(100) },
  844. {
  845. type: 'image',
  846. attachment: {
  847. attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`),
  848. mediaType: 'image/png',
  849. bytes: 1,
  850. width: 1,
  851. height: 1,
  852. },
  853. },
  854. ],
  855. source: { kind: 'user' },
  856. }), {
  857. surfaceOp: { op: 'replace', start: replaced, end: replaced },
  858. sourceEventSeqs: [replaced],
  859. })
  860. session.append('image/offload', {
  861. turn: 4,
  862. step: 1,
  863. watermark: { seq: replacement.seq, path: [1] },
  864. })
  865. await compact.compactRegion(replacement.seq, replacement.seq, agent(session, MODEL), SIGNAL)
  866. expect(compact.calls[0]!.input.messages[0]!.content[1]).toMatchObject({
  867. type: 'image',
  868. offloaded: true,
  869. })
  870. const durable = session.eventAt(replacement.seq)!
  871. expect(durable.type === 'user/message' ? durable.data.content[1] : undefined)
  872. .not.toHaveProperty('offloaded')
  873. })
  874. it.each([
  875. ['start missing', 9_001, undefined, /start seq 9001 not found/],
  876. ['end missing', undefined, 9_002, /end seq 9002 not found/],
  877. ])('rejects %s', async (_label, startOverride, endOverride, pattern) => {
  878. const compact = service()
  879. const session = conversation(2)
  880. const nodes = session.surface.nodes
  881. await expect(compact.compactRegion(
  882. startOverride === undefined ? nodes[0]! : SessionSeq(startOverride),
  883. endOverride === undefined ? nodes[1]! : SessionSeq(endOverride),
  884. agent(session, MODEL),
  885. )).rejects.toThrow(pattern)
  886. })
  887. it('rejects reversed and tool-unbalanced positional boundaries', async () => {
  888. const compact = service()
  889. const plain = conversation(2)
  890. const nodes = plain.surface.nodes
  891. await expect(compact.compactRegion(
  892. nodes[2]!,
  893. nodes[1]!,
  894. agent(plain, MODEL),
  895. )).rejects.toThrow(/is after end/)
  896. const tools = toolConversation()
  897. const toolNodes = tools.surface.nodes
  898. await expect(compact.compactRegion(
  899. toolNodes[2]!,
  900. toolNodes[4]!,
  901. agent(tools, MODEL),
  902. )).rejects.toThrow(/start seq .* not a balanced boundary/)
  903. await expect(compact.compactRegion(
  904. toolNodes[0]!,
  905. toolNodes[1]!,
  906. agent(tools, MODEL),
  907. )).rejects.toThrow(/end seq .* not a balanced boundary/)
  908. })
  909. it('requires an open turn and an idle compaction bracket', async () => {
  910. const compact = service()
  911. const closed = conversation(1)
  912. closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  913. const nodes = closed.surface.nodes
  914. await expect(compact.compactRegion(
  915. nodes[0]!,
  916. nodes[1]!,
  917. agent(closed, MODEL),
  918. )).rejects.toThrow(/no open turn/)
  919. const locked = conversation(1)
  920. locked.append('compaction/start', {
  921. compactionId: CompactionId('locked-compaction'),
  922. turn: 2,
  923. })
  924. const lockedNodes = locked.surface.nodes
  925. await expect(compact.compactRegion(
  926. lockedNodes[0]!,
  927. lockedNodes[1]!,
  928. agent(locked, MODEL),
  929. )).rejects.toThrow(/already in progress/)
  930. })
  931. it('rejects a session with no turn boundary at all', async () => {
  932. const compact = service()
  933. const session = Session.create(SessionId('turnless'))
  934. session.append('user/message', createUserMessage({
  935. content: [{ type: 'text', text: 'orphan' }],
  936. source: { kind: 'user' },
  937. }), { surfaceOp: 'append' })
  938. const node = session.surface.nodes[0]!
  939. await expect(compact.compactRegion(
  940. node,
  941. node,
  942. agent(session, MODEL),
  943. )).rejects.toThrow(/no open turn/)
  944. })
  945. it('rejects a meter snapshot that changed before summarization began', async () => {
  946. const ctx = createContext()
  947. const meter = ctx.tokenMeter
  948. const original = meter.measure.bind(meter)
  949. vi.spyOn(meter, 'measure').mockImplementationOnce((session) => {
  950. const measurement = original(session)
  951. return { ...measurement, nodes: measurement.nodes.slice(1) }
  952. })
  953. const compact = service({ auto: false }, ctx)
  954. const session = conversation(2)
  955. const nodes = session.surface.nodes
  956. await expect(compact.compactRegion(
  957. nodes[0]!,
  958. nodes[2]!,
  959. agent(session, MODEL),
  960. )).rejects.toThrow(/selected surface changed/)
  961. })
  962. it('records summarizer failures without mutating the surface', async () => {
  963. const compact = service()
  964. compact.error = new Error('summary unavailable')
  965. const session = conversation(2)
  966. const before = session.surface.nodes
  967. await expect(compact.compactRegion(
  968. before[0]!,
  969. before[2]!,
  970. agent(session, MODEL),
  971. )).rejects.toThrow('summary unavailable')
  972. expect(session.surface.nodes).toEqual(before)
  973. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data)
  974. .toMatchObject({ error: 'summary unavailable' })
  975. })
  976. it('stringifies non-Error failures in the durable end bracket', async () => {
  977. const compact = service()
  978. compact.error = 'plain failure'
  979. const session = conversation(2)
  980. const nodes = session.surface.nodes
  981. await expect(compact.compactRegion(
  982. nodes[0]!,
  983. nodes[2]!,
  984. agent(session, MODEL),
  985. )).rejects.toBe('plain failure')
  986. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data)
  987. .toMatchObject({ error: 'plain failure' })
  988. })
  989. it('tolerates concurrent log-only appends while the selected surface is stable', async () => {
  990. const compact = service()
  991. const session = conversation(2)
  992. compact.mutateDuringSummary = () => {
  993. session.append('request/header', {
  994. header: { config: { provider: MODEL, model: MODEL } },
  995. reason: 'change',
  996. })
  997. }
  998. const nodes = session.surface.nodes
  999. await expect(compact.compactRegion(
  1000. nodes[0]!,
  1001. nodes[2]!,
  1002. agent(session, MODEL),
  1003. )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) })
  1004. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
  1005. })
  1006. it('rejects concurrent surface appends before committing the replacement', async () => {
  1007. const compact = service()
  1008. const session = conversation(2)
  1009. compact.mutateDuringSummary = () => {
  1010. session.append('user/message', createUserMessage({
  1011. content: [{ type: 'text', text: 'concurrent surface mutation' }],
  1012. source: { kind: 'plugin', plugin: 'test' },
  1013. }), { surfaceOp: 'append' })
  1014. }
  1015. const nodes = session.surface.nodes
  1016. await expect(compact.compactRegion(
  1017. nodes[0]!,
  1018. nodes[2]!,
  1019. agent(session, MODEL),
  1020. )).rejects.toThrow(/session surface changed/)
  1021. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
  1022. })
  1023. it('rejects a non-shrinking framed summary under the conversation meter', async () => {
  1024. const compact = service()
  1025. compact.summary = Array.from({ length: 100 }, (_, index) => ({
  1026. type: 'text',
  1027. text: `verbose ${index}`,
  1028. }))
  1029. const session = conversation(2)
  1030. const nodes = session.surface.nodes
  1031. await expect(compact.compactRegion(
  1032. nodes[0]!,
  1033. nodes[2]!,
  1034. agent(session, MODEL),
  1035. )).rejects.toThrow(/summary is not smaller/)
  1036. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
  1037. })
  1038. it('lets a model-independent custom summarizer compact without a conversation model', async () => {
  1039. const compact = service()
  1040. const session = Session.create(SessionId('model-less-region'))
  1041. session.append('turn/start', { turn: 1 })
  1042. session.append('user/message', createUserMessage({
  1043. content: [{ type: 'text', text: 'history '.repeat(100) }],
  1044. source: { kind: 'user' },
  1045. }), { surfaceOp: 'append' })
  1046. session.append('step/start', { turn: 1, step: 1 })
  1047. session.append('assistant/message', {
  1048. stream: [],
  1049. turn: 1,
  1050. step: 1,
  1051. message: createMessage({
  1052. role: 'assistant',
  1053. content: [{ type: 'text', text: 'answer '.repeat(100) }],
  1054. source: {
  1055. kind: 'model',
  1056. ...{ provider: 'historical', model: 'historical' },
  1057. },
  1058. }),
  1059. }, { surfaceOp: 'append' })
  1060. session.append('step/end', { turn: 1, step: 1 })
  1061. const nodes = session.surface.nodes
  1062. await expect(compact.compactRegion(
  1063. nodes[0]!,
  1064. nodes[1]!,
  1065. agent(session),
  1066. )).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!, nodes[1]!] })
  1067. })
  1068. })
  1069. class ScriptedAdapter extends LlmAdapter {
  1070. lastOptions: GenerateOptions | undefined
  1071. usage: TokenUsage | undefined
  1072. constructor(
  1073. private readonly blocks: readonly ContentBlock[],
  1074. private readonly finish: (StreamChunk & { type: 'finish' })['reason'] = { kind: 'stop' },
  1075. ) {
  1076. super()
  1077. }
  1078. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  1079. this.lastOptions = options
  1080. for (const [index, block] of this.blocks.entries()) {
  1081. yield { type: 'block-start', index, blockType: block.type }
  1082. if (block.type === 'text') {
  1083. yield { type: 'text-delta', index, text: block.text }
  1084. } else if (block.type === 'reasoning') {
  1085. yield { type: 'reasoning-delta', index, text: block.text }
  1086. } else {
  1087. yield { type: 'block-end', index, block }
  1088. }
  1089. }
  1090. if (this.usage !== undefined) yield { type: 'usage', usage: this.usage }
  1091. yield { type: 'finish', reason: this.finish }
  1092. }
  1093. }
  1094. class ExposedCompactionEngine extends BasicCompactionEngine {
  1095. runSummarize(
  1096. input: SummarizationInput,
  1097. owner: Agent,
  1098. signal?: AbortSignal,
  1099. ): Promise<{
  1100. summary: ContentBlock[]
  1101. rawOutput?: ContentBlock[]
  1102. provider: string
  1103. model: string
  1104. maxTokens?: number
  1105. usage?: TokenUsage
  1106. }> {
  1107. return this.summarize(input, owner, signal)
  1108. }
  1109. }
  1110. async function summarizerHarness(
  1111. blocks: readonly ContentBlock[],
  1112. finish?: (StreamChunk & { type: 'finish' })['reason'],
  1113. model = MODEL,
  1114. config: BasicCompactionConfig = { auto: false },
  1115. ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactionEngine }> {
  1116. const ctx = new Context()
  1117. await ctx.plugin(LlmRuntime)
  1118. await ctx.plugin(SessionProjectionRegistry)
  1119. void new TokenMeter(ctx)
  1120. const adapter = new ScriptedAdapter(blocks, finish)
  1121. ctx.llm.registerAdapter([model], adapter)
  1122. const compact = new ExposedCompactionEngine(ctx, config)
  1123. return { ctx, adapter, compact }
  1124. }
  1125. describe('default one-shot summarizer', () => {
  1126. it('requires complete raw output when a subclass marks one local LLM stream call', () => {
  1127. expectTypeOf<{
  1128. summary: ContentBlock[]
  1129. llmStreamCall: true
  1130. provider: string
  1131. model: string
  1132. }>().not.toExtend<SummaryResult>()
  1133. })
  1134. it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
  1135. const { adapter, compact } = await summarizerHarness([
  1136. { type: 'reasoning', text: 'private' },
  1137. { type: 'text', text: 'public summary' },
  1138. { type: 'tool-call', id: ToolCallId('unexpected'), name: 'x', arguments: '{}' },
  1139. ], undefined, MODEL, {
  1140. auto: false,
  1141. summarizationProvider: MODEL,
  1142. summarizationModel: MODEL,
  1143. maxTokens: 321,
  1144. })
  1145. const session = conversation(1)
  1146. adapter.usage = { inputTokens: 12, outputTokens: 3 }
  1147. const output = await compact.runSummarize(promptInput('transcript'), agent(session, 'fallback'), SIGNAL)
  1148. expect(output).toEqual({
  1149. summary: [{ type: 'text', text: 'public summary' }],
  1150. rawOutput: [
  1151. { type: 'reasoning', text: 'private' },
  1152. { type: 'text', text: 'public summary' },
  1153. { type: 'tool-call', id: ToolCallId('unexpected'), name: 'x', arguments: '{}' },
  1154. ],
  1155. llmStreamCall: true,
  1156. provider: MODEL,
  1157. model: MODEL,
  1158. maxTokens: 321,
  1159. usage: adapter.usage,
  1160. })
  1161. expect(adapter.lastOptions).toMatchObject({
  1162. provider: MODEL,
  1163. model: MODEL,
  1164. maxTokens: 321,
  1165. signal: SIGNAL,
  1166. sessionId: session.id,
  1167. purpose: 'compaction',
  1168. })
  1169. const instruction = adapter.lastOptions?.messages.at(-1)?.content[0]
  1170. expect(instruction?.type === 'text' ? instruction.text : '').toContain('## Primary Request and Intent')
  1171. })
  1172. it('replays the conversation prefix and appends the instruction as the final message', async () => {
  1173. const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
  1174. const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
  1175. const prefix: Message = createUserMessage({
  1176. content: [
  1177. { type: 'text', text: 'earlier turn' },
  1178. {
  1179. type: 'image',
  1180. attachment: {
  1181. attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
  1182. mediaType: 'image/png',
  1183. bytes: 1,
  1184. width: 1,
  1185. height: 1,
  1186. },
  1187. },
  1188. ],
  1189. source: { kind: 'plugin', plugin: 'test' },
  1190. })
  1191. await compact.runSummarize({
  1192. system: 'REPLAYED SYSTEM',
  1193. tools,
  1194. messages: [prefix],
  1195. }, agent(conversation(1), MODEL))
  1196. expect(adapter.lastOptions?.system).toBe('REPLAYED SYSTEM')
  1197. expect(adapter.lastOptions?.tools).toEqual(tools)
  1198. const messages = adapter.lastOptions?.messages ?? []
  1199. expect(messages[0]).toEqual(prefix)
  1200. const last = messages.at(-1)?.content[0]
  1201. const lastText = last?.type === 'text' ? last.text : ''
  1202. expect(lastText).toContain('Write concise English engineering prose.')
  1203. expect(lastText).toContain('numeric values, function signatures, and syntax fragments.')
  1204. expect(lastText).toContain('## Primary Request and Intent')
  1205. })
  1206. it('applies the routed model policy without changing the replayed prefix', async () => {
  1207. const { ctx, compact } = await summarizerHarness(
  1208. [{ type: 'text', text: 'unused default summary' }],
  1209. undefined,
  1210. MODEL,
  1211. {
  1212. auto: false,
  1213. maxTokens: 111,
  1214. modelPolicies: [{
  1215. provider: MODEL,
  1216. model: MODEL,
  1217. summarizationProvider: 'policy-summary',
  1218. summarizationModel: 'policy-summary',
  1219. maxTokens: 222,
  1220. }],
  1221. },
  1222. )
  1223. const policyAdapter = new ScriptedAdapter([{ type: 'text', text: 'policy summary' }])
  1224. ctx.llm.registerAdapter(['policy-summary'], policyAdapter)
  1225. const prefix: Message = createUserMessage({
  1226. content: [{ type: 'text', text: 'warm prefix' }],
  1227. source: { kind: 'plugin', plugin: 'test' },
  1228. })
  1229. const output = await compact.runSummarize({
  1230. system: 'WARM SYSTEM',
  1231. messages: [prefix],
  1232. }, agent(conversation(1), 'fallback'))
  1233. expect(output).toMatchObject({
  1234. provider: 'policy-summary',
  1235. model: 'policy-summary',
  1236. maxTokens: 222,
  1237. })
  1238. expect(policyAdapter.lastOptions).toMatchObject({
  1239. provider: 'policy-summary',
  1240. model: 'policy-summary',
  1241. maxTokens: 222,
  1242. system: 'WARM SYSTEM',
  1243. })
  1244. expect(policyAdapter.lastOptions?.messages[0]).toEqual(prefix)
  1245. })
  1246. it('resolves the latest routed provider/model before the AgentOptions pair', async () => {
  1247. const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed')
  1248. const session = conversation(1)
  1249. session.append('request/header', {
  1250. header: { config: { provider: 'routed', model: 'routed' } },
  1251. reason: 'initial',
  1252. })
  1253. const output = await compact.runSummarize(promptInput('history'), agent(session, 'fallback'))
  1254. expect(output.provider).toBe('routed')
  1255. expect(output.model).toBe('routed')
  1256. expect(adapter.lastOptions?.provider).toBe('routed')
  1257. expect(adapter.lastOptions?.model).toBe('routed')
  1258. })
  1259. it('records the model actually dispatched after one-shot stream routing', async () => {
  1260. const { ctx, compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
  1261. const routedAdapter = new ScriptedAdapter([{ type: 'text', text: 'routed summary' }])
  1262. ctx.llm.registerAdapter(['routed-summary-provider'], routedAdapter)
  1263. ctx.on('llm/stream', (options, next) => {
  1264. options.provider = 'routed-summary-provider'
  1265. options.model = 'routed-summary-model'
  1266. return next()
  1267. })
  1268. const session = conversation(3, 'large history '.repeat(500))
  1269. const nodes = session.surface.nodes
  1270. await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL)
  1271. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({
  1272. summary: [{ type: 'text', text: 'routed summary' }],
  1273. llmStreamCall: true,
  1274. provider: 'routed-summary-provider',
  1275. model: 'routed-summary-model',
  1276. })
  1277. expect(routedAdapter.lastOptions?.provider).toBe('routed-summary-provider')
  1278. expect(routedAdapter.lastOptions?.model).toBe('routed-summary-model')
  1279. })
  1280. it('fails clearly when no complete summarization target can be resolved', async () => {
  1281. const ctx = new Context()
  1282. await ctx.plugin(LlmRuntime)
  1283. await ctx.plugin(SessionProjectionRegistry)
  1284. void new TokenMeter(ctx)
  1285. const compact = new ExposedCompactionEngine(ctx, { auto: false })
  1286. await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less')))))
  1287. .rejects.toThrow(/no provider\/model available for summarization/)
  1288. })
  1289. it('uses a complete AgentOptions target when no durable route exists', async () => {
  1290. const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
  1291. const session = Session.create(SessionId('headerless-summary'))
  1292. await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({
  1293. provider: MODEL,
  1294. model: MODEL,
  1295. })
  1296. expect(adapter.lastOptions).toMatchObject({ provider: MODEL, model: MODEL })
  1297. })
  1298. it.each([
  1299. { provider: '', model: MODEL },
  1300. { provider: MODEL },
  1301. { provider: MODEL, model: '' },
  1302. ])('rejects incomplete AgentOptions target %#', async (options) => {
  1303. const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
  1304. const owner = {
  1305. session: Session.create(SessionId(`incomplete-${String(options.model)}`)),
  1306. options,
  1307. } as Agent
  1308. await expect(compact.runSummarize(promptInput('history'), owner))
  1309. .rejects.toThrow(/no provider\/model available for summarization/)
  1310. })
  1311. it.each([
  1312. [{ kind: 'error', failure: { message: 'provider failed', code: 'PROVIDER' } }, 'PROVIDER', /provider failed/],
  1313. [{ kind: 'error', failure: { message: 'opaque', code: 'UNKNOWN' } }, 'UNKNOWN', /opaque/],
  1314. [{ kind: 'aborted', failure: { message: 'summarization aborted', code: 'ABORTED' } }, 'ABORTED', /aborted/],
  1315. [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/],
  1316. ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) (
  1317. 'rejects terminal finish %#',
  1318. async (finish, code, pattern) => {
  1319. const { compact } = await summarizerHarness([], finish)
  1320. let thrown: unknown
  1321. try {
  1322. await compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))
  1323. } catch (error: unknown) {
  1324. thrown = error
  1325. }
  1326. expect(thrown).toBeInstanceOf(Error)
  1327. expect((thrown as Error).message).toMatch(pattern)
  1328. expect((thrown as Error & { code?: string }).code).toBe(code)
  1329. },
  1330. )
  1331. it('rejects empty or reasoning-only successful output', async () => {
  1332. const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
  1333. await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
  1334. .rejects.toThrow(/no text summary content/)
  1335. })
  1336. it('rejects image summary output instead of silently dropping it', async () => {
  1337. const { compact } = await summarizerHarness([
  1338. {
  1339. type: 'image',
  1340. attachment: {
  1341. attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
  1342. mediaType: 'image/png',
  1343. bytes: 1,
  1344. width: 1,
  1345. height: 1,
  1346. },
  1347. },
  1348. { type: 'text', text: 'partial summary' },
  1349. ])
  1350. await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
  1351. .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
  1352. })
  1353. it('rejects image summary output nested in a tool result', async () => {
  1354. const { compact } = await summarizerHarness([{
  1355. type: 'tool-result',
  1356. toolCallId: ToolCallId('summary-tool'),
  1357. content: [{
  1358. type: 'image',
  1359. attachment: {
  1360. attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
  1361. mediaType: 'image/png',
  1362. bytes: 1,
  1363. width: 1,
  1364. height: 1,
  1365. },
  1366. }],
  1367. }])
  1368. await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
  1369. .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
  1370. })
  1371. })
  1372. describe('automatic listener and loader composition', () => {
  1373. function preStep(ctx: Context, owner: Agent, signal = SIGNAL) {
  1374. return agentEvents(ctx, owner).waterfall(
  1375. 'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
  1376. () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
  1377. )
  1378. }
  1379. function recover(
  1380. ctx: Context,
  1381. owner: Agent,
  1382. error: Error & { code?: string },
  1383. signal = SIGNAL,
  1384. next: () => Promise<RequestErrorAction> = () => Promise.resolve(undefined),
  1385. ): Promise<boolean> {
  1386. const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
  1387. const turn = owner.session.snapshotEvents().findLast(event => event.type === 'turn/start')?.data.turn ?? 1
  1388. return agentEvents(ctx, owner).waterfall(
  1389. 'agent/request-error',
  1390. { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal },
  1391. next,
  1392. ).then(action => action?.kind === 'retry')
  1393. }
  1394. function overflow(message = 'provider overflow'): Error & { code: string } {
  1395. return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE })
  1396. }
  1397. it('compacts before a step above threshold using the durable routed model and remains idle below it', async () => {
  1398. const ctx = createContext()
  1399. const compact = new TestCompactionEngine(ctx, {
  1400. thresholdRatio: 0.5,
  1401. retainTokens: 180,
  1402. })
  1403. const pressured = conversation(4)
  1404. await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
  1405. expect(pressured.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
  1406. const small = conversation(1)
  1407. await preStep(ctx, agent(small, MODEL))
  1408. expect(small.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
  1409. expect(compact.calls).toHaveLength(1)
  1410. })
  1411. it('skips pre-step pressure when the step signal is already aborted', async () => {
  1412. const ctx = createContext()
  1413. const compact = new TestCompactionEngine(ctx, {
  1414. thresholdRatio: 0.5,
  1415. retainTokens: 180,
  1416. })
  1417. const pressured = conversation(4)
  1418. const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
  1419. await expect(preStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted')))
  1420. .resolves.toEqual({ kind: 'enter', messages: [] })
  1421. expect(compactIfNeeded).not.toHaveBeenCalled()
  1422. expect(pressured.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
  1423. })
  1424. it('warns and continues after operational failures, including non-Errors', async () => {
  1425. const ctx = createContext()
  1426. const warnings: string[] = []
  1427. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  1428. const compact = new TestCompactionEngine(ctx, {
  1429. thresholdRatio: 0.5,
  1430. retainTokens: 180,
  1431. })
  1432. compact.error = 'temporary failure'
  1433. const session = conversation(4)
  1434. await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] })
  1435. expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
  1436. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
  1437. })
  1438. it('warns once per routed target when proactive pressure has no context metadata', async () => {
  1439. const ctx = createContext()
  1440. const warnings: string[] = []
  1441. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  1442. vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
  1443. provider,
  1444. id: model,
  1445. name: model,
  1446. }))
  1447. void new TestCompactionEngine(ctx, {
  1448. thresholdRatio: 0.5,
  1449. retainTokens: 180,
  1450. })
  1451. const session = conversation(4)
  1452. await preStep(ctx, agent(session, MODEL))
  1453. await preStep(ctx, agent(session, MODEL))
  1454. expect(warnings).toEqual([
  1455. expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`),
  1456. ])
  1457. })
  1458. it('warns once per routed target when absolute retention exceeds its resolved threshold', async () => {
  1459. const ctx = createContext()
  1460. const warnings: string[] = []
  1461. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  1462. void new TestCompactionEngine(ctx, {
  1463. thresholdRatio: 0.5,
  1464. retainTokens: 500,
  1465. })
  1466. const session = conversation(4)
  1467. await preStep(ctx, agent(session, MODEL))
  1468. await preStep(ctx, agent(session, MODEL))
  1469. expect(warnings).toEqual([
  1470. expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'),
  1471. ])
  1472. })
  1473. it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => {
  1474. const ctx = createContext(10_000)
  1475. void new TestCompactionEngine(ctx, {
  1476. thresholdRatio: 1,
  1477. retainTokens: 900,
  1478. })
  1479. const session = conversation(3)
  1480. const beforeGeneration = session.surface.replaceGeneration
  1481. const retainedSeq = session.surface.nodes.at(-1)!
  1482. const threshold = 10_000
  1483. expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
  1484. const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
  1485. expect(decision).toBe(true)
  1486. expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
  1487. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
  1488. expect(session.surface.nodes).toContain(retainedSeq)
  1489. })
  1490. it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => {
  1491. const ctx = createContext(10_000)
  1492. void new ToolResultPruner(ctx, {
  1493. thresholdChars: 100,
  1494. headChars: 20,
  1495. tailChars: 10,
  1496. })
  1497. const compact = new TestCompactionEngine(ctx, {
  1498. thresholdRatio: 1,
  1499. retainTokens: 900,
  1500. })
  1501. const session = oversizedToolResult()
  1502. expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
  1503. expect(session.surface.replaceGeneration).toBe(1)
  1504. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
  1505. expect(compact.calls).toHaveLength(0)
  1506. })
  1507. it('continues overflow recovery with summarization on the pruned surface', async () => {
  1508. const ctx = createContext(10_000)
  1509. void new ToolResultPruner(ctx, {
  1510. thresholdChars: 100,
  1511. headChars: 20,
  1512. tailChars: 10,
  1513. })
  1514. const compact = new TestCompactionEngine(ctx, {
  1515. thresholdRatio: 1,
  1516. retainTokens: 900,
  1517. })
  1518. const session = toolConversation()
  1519. expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
  1520. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
  1521. expect(compact.calls).toHaveLength(1)
  1522. expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
  1523. })
  1524. it('retries from a durable prune when later overflow summarization throws', async () => {
  1525. const ctx = createContext(10_000)
  1526. const warnings: string[] = []
  1527. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  1528. void new ToolResultPruner(ctx, {
  1529. thresholdChars: 100,
  1530. headChars: 20,
  1531. tailChars: 10,
  1532. })
  1533. const compact = new TestCompactionEngine(ctx, {
  1534. thresholdRatio: 1,
  1535. retainTokens: 900,
  1536. })
  1537. compact.error = new Error('summary unavailable after prune')
  1538. const session = oversizedToolResult(3_000, true)
  1539. expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
  1540. expect(session.surface.replaceGeneration).toBe(1)
  1541. expect(session.snapshotEvents().filter(event => event.type === 'tool/result')).toHaveLength(2)
  1542. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data)
  1543. .toMatchObject({ error: 'summary unavailable after prune' })
  1544. expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface'))
  1545. })
  1546. it('lets cancellation win when summary throws after a durable prune', async () => {
  1547. const ctx = createContext(10_000)
  1548. const controller = new AbortController()
  1549. void new ToolResultPruner(ctx, {
  1550. thresholdChars: 100,
  1551. headChars: 20,
  1552. tailChars: 10,
  1553. })
  1554. const compact = new TestCompactionEngine(ctx, {
  1555. thresholdRatio: 1,
  1556. retainTokens: 900,
  1557. })
  1558. compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') }
  1559. compact.error = new Error('summary cancelled after prune')
  1560. const session = oversizedToolResult(3_000, true)
  1561. expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
  1562. expect(session.surface.replaceGeneration).toBe(1)
  1563. })
  1564. it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
  1565. const ctx = createContext()
  1566. void new TestCompactionEngine(ctx, {
  1567. thresholdRatio: 1,
  1568. retainTokens: 90,
  1569. })
  1570. const session = toolConversation()
  1571. const newestAssistant = session.surface.nodes.at(-2)!
  1572. const newestResult = session.surface.nodes.at(-1)!
  1573. expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
  1574. const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
  1575. const currentResult = session.surface.nodes.find(node => node === newestResult)
  1576. expect(currentAssistant).toBeDefined()
  1577. expect(currentResult).toBeDefined()
  1578. expect(toolPairingBalancedBefore(session, currentAssistant!)).toBe(true)
  1579. expect(toolPairingBalancedAfter(session, currentResult!)).toBe(true)
  1580. })
  1581. it('does not retry when a backend reports success without replacing the surface', async () => {
  1582. const ctx = createContext()
  1583. const compact = new TestCompactionEngine(ctx)
  1584. const session = conversation(2)
  1585. const fakeResult: CompactionResult = {
  1586. compactionId: CompactionId('fake-compaction'),
  1587. startSeq: SessionSeq(1),
  1588. summarySeq: SessionSeq(2),
  1589. endSeq: SessionSeq(3),
  1590. summary: [{ type: 'text', text: 'fake' }],
  1591. shadowedRange: { start: SessionSeq(1), end: SessionSeq(2) },
  1592. shadowedSeqs: [SessionSeq(1), SessionSeq(2)],
  1593. shadowedTokenCount: 10,
  1594. }
  1595. vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult)
  1596. expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
  1597. expect(session.surface.replaceGeneration).toBe(0)
  1598. })
  1599. it('delegates downstream exactly once when no replacement is available', async () => {
  1600. const ctx = createContext()
  1601. const compact = new TestCompactionEngine(ctx)
  1602. vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(null)
  1603. const downstream = new Error('downstream recovery failed')
  1604. let calls = 0
  1605. await expect(recover(
  1606. ctx,
  1607. agent(conversation(2), MODEL),
  1608. overflow(),
  1609. SIGNAL,
  1610. () => {
  1611. calls += 1
  1612. return Promise.reject(downstream)
  1613. },
  1614. )).rejects.toBe(downstream)
  1615. expect(calls).toBe(1)
  1616. })
  1617. it('preserves the original provider error when recovery throws', async () => {
  1618. const ctx = createContext()
  1619. const warnings: string[] = []
  1620. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  1621. const compact = new TestCompactionEngine(ctx)
  1622. compact.error = new Error('summary unavailable')
  1623. const original = overflow('original provider overflow')
  1624. expect(await recover(ctx, agent(conversation(3), MODEL), original)).toBe(false)
  1625. expect(original).toMatchObject({
  1626. message: 'original provider overflow',
  1627. code: CONTEXT_WINDOW_EXCEEDED_CODE,
  1628. })
  1629. expect(warnings).toContainEqual(expect.stringContaining('preserving the original request error'))
  1630. })
  1631. it('delegates once when overflow recovery throws a non-Error value', async () => {
  1632. const ctx = createContext()
  1633. const warnings: string[] = []
  1634. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  1635. const compact = new TestCompactionEngine(ctx)
  1636. compact.error = 'non-error recovery failure'
  1637. const session = conversation(3)
  1638. const generation = session.surface.replaceGeneration
  1639. const original = overflow('original provider failure')
  1640. let delegations = 0
  1641. const decision = await recover(ctx, agent(session, MODEL), original, SIGNAL, () => {
  1642. delegations += 1
  1643. return Promise.resolve(undefined)
  1644. })
  1645. expect(decision).toBe(false)
  1646. expect(delegations).toBe(1)
  1647. expect(session.surface.replaceGeneration).toBe(generation)
  1648. expect(original).toMatchObject({
  1649. message: 'original provider failure',
  1650. code: CONTEXT_WINDOW_EXCEEDED_CODE,
  1651. })
  1652. expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure'))
  1653. })
  1654. it('recovers an overflow for an unlisted routed model', async () => {
  1655. const ctx = createContext()
  1656. void new TestCompactionEngine(ctx)
  1657. const session = conversation(2)
  1658. session.append('request/header', {
  1659. header: { config: { provider: 'unknown-routed-provider', model: 'unknown-routed-model' } },
  1660. reason: 'resume',
  1661. })
  1662. expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
  1663. .toBe(true)
  1664. })
  1665. it('delegates canonical overflow when no durable routed target exists', async () => {
  1666. const ctx = createContext()
  1667. void new TestCompactionEngine(ctx)
  1668. const session = Session.create(SessionId('headerless-overflow'))
  1669. session.append('turn/start', {
  1670. turn: 1,
  1671. })
  1672. await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
  1673. })
  1674. it('honors retry caps and ignores non-context failures', async () => {
  1675. const ctx = createContext()
  1676. const compact = new TestCompactionEngine(ctx, { maxOverflowRetries: 1 })
  1677. const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
  1678. const owner = agent(conversation(3), MODEL)
  1679. expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' })))
  1680. .toBe(false)
  1681. expect(await recover(ctx, owner, overflow())).toBe(true)
  1682. compactSpy.mockClear()
  1683. expect(await recover(ctx, owner, overflow())).toBe(false)
  1684. expect(compactSpy).not.toHaveBeenCalled()
  1685. })
  1686. it('applies the routed model override to the overflow retry cap', async () => {
  1687. const ctx = createContext()
  1688. const compact = new TestCompactionEngine(ctx, {
  1689. maxOverflowRetries: 2,
  1690. modelPolicies: [{
  1691. provider: MODEL,
  1692. model: MODEL,
  1693. maxOverflowRetries: 1,
  1694. }],
  1695. })
  1696. const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
  1697. const owner = agent(conversation(3), MODEL)
  1698. expect(await recover(ctx, owner, overflow())).toBe(true)
  1699. compactSpy.mockClear()
  1700. expect(await recover(ctx, owner, overflow())).toBe(false)
  1701. expect(compactSpy).not.toHaveBeenCalled()
  1702. })
  1703. it('does not retry when cancellation lands during an awaited compaction', async () => {
  1704. const ctx = createContext()
  1705. const compact = new TestCompactionEngine(ctx)
  1706. const controller = new AbortController()
  1707. compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') }
  1708. const session = conversation(3)
  1709. const generation = session.surface.replaceGeneration
  1710. expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
  1711. expect(session.surface.replaceGeneration).toBe(generation + 1)
  1712. })
  1713. it('maxOverflowRetries:0 disables recovery without disabling post-step pressure', async () => {
  1714. const ctx = createContext()
  1715. void new TestCompactionEngine(ctx, {
  1716. maxOverflowRetries: 0,
  1717. thresholdRatio: 0.5,
  1718. retainTokens: 180,
  1719. })
  1720. const session = conversation(4)
  1721. await preStep(ctx, agent(session, MODEL))
  1722. const summaries = session.snapshotEvents().filter(event => event.type === 'compaction/summary').length
  1723. expect(summaries).toBe(1)
  1724. expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
  1725. expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(summaries)
  1726. })
  1727. it('auto:false installs neither automatic listener', async () => {
  1728. const ctx = createContext()
  1729. void new TestCompactionEngine(ctx, {
  1730. auto: false,
  1731. thresholdRatio: 0.5,
  1732. retainTokens: 180,
  1733. })
  1734. const session = conversation(4)
  1735. await preStep(ctx, agent(session, MODEL))
  1736. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
  1737. expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
  1738. })
  1739. it('loads and disposes the real zero-config service stack', async () => {
  1740. const ctx = new Context()
  1741. await ctx.plugin(LlmRuntime)
  1742. await ctx.plugin(SessionStore)
  1743. await ctx.plugin(SessionProjectionRegistry)
  1744. const meterFiber = await ctx.plugin(TokenMeter)
  1745. const compactFiber = await ctx.plugin(BasicCompactionEngine, { auto: false })
  1746. expect(ctx.get('compaction')).toBeInstanceOf(BasicCompactionEngine)
  1747. await compactFiber.dispose()
  1748. expect(ctx.get('compaction')).toBeUndefined()
  1749. await meterFiber.dispose()
  1750. expect(ctx.get('tokenMeter')).toBeUndefined()
  1751. })
  1752. it('removes its automatic listener with the plugin fiber', async () => {
  1753. const ctx = new Context()
  1754. await ctx.plugin(LlmRuntime)
  1755. await ctx.plugin(SessionProjectionRegistry)
  1756. await ctx.plugin(TokenMeter)
  1757. const fiber = await ctx.plugin(TestCompactionEngine, {
  1758. thresholdRatio: 0.5,
  1759. retainTokens: 180,
  1760. })
  1761. await fiber.dispose()
  1762. const session = conversation(4)
  1763. await preStep(ctx, agent(session, MODEL))
  1764. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
  1765. expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
  1766. })
  1767. })
  1768. describe('route-priced image pressure', () => {
  1769. const IMAGE_VISUAL_TOKENS = 300
  1770. const IMAGE_HANDLE_TEXT = 'request preview'
  1771. class PricedContextAdapter extends ContextAdapter {
  1772. override imageRequestPricing(): { priceImages: (images: readonly unknown[]) => Array<{ visualTokens: number; text: string }> } {
  1773. return {
  1774. priceImages: images => images.map(() => ({
  1775. visualTokens: IMAGE_VISUAL_TOKENS,
  1776. text: IMAGE_HANDLE_TEXT,
  1777. })),
  1778. }
  1779. }
  1780. }
  1781. function pricedContext(contextWindow = 1_000): Context {
  1782. const ctx = new Context()
  1783. void new LlmRuntime(ctx)
  1784. new SessionProjectionRegistry(ctx)
  1785. void new TokenMeter(ctx)
  1786. ctx.llm.registerAdapter([MODEL], new PricedContextAdapter(contextWindow))
  1787. return ctx
  1788. }
  1789. /** Closed short-text turns whose user messages each carry one image. */
  1790. function imageConversation(turns = 4): Session {
  1791. const session = Session.create(SessionId(`image-dense-${turns}`))
  1792. for (let turn = 1; turn <= turns; turn += 1) {
  1793. session.append('turn/start', { turn })
  1794. session.append('user/message', createUserMessage({
  1795. content: [
  1796. { type: 'text', text: `image turn ${turn}` },
  1797. {
  1798. type: 'image',
  1799. attachment: {
  1800. attachmentId: AttachmentId(`sha256:${String(turn).repeat(8)}`),
  1801. mediaType: 'image/png',
  1802. bytes: 2048,
  1803. width: 800,
  1804. height: 800,
  1805. name: `shot-${turn}`,
  1806. },
  1807. },
  1808. ],
  1809. source: { kind: 'user' },
  1810. }), { surfaceOp: 'append' })
  1811. session.append('step/start', { turn, step: 1 })
  1812. if (turn === 1) {
  1813. session.append('request/header', {
  1814. header: { config: { provider: MODEL, model: MODEL } },
  1815. reason: 'initial',
  1816. })
  1817. }
  1818. session.append('assistant/message', {
  1819. stream: [],
  1820. turn,
  1821. step: 1,
  1822. message: createMessage({
  1823. role: 'assistant',
  1824. content: [{ type: 'text', text: `ok ${turn}` }],
  1825. source: {
  1826. kind: 'model',
  1827. ...{ provider: MODEL, model: MODEL },
  1828. },
  1829. }),
  1830. }, { surfaceOp: 'append' })
  1831. session.append('step/end', { turn, step: 1 })
  1832. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  1833. }
  1834. session.append('turn/start', { turn: turns + 1 })
  1835. return session
  1836. }
  1837. it('selects an image-dense range only when the routed price counts visual tokens', () => {
  1838. const session = imageConversation()
  1839. const routed = pricedContext().tokenMeter.measure(session)
  1840. const neutral = createContext().tokenMeter.measure(session)
  1841. expect(routed.surfaceTokens).toBeGreaterThan(neutral.surfaceTokens + 4 * IMAGE_VISUAL_TOKENS - 200)
  1842. expect(routed.nodes.map(node => node.seq)).toEqual(neutral.nodes.map(node => node.seq))
  1843. expect(routed.nodes.map(node => node.heuristicTokens)).toEqual(neutral.nodes.map(node => node.tokens))
  1844. // The same verbatim tail budget retains almost everything under the
  1845. // neutral heuristic but forces a cut once visual tokens are counted.
  1846. expect(selectCompactableRange(session, neutral, 350)).toBeNull()
  1847. const range = selectCompactableRange(session, routed, 350)
  1848. expect(range).not.toBeNull()
  1849. })
  1850. it('accepts a summary larger than the span heuristic when the route price shrinks', async () => {
  1851. // A single short image message prices below a framed summary under the
  1852. // fixed heuristic but far above it under the route: the shrink comparison
  1853. // must ask whether the replacement lowers route pressure.
  1854. const ctx = pricedContext(1_000)
  1855. const session = imageConversation(1)
  1856. const before = ctx.tokenMeter.measure(session)
  1857. const imageNode = before.nodes[0]!
  1858. const compact = new TestCompactionEngine(ctx, { auto: false })
  1859. compact.summary = [{
  1860. type: 'text',
  1861. text: 'summary text sized between the heuristic and route prices of the shadowed image message, '
  1862. + 'long enough that the fixed heuristic alone would reject it as not smaller '
  1863. + 'while the route-priced comparison accepts the pressure reduction.',
  1864. }]
  1865. const framed = ctx.tokenMeter.estimateMessage(createUserMessage({
  1866. content: frameSummary(compact.summary),
  1867. source: { kind: 'plugin', plugin: 'test' },
  1868. }))
  1869. expect(framed).toBeGreaterThan(imageNode.heuristicTokens)
  1870. expect(framed).toBeLessThan(imageNode.tokens)
  1871. const result = await compact.compactRegion(imageNode.seq, imageNode.seq, agent(session), SIGNAL)
  1872. expect(result.shadowedSeqs).toEqual([imageNode.seq])
  1873. expect(result.shadowedTokenCount).toBe(imageNode.heuristicTokens)
  1874. })
  1875. it('triggers pressure compaction from routed visual tokens and logs heuristic shadow prices', async () => {
  1876. const ctx = pricedContext(1_000)
  1877. const session = imageConversation()
  1878. const before = ctx.tokenMeter.measure(session)
  1879. const compact = new TestCompactionEngine(ctx, {
  1880. auto: false,
  1881. thresholdRatio: 0.8,
  1882. retainTokens: 350,
  1883. })
  1884. // The same history stays below the 800-token threshold without pricing.
  1885. const neutralResult = await compactIfNeeded(service({
  1886. auto: false,
  1887. thresholdRatio: 0.8,
  1888. retainTokens: 350,
  1889. }), session)
  1890. expect(neutralResult).toBeNull()
  1891. const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL)
  1892. expect(result).not.toBeNull()
  1893. const summaryEvent = session.snapshotEvents().find(event => event.type === 'compaction/summary')
  1894. expect(summaryEvent).toBeDefined()
  1895. const shadowedHeuristic = before.nodes
  1896. .filter(node => result?.shadowedSeqs.includes(node.seq))
  1897. .reduce((total, node) => total + node.heuristicTokens, 0)
  1898. expect(summaryEvent?.data.shadowedTokenCount).toBe(shadowedHeuristic)
  1899. })
  1900. })