1
0

compaction-basic.spec.ts 69 KB

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