compact-basic.spec.ts 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
  4. import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
  5. import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
  6. import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
  7. import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts'
  8. import type { CompactionResult } from '@deepseek-ai/dsh-compact'
  9. import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm'
  10. import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  11. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  12. import TokenMeterService from '@deepseek-ai/dsh-token-meter'
  13. import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
  14. import type { Agent } from '@deepseek-ai/dsh-agent'
  15. const SIGNAL = new AbortController().signal
  16. const MODEL = 'test-model'
  17. function createContext(contextWindow = 1_000): Context {
  18. const ctx = new Context()
  19. void new TokenMeterService(ctx, { contextWindow })
  20. return ctx
  21. }
  22. function agent(session: Session, model?: string): Agent {
  23. return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
  24. }
  25. /** Closed two-message turns followed by one open turn for durable compaction events. */
  26. function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
  27. const session = new Session(SessionId(`conversation-${turns}`))
  28. for (let turn = 1; turn <= turns; turn += 1) {
  29. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  30. session.append('user/message', {
  31. content: [{ type: 'text', text: `${text} user ${turn}` }],
  32. source: { kind: 'user' },
  33. }, { surfaceOp: 'append' })
  34. session.append('step/start', { turn, step: 1 })
  35. if (turn === 1) {
  36. session.append('request/header', {
  37. header: { config: { provider: MODEL, model: MODEL } },
  38. reason: 'initial',
  39. })
  40. }
  41. session.append('assistant/message', {
  42. provenance: { provider: MODEL, model: MODEL },
  43. turn,
  44. step: 1,
  45. content: [{ type: 'text', text: `${text} assistant ${turn}` }],
  46. }, { surfaceOp: 'append' })
  47. session.append('step/end', { turn, step: 1 })
  48. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  49. }
  50. session.append('turn/start', {
  51. turn: turns + 1,
  52. trigger: { kind: 'message', source: { kind: 'user' } },
  53. })
  54. return session
  55. }
  56. function toolConversation(): Session {
  57. const session = new Session(SessionId('tools'))
  58. for (let turn = 1; turn <= 3; turn += 1) {
  59. const callId = CallId(`call-${turn}`)
  60. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  61. session.append('user/message', {
  62. content: [{ type: 'text', text: `request ${turn} `.repeat(300) }],
  63. source: { kind: 'user' },
  64. }, { surfaceOp: 'append' })
  65. session.append('step/start', { turn, step: 1 })
  66. if (turn === 1) {
  67. session.append('request/header', {
  68. header: { config: { provider: MODEL, model: MODEL } },
  69. reason: 'initial',
  70. })
  71. }
  72. session.append('assistant/message', {
  73. provenance: { provider: MODEL, model: MODEL },
  74. turn,
  75. step: 1,
  76. content: [
  77. { type: 'text', text: `calling ${turn} `.repeat(300) },
  78. { type: 'tool-call', id: callId, name: 'read', arguments: '{}' },
  79. ],
  80. }, { surfaceOp: 'append' })
  81. session.append('tool/call', { turn, step: 1, callId, name: 'read', arguments: '{}' })
  82. session.append('tool/result', {
  83. turn,
  84. step: 1,
  85. callId,
  86. content: [{ type: 'text', text: `result ${turn} `.repeat(300) }],
  87. isError: false,
  88. }, { surfaceOp: 'append' })
  89. session.append('step/end', { turn, step: 1 })
  90. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  91. }
  92. session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
  93. return session
  94. }
  95. /** One closed routed tool step followed by an open turn for rewrite events. */
  96. function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
  97. const session = new Session(SessionId(`oversized-tool-${chars}`))
  98. const callId = CallId('oversized')
  99. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  100. if (withCompactablePrompt) {
  101. session.append('user/message', {
  102. content: [{ type: 'text', text: 'older history '.repeat(200) }],
  103. source: { kind: 'user' },
  104. }, { surfaceOp: 'append' })
  105. }
  106. session.append('step/start', { turn: 1, step: 1 })
  107. session.append('request/header', {
  108. header: { config: { provider: MODEL, model: MODEL } },
  109. reason: 'initial',
  110. })
  111. session.append('assistant/message', {
  112. turn: 1,
  113. step: 1,
  114. content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
  115. provenance: { provider: MODEL, model: MODEL },
  116. }, { surfaceOp: 'append' })
  117. session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
  118. session.append('tool/result', {
  119. turn: 1,
  120. step: 1,
  121. callId,
  122. content: [{ type: 'text', text: 'X'.repeat(chars) }],
  123. isError: false,
  124. meta: { presentation: 'preserved' },
  125. }, { surfaceOp: 'append' })
  126. session.append('step/end', { turn: 1, step: 1 })
  127. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  128. session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
  129. return session
  130. }
  131. class TestCompactService extends BasicCompactService {
  132. summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
  133. summaryProvider = 'summary-provider'
  134. summaryModel = 'summary-model'
  135. error: unknown
  136. mutateDuringSummary: (() => void) | undefined
  137. calls: Array<{ text: string; signal: AbortSignal | undefined }> = []
  138. override async summarize(
  139. text: string,
  140. _agent: Agent,
  141. signal?: AbortSignal,
  142. ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
  143. this.calls.push({ text, signal })
  144. this.mutateDuringSummary?.()
  145. if (this.error !== undefined) throw this.error
  146. return {
  147. summary: this.summary,
  148. provider: this.summaryProvider,
  149. model: this.summaryModel,
  150. maxTokens: 123,
  151. }
  152. }
  153. }
  154. function service(
  155. config: BasicCompactConfig = { auto: false },
  156. ctx = createContext(),
  157. ): TestCompactService {
  158. return new TestCompactService(ctx, config)
  159. }
  160. async function compactIfNeeded(
  161. compact: BasicCompactService,
  162. session: Session,
  163. trigger: 'pressure' | 'context-overflow' = 'pressure',
  164. model: string | undefined = MODEL,
  165. ): Promise<CompactionResult | null> {
  166. return compact.compactIfNeeded(agent(session, model), trigger, SIGNAL)
  167. }
  168. describe('compact configuration and defaults', () => {
  169. it('uses low-friction service-wide defaults', () => {
  170. const ctx = createContext()
  171. const resolved = resolveConfig({}, ctx.tokenMeter)
  172. expect(resolved).toEqual({
  173. thresholdRatio: 0.8,
  174. retainTokens: 160,
  175. summarizationProvider: '',
  176. summarizationModel: '',
  177. maxTokens: 8192,
  178. compactionRetries: 1,
  179. maxOverflowRetries: 1,
  180. auto: true,
  181. })
  182. expect(Object.isFrozen(resolved)).toBe(true)
  183. })
  184. it('resolves threshold and retention overrides independently', () => {
  185. const ctx = createContext()
  186. const thresholdOnly = resolveConfig({
  187. thresholdRatio: 0.5,
  188. }, ctx.tokenMeter)
  189. expect(thresholdOnly).toMatchObject({
  190. thresholdRatio: 0.5,
  191. retainTokens: 160,
  192. })
  193. const retentionOnly = resolveConfig({
  194. retainTokens: 70,
  195. }, ctx.tokenMeter)
  196. expect(retentionOnly).toMatchObject({
  197. thresholdRatio: 0.8,
  198. retainTokens: 70,
  199. })
  200. })
  201. it('validates common values and pressure-policy invariants', () => {
  202. const ctx = createContext()
  203. const bad = [
  204. [{ maxTokens: 0 }, /maxTokens/],
  205. [{ compactionRetries: -1 }, /compactionRetries/],
  206. [{ maxOverflowRetries: -1 }, /maxOverflowRetries/],
  207. [{ auto: 'yes' }, /auto must be a boolean/],
  208. [{ summarizationProvider: 1 }, /summarizationProvider must be a string/],
  209. [{ summarizationModel: 1 }, /summarizationModel must be a string/],
  210. [{ summarizationProvider: MODEL }, /must both be set or both be empty/],
  211. [{ summarizationModel: MODEL }, /must both be set or both be empty/],
  212. [{ thresholdRatio: 0 }, /number in \(0, 1\]/],
  213. [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
  214. [{ retainTokens: -1 }, /non-negative integer/],
  215. [{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/],
  216. [{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/],
  217. [{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/],
  218. ] as Array<[unknown, RegExp]>
  219. for (const [config, pattern] of bad) {
  220. expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern)
  221. }
  222. })
  223. })
  224. describe('pressure measurement and retention', () => {
  225. const compactConfig: BasicCompactConfig = {
  226. auto: false,
  227. thresholdRatio: 0.5,
  228. retainTokens: 180,
  229. }
  230. it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
  231. const compact = service(compactConfig)
  232. const session = new Session(SessionId('headerless'))
  233. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  234. await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL))
  235. .resolves.toBeNull()
  236. expect(compact.calls).toHaveLength(0)
  237. })
  238. it('meters any routed model without profile resolution', async () => {
  239. const compact = service(compactConfig)
  240. const session = conversation()
  241. session.append('request/header', {
  242. header: { config: { provider: 'unlisted-provider', model: 'unlisted-model' } },
  243. reason: 'resume',
  244. })
  245. await expect(compactIfNeeded(compact, session))
  246. .resolves.not.toBeNull()
  247. })
  248. it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
  249. const compact = service(compactConfig)
  250. const session = new Session(SessionId('single-tool-pair'))
  251. const callId = CallId('single-call')
  252. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  253. session.append('step/start', { turn: 1, step: 1 })
  254. session.append('request/header', {
  255. header: { config: { provider: MODEL, model: MODEL } },
  256. reason: 'initial',
  257. })
  258. session.append('assistant/message', {
  259. provenance: { provider: MODEL, model: MODEL },
  260. turn: 1,
  261. step: 1,
  262. content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
  263. }, { surfaceOp: 'append' })
  264. session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
  265. session.append('tool/result', {
  266. turn: 1,
  267. step: 1,
  268. callId,
  269. content: [{ type: 'text', text: 'result' }],
  270. isError: false,
  271. }, { surfaceOp: 'append' })
  272. session.append('step/end', { turn: 1, step: 1 })
  273. const generation = session.surface.replaceGeneration
  274. await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull()
  275. expect(session.surface.replaceGeneration).toBe(generation)
  276. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  277. })
  278. it('does nothing below threshold and compacts a priced head above threshold', async () => {
  279. const compact = service(compactConfig)
  280. expect(await compactIfNeeded(compact, conversation(2))).toBeNull()
  281. const session = conversation(4)
  282. const result = await compactIfNeeded(compact, session)
  283. expect(result).not.toBeNull()
  284. expect(result?.shadowedSeqs.length).toBeGreaterThan(2)
  285. expect(session.surface.nodes.length).toBeLessThan(8)
  286. })
  287. it('counts the durable routed request envelope without putting its prefix on the surface', async () => {
  288. const compact = service({
  289. auto: false,
  290. thresholdRatio: 0.9,
  291. retainTokens: 50,
  292. })
  293. const session = conversation(2, 'x'.repeat(600))
  294. expect(await compactIfNeeded(compact, session)).toBeNull()
  295. const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }]
  296. session.append('request/header', {
  297. header: {
  298. config: { provider: MODEL, model: MODEL },
  299. system: 's'.repeat(600),
  300. messagePrefix: prefix,
  301. },
  302. reason: 'resume',
  303. })
  304. const result = await compactIfNeeded(compact, session)
  305. expect(result).not.toBeNull()
  306. expect(prefix).toHaveLength(1)
  307. expect(session.events.some(event => event.type === 'context/message')).toBe(false)
  308. })
  309. it('uses the latest logged request envelope without an AgentOptions override', async () => {
  310. const ctx = createContext()
  311. const compact = service({
  312. auto: false,
  313. thresholdRatio: 0.5,
  314. retainTokens: 180,
  315. }, ctx)
  316. const session = conversation(4)
  317. session.append('request/header', {
  318. header: { config: { provider: 'actual', model: 'actual' } },
  319. reason: 'initial',
  320. })
  321. const measure = vi.spyOn(ctx.tokenMeter, 'measure')
  322. const result = await compactIfNeeded(compact, session, 'pressure', 'fallback')
  323. expect(result).not.toBeNull()
  324. expect(session.requestHeader()?.config.model).toBe('actual')
  325. expect(measure.mock.calls[0]).toEqual([session])
  326. })
  327. it('declines when envelope pressure is high but the surface has no compactable range', async () => {
  328. const compact = service(compactConfig)
  329. const empty = new Session(SessionId('empty'))
  330. empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  331. empty.append('request/header', {
  332. header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
  333. reason: 'initial',
  334. })
  335. expect(await compactIfNeeded(compact, empty)).toBeNull()
  336. const retained = conversation(1)
  337. retained.append('request/header', {
  338. header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
  339. reason: 'resume',
  340. })
  341. expect(await compactIfNeeded(compact, retained)).toBeNull()
  342. })
  343. it('uses one unified measurement for each pressure-and-retention decision', async () => {
  344. const ctx = createContext()
  345. const compact = service(compactConfig, ctx)
  346. const measure = vi.spyOn(ctx.tokenMeter, 'measure')
  347. const stop = new Error('stop after first decision')
  348. vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop)
  349. await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop)
  350. expect(measure).toHaveBeenCalledTimes(1)
  351. })
  352. it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
  353. const compact = service({
  354. auto: false,
  355. compactionRetries: 0,
  356. thresholdRatio: 0.3,
  357. retainTokens: 180,
  358. })
  359. compact.summary = Array.from({ length: 7 }, (_, index) => ({
  360. type: 'text',
  361. text: `summary ${index}`,
  362. }))
  363. await expect(compactIfNeeded(compact, conversation(4)))
  364. .rejects.toThrow(/still above threshold after 1 compaction attempts/)
  365. })
  366. it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => {
  367. const compact = service({
  368. auto: false,
  369. thresholdRatio: 0.8,
  370. retainTokens: 80,
  371. }, createContext(4_000))
  372. const session = toolConversation()
  373. const result = await compactIfNeeded(compact, session)
  374. expect(result).not.toBeNull()
  375. const messages = session.deriveMessages()
  376. const calls = new Set<string>()
  377. for (const message of messages) {
  378. for (const block of message.content) {
  379. if (block.type === 'tool-call') calls.add(block.id)
  380. if (block.type === 'tool-result') expect(calls.has(block.toolCallId)).toBe(true)
  381. }
  382. }
  383. })
  384. it('rejects a priced surface that is not the current positional surface', () => {
  385. const ctx = createContext()
  386. const session = conversation(2)
  387. const priced = ctx.tokenMeter.measure(session)
  388. expect(() => selectCompactableRange(session, {
  389. ...priced,
  390. nodes: priced.nodes.slice(1),
  391. }, 1)).toThrow(/does not match/)
  392. })
  393. it('declines when rounding a cut would consume the only tool pair', () => {
  394. const ctx = createContext()
  395. const session = new Session(SessionId('one-tool-pair'))
  396. const callId = CallId('only')
  397. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  398. session.append('step/start', { turn: 1, step: 1 })
  399. session.append('assistant/message', {
  400. provenance: { provider: MODEL, model: MODEL },
  401. turn: 1,
  402. step: 1,
  403. content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
  404. }, { surfaceOp: 'append' })
  405. session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
  406. session.append('tool/result', {
  407. turn: 1,
  408. step: 1,
  409. callId,
  410. content: [{ type: 'text', text: 'result' }],
  411. isError: false,
  412. }, { surfaceOp: 'append' })
  413. session.append('step/end', { turn: 1, step: 1 })
  414. const priced = ctx.tokenMeter.measure(session)
  415. expect(selectCompactableRange(session, priced, 1)).toBeNull()
  416. })
  417. })
  418. describe('optional model-free tool-result pruning', () => {
  419. const pruneConfig = { thresholdChars: 100, headChars: 20, tailChars: 10 }
  420. it('does not prune a below-pressure session opportunistically', async () => {
  421. const ctx = createContext(10_000)
  422. const prune = new ToolResultPruneService(ctx, pruneConfig)
  423. const compact = new TestCompactService(ctx, {
  424. auto: false,
  425. thresholdRatio: 0.8,
  426. retainTokens: 100,
  427. })
  428. const session = oversizedToolResult()
  429. const pruneSession = vi.spyOn(prune, 'pruneSession')
  430. expect(await compactIfNeeded(compact, session)).toBeNull()
  431. expect(pruneSession).not.toHaveBeenCalled()
  432. expect(compact.calls).toHaveLength(0)
  433. expect(session.surface.replaceGeneration).toBe(0)
  434. })
  435. it('skips LLM summarization when pruning alone clears pressure', async () => {
  436. const ctx = createContext(1_000)
  437. void new ToolResultPruneService(ctx, pruneConfig)
  438. const compact = new TestCompactService(ctx, {
  439. auto: false,
  440. thresholdRatio: 0.5,
  441. retainTokens: 50,
  442. })
  443. const session = oversizedToolResult()
  444. expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(500)
  445. expect(await compactIfNeeded(compact, session)).toBeNull()
  446. expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(500)
  447. expect(compact.calls).toHaveLength(0)
  448. expect(session.surface.replaceGeneration).toBe(1)
  449. })
  450. it('summarizes the pruned surface when pruning is insufficient', async () => {
  451. const ctx = createContext(2_000)
  452. void new ToolResultPruneService(ctx, pruneConfig)
  453. const compact = new TestCompactService(ctx, {
  454. auto: false,
  455. thresholdRatio: 0.5,
  456. retainTokens: 50,
  457. })
  458. const session = toolConversation()
  459. expect(await compactIfNeeded(compact, session)).not.toBeNull()
  460. expect(compact.calls).toHaveLength(1)
  461. expect(compact.calls[0]!.text).toContain('tool result middle pruned')
  462. expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300))
  463. })
  464. it('retains the original compact-basic behavior without the optional plugin', async () => {
  465. const ctx = createContext(2_000)
  466. const compact = new TestCompactService(ctx, {
  467. auto: false,
  468. thresholdRatio: 0.5,
  469. retainTokens: 50,
  470. })
  471. const session = oversizedToolResult(3_000, true)
  472. expect(await compactIfNeeded(compact, session)).not.toBeNull()
  473. expect(compact.calls).toHaveLength(1)
  474. const original = session.events.find(event => event.type === 'tool/result')
  475. expect(original?.type === 'tool/result' && original.data.content[0])
  476. .toEqual({ type: 'text', text: 'X'.repeat(3_000) })
  477. expect(session.events.filter(event =>
  478. event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
  479. })
  480. })
  481. describe('compaction region transaction', () => {
  482. it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
  483. const compact = service()
  484. const session = conversation(3)
  485. const before = [...session.surface.nodes]
  486. const result = await compact.compactRegion(
  487. before[0]!,
  488. before[3]!,
  489. agent(session, MODEL),
  490. SIGNAL,
  491. )
  492. expect(result.shadowedSeqs).toEqual(before.slice(0, 4))
  493. expect(result.shadowedTokenCount).toBeGreaterThan(0)
  494. expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
  495. expect(compact.calls[0]?.text).toContain('fixture user 1')
  496. const summary = session.events.findLast(event => event.type === 'compact/summary')
  497. expect(summary?.data).toMatchObject({
  498. shadowedSeqs: result.shadowedSeqs,
  499. shadowedTokenCount: result.shadowedTokenCount,
  500. provider: 'summary-provider',
  501. model: 'summary-model',
  502. maxTokens: 123,
  503. })
  504. const head = session.deriveMessages()[0]!
  505. expect(head.content[0]?.type).toBe('text')
  506. expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
  507. expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
  508. const replay = new Session(SessionId('replay'), [...session.events])
  509. expect(replay.deriveMessages()).toEqual(session.deriveMessages())
  510. })
  511. it.each([
  512. ['start missing', 9_001, undefined, /start seq 9001 not found/],
  513. ['end missing', undefined, 9_002, /end seq 9002 not found/],
  514. ])('rejects %s', async (_label, startOverride, endOverride, pattern) => {
  515. const compact = service()
  516. const session = conversation(2)
  517. const nodes = session.surface.nodes
  518. await expect(compact.compactRegion(
  519. startOverride ?? nodes[0]!,
  520. endOverride ?? nodes[1]!,
  521. agent(session, MODEL),
  522. )).rejects.toThrow(pattern)
  523. })
  524. it('rejects reversed and tool-unbalanced positional boundaries', async () => {
  525. const compact = service()
  526. const plain = conversation(2)
  527. const nodes = plain.surface.nodes
  528. await expect(compact.compactRegion(
  529. nodes[2]!,
  530. nodes[1]!,
  531. agent(plain, MODEL),
  532. )).rejects.toThrow(/is after end/)
  533. const tools = toolConversation()
  534. const toolNodes = tools.surface.nodes
  535. await expect(compact.compactRegion(
  536. toolNodes[2]!,
  537. toolNodes[4]!,
  538. agent(tools, MODEL),
  539. )).rejects.toThrow(/start seq .* not a balanced boundary/)
  540. await expect(compact.compactRegion(
  541. toolNodes[0]!,
  542. toolNodes[1]!,
  543. agent(tools, MODEL),
  544. )).rejects.toThrow(/end seq .* not a balanced boundary/)
  545. })
  546. it('requires an open turn and an idle compaction bracket', async () => {
  547. const compact = service()
  548. const closed = conversation(1)
  549. closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  550. const nodes = closed.surface.nodes
  551. await expect(compact.compactRegion(
  552. nodes[0]!,
  553. nodes[1]!,
  554. agent(closed, MODEL),
  555. )).rejects.toThrow(/no open turn/)
  556. const locked = conversation(1)
  557. locked.append('compact/start', { turn: 2 })
  558. const lockedNodes = locked.surface.nodes
  559. await expect(compact.compactRegion(
  560. lockedNodes[0]!,
  561. lockedNodes[1]!,
  562. agent(locked, MODEL),
  563. )).rejects.toThrow(/already in progress/)
  564. })
  565. it('rejects a session with no turn boundary at all', async () => {
  566. const compact = service()
  567. const session = new Session(SessionId('turnless'))
  568. session.append('user/message', {
  569. content: [{ type: 'text', text: 'orphan' }],
  570. source: { kind: 'user' },
  571. }, { surfaceOp: 'append' })
  572. const node = session.surface.nodes[0]!
  573. await expect(compact.compactRegion(
  574. node,
  575. node,
  576. agent(session, MODEL),
  577. )).rejects.toThrow(/no open turn/)
  578. })
  579. it('rejects a meter snapshot that changed before summarization began', async () => {
  580. const ctx = createContext()
  581. const meter = ctx.tokenMeter
  582. const original = meter.measure.bind(meter)
  583. vi.spyOn(meter, 'measure').mockImplementationOnce((session) => {
  584. const measurement = original(session)
  585. return { ...measurement, nodes: measurement.nodes.slice(1) }
  586. })
  587. const compact = service({ auto: false }, ctx)
  588. const session = conversation(2)
  589. const nodes = session.surface.nodes
  590. await expect(compact.compactRegion(
  591. nodes[0]!,
  592. nodes[2]!,
  593. agent(session, MODEL),
  594. )).rejects.toThrow(/selected surface changed/)
  595. })
  596. it('records summarizer failures without mutating the surface', async () => {
  597. const compact = service()
  598. compact.error = new Error('summary unavailable')
  599. const session = conversation(2)
  600. const before = session.surface.nodes
  601. await expect(compact.compactRegion(
  602. before[0]!,
  603. before[2]!,
  604. agent(session, MODEL),
  605. )).rejects.toThrow('summary unavailable')
  606. expect(session.surface.nodes).toEqual(before)
  607. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  608. .toMatchObject({ error: 'summary unavailable' })
  609. })
  610. it('stringifies non-Error failures in the durable end bracket', async () => {
  611. const compact = service()
  612. compact.error = 'plain failure'
  613. const session = conversation(2)
  614. const nodes = session.surface.nodes
  615. await expect(compact.compactRegion(
  616. nodes[0]!,
  617. nodes[2]!,
  618. agent(session, MODEL),
  619. )).rejects.toBe('plain failure')
  620. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  621. .toMatchObject({ error: 'plain failure' })
  622. })
  623. it('rejects concurrent durable appends before committing the replacement', async () => {
  624. const compact = service()
  625. const session = conversation(2)
  626. compact.mutateDuringSummary = () => {
  627. session.append('request/header', {
  628. header: { config: { provider: MODEL, model: MODEL } },
  629. reason: 'initial',
  630. })
  631. }
  632. const nodes = session.surface.nodes
  633. await expect(compact.compactRegion(
  634. nodes[0]!,
  635. nodes[2]!,
  636. agent(session, MODEL),
  637. )).rejects.toThrow(/session log changed/)
  638. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  639. })
  640. it('rejects a non-shrinking framed summary under the conversation meter', async () => {
  641. const compact = service()
  642. compact.summary = Array.from({ length: 100 }, (_, index) => ({
  643. type: 'text',
  644. text: `verbose ${index}`,
  645. }))
  646. const session = conversation(2)
  647. const nodes = session.surface.nodes
  648. await expect(compact.compactRegion(
  649. nodes[0]!,
  650. nodes[2]!,
  651. agent(session, MODEL),
  652. )).rejects.toThrow(/summary is not smaller/)
  653. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  654. })
  655. it('lets a model-independent custom summarizer compact without a conversation model', async () => {
  656. const compact = service()
  657. const session = new Session(SessionId('model-less-region'))
  658. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  659. session.append('user/message', {
  660. content: [{ type: 'text', text: 'history '.repeat(100) }],
  661. source: { kind: 'user' },
  662. }, { surfaceOp: 'append' })
  663. session.append('step/start', { turn: 1, step: 1 })
  664. session.append('assistant/message', {
  665. provenance: { provider: 'historical', model: 'historical' },
  666. turn: 1,
  667. step: 1,
  668. content: [{ type: 'text', text: 'answer '.repeat(100) }],
  669. }, { surfaceOp: 'append' })
  670. session.append('step/end', { turn: 1, step: 1 })
  671. const nodes = session.surface.nodes
  672. await expect(compact.compactRegion(
  673. nodes[0]!,
  674. nodes[1]!,
  675. agent(session),
  676. )).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!, nodes[1]!] })
  677. })
  678. })
  679. class ScriptedAdapter extends LlmAdapter {
  680. lastOptions: GenerateOptions | undefined
  681. constructor(
  682. private readonly blocks: readonly ContentBlock[],
  683. private readonly finish: (StreamChunk & { type: 'finish' })['reason'] = { kind: 'stop' },
  684. ) {
  685. super()
  686. }
  687. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  688. this.lastOptions = options
  689. for (const [index, block] of this.blocks.entries()) {
  690. yield { type: 'block-start', index, blockType: block.type }
  691. if (block.type === 'text') {
  692. yield { type: 'text-delta', index, text: block.text }
  693. } else if (block.type === 'reasoning') {
  694. yield { type: 'reasoning-delta', index, text: block.text }
  695. } else {
  696. yield { type: 'block-end', index, block }
  697. }
  698. }
  699. yield { type: 'finish', reason: this.finish }
  700. }
  701. }
  702. class ExposedCompactService extends BasicCompactService {
  703. runSummarize(
  704. text: string,
  705. owner: Agent,
  706. signal?: AbortSignal,
  707. ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
  708. return this.summarize(text, owner, signal)
  709. }
  710. }
  711. async function summarizerHarness(
  712. blocks: readonly ContentBlock[],
  713. finish?: (StreamChunk & { type: 'finish' })['reason'],
  714. model = MODEL,
  715. config: BasicCompactConfig = { auto: false },
  716. ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> {
  717. const ctx = new Context()
  718. await ctx.plugin(LlmService)
  719. void new TokenMeterService(ctx, { contextWindow: 1_000 })
  720. const adapter = new ScriptedAdapter(blocks, finish)
  721. ctx.llm.registerAdapter([model], adapter)
  722. const compact = new ExposedCompactService(ctx, config)
  723. return { ctx, adapter, compact }
  724. }
  725. describe('default one-shot summarizer', () => {
  726. it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
  727. const { adapter, compact } = await summarizerHarness([
  728. { type: 'reasoning', text: 'private' },
  729. { type: 'text', text: 'public summary' },
  730. { type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
  731. ], undefined, MODEL, {
  732. auto: false,
  733. summarizationProvider: MODEL,
  734. summarizationModel: MODEL,
  735. maxTokens: 321,
  736. })
  737. const session = conversation(1)
  738. const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL)
  739. expect(output).toEqual({
  740. summary: [{ type: 'text', text: 'public summary' }],
  741. provider: MODEL,
  742. model: MODEL,
  743. maxTokens: 321,
  744. })
  745. expect(adapter.lastOptions).toMatchObject({
  746. provider: MODEL,
  747. model: MODEL,
  748. maxTokens: 321,
  749. signal: SIGNAL,
  750. sessionId: session.id,
  751. })
  752. expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent')
  753. })
  754. it('resolves the latest routed provider/model before the AgentOptions pair', async () => {
  755. const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed')
  756. const session = conversation(1)
  757. session.append('request/header', {
  758. header: { config: { provider: 'routed', model: 'routed' } },
  759. reason: 'initial',
  760. })
  761. const output = await compact.runSummarize('history', agent(session, 'fallback'))
  762. expect(output.provider).toBe('routed')
  763. expect(output.model).toBe('routed')
  764. expect(adapter.lastOptions?.provider).toBe('routed')
  765. expect(adapter.lastOptions?.model).toBe('routed')
  766. })
  767. it('records the model actually dispatched after one-shot stream routing', async () => {
  768. const { ctx, compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
  769. const routedAdapter = new ScriptedAdapter([{ type: 'text', text: 'routed summary' }])
  770. ctx.llm.registerAdapter(['routed-summary-provider'], routedAdapter)
  771. ctx.on('llm/stream', (options, next) => {
  772. options.provider = 'routed-summary-provider'
  773. options.model = 'routed-summary-model'
  774. return next()
  775. })
  776. const session = conversation(3, 'large history '.repeat(500))
  777. const nodes = session.surface.nodes
  778. await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL)
  779. expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({
  780. summary: [{ type: 'text', text: 'routed summary' }],
  781. provider: 'routed-summary-provider',
  782. model: 'routed-summary-model',
  783. })
  784. expect(routedAdapter.lastOptions?.provider).toBe('routed-summary-provider')
  785. expect(routedAdapter.lastOptions?.model).toBe('routed-summary-model')
  786. })
  787. it('fails clearly when no complete summarization target can be resolved', async () => {
  788. const ctx = new Context()
  789. await ctx.plugin(LlmService)
  790. void new TokenMeterService(ctx)
  791. const compact = new ExposedCompactService(ctx, { auto: false })
  792. await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less')))))
  793. .rejects.toThrow(/no provider\/model available for summarization/)
  794. })
  795. it.each([
  796. [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/],
  797. [{ kind: 'error', message: 'opaque' }, undefined, /opaque/],
  798. [{ kind: 'aborted' }, 'ABORTED', /aborted/],
  799. [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/],
  800. ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) (
  801. 'rejects terminal finish %#',
  802. async (finish, code, pattern) => {
  803. const { compact } = await summarizerHarness([], finish)
  804. let thrown: unknown
  805. try {
  806. await compact.runSummarize('history', agent(conversation(1), MODEL))
  807. } catch (error: unknown) {
  808. thrown = error
  809. }
  810. expect(thrown).toBeInstanceOf(Error)
  811. expect((thrown as Error).message).toMatch(pattern)
  812. expect((thrown as Error & { code?: string }).code).toBe(code)
  813. },
  814. )
  815. it('rejects empty or reasoning-only successful output', async () => {
  816. const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
  817. await expect(compact.runSummarize('history', agent(conversation(1), MODEL)))
  818. .rejects.toThrow(/no text summary content/)
  819. })
  820. })
  821. describe('automatic listener and loader composition', () => {
  822. function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise<unknown> {
  823. return ctx.serial('agent/post-step', owner, 1, 1, signal)
  824. }
  825. function recover(
  826. ctx: Context,
  827. owner: Agent,
  828. error: Error & { code?: string },
  829. retryAttempt = 0,
  830. signal = SIGNAL,
  831. next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }),
  832. ): Promise<{ action: 'fail' | 'retry' }> {
  833. return ctx.waterfall('agent/request-error', owner, 1, 1, error, retryAttempt, signal, next)
  834. }
  835. function overflow(message = 'provider overflow'): Error & { code: string } {
  836. return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE })
  837. }
  838. it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => {
  839. const ctx = createContext()
  840. const compact = new TestCompactService(ctx, {
  841. thresholdRatio: 0.5,
  842. retainTokens: 180,
  843. })
  844. const pressured = conversation(4)
  845. await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
  846. expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true)
  847. const small = conversation(1)
  848. await postStep(ctx, agent(small, MODEL))
  849. expect(small.events.some(event => event.type === 'compact/start')).toBe(false)
  850. expect(compact.calls).toHaveLength(1)
  851. })
  852. it('skips post-step pressure when the step signal is already aborted', async () => {
  853. const ctx = createContext()
  854. const compact = new TestCompactService(ctx, {
  855. thresholdRatio: 0.5,
  856. retainTokens: 180,
  857. })
  858. const pressured = conversation(4)
  859. const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
  860. await expect(postStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted')))
  861. .resolves.toBeUndefined()
  862. expect(compactIfNeeded).not.toHaveBeenCalled()
  863. expect(pressured.events.some(event => event.type === 'compact/start')).toBe(false)
  864. })
  865. it('warns and continues after operational failures, including non-Errors', async () => {
  866. const ctx = createContext()
  867. const warnings: string[] = []
  868. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  869. const compact = new TestCompactService(ctx, {
  870. thresholdRatio: 0.5,
  871. retainTokens: 180,
  872. })
  873. compact.error = 'temporary failure'
  874. const session = conversation(4)
  875. await expect(postStep(ctx, agent(session, MODEL))).resolves.toBeUndefined()
  876. expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
  877. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  878. })
  879. it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => {
  880. const ctx = createContext(10_000)
  881. void new TestCompactService(ctx, {
  882. thresholdRatio: 1,
  883. retainTokens: 900,
  884. })
  885. const session = conversation(3)
  886. const beforeGeneration = session.surface.replaceGeneration
  887. const retainedSeq = session.surface.nodes.at(-1)!
  888. const threshold = 10_000
  889. expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
  890. const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
  891. expect(decision).toEqual({ action: 'retry' })
  892. expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
  893. expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
  894. expect(session.surface.nodes).toContain(retainedSeq)
  895. })
  896. it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => {
  897. const ctx = createContext(10_000)
  898. void new ToolResultPruneService(ctx, {
  899. thresholdChars: 100,
  900. headChars: 20,
  901. tailChars: 10,
  902. })
  903. const compact = new TestCompactService(ctx, {
  904. thresholdRatio: 1,
  905. retainTokens: 900,
  906. })
  907. const session = oversizedToolResult()
  908. expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
  909. expect(session.surface.replaceGeneration).toBe(1)
  910. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  911. expect(compact.calls).toHaveLength(0)
  912. })
  913. it('continues overflow recovery with summarization on the pruned surface', async () => {
  914. const ctx = createContext(10_000)
  915. void new ToolResultPruneService(ctx, {
  916. thresholdChars: 100,
  917. headChars: 20,
  918. tailChars: 10,
  919. })
  920. const compact = new TestCompactService(ctx, {
  921. thresholdRatio: 1,
  922. retainTokens: 900,
  923. })
  924. const session = toolConversation()
  925. expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
  926. expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
  927. expect(compact.calls).toHaveLength(1)
  928. expect(compact.calls[0]!.text).toContain('tool result middle pruned')
  929. })
  930. it('retries from a durable prune when later overflow summarization throws', async () => {
  931. const ctx = createContext(10_000)
  932. const warnings: string[] = []
  933. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  934. void new ToolResultPruneService(ctx, {
  935. thresholdChars: 100,
  936. headChars: 20,
  937. tailChars: 10,
  938. })
  939. const compact = new TestCompactService(ctx, {
  940. thresholdRatio: 1,
  941. retainTokens: 900,
  942. })
  943. compact.error = new Error('summary unavailable after prune')
  944. const session = oversizedToolResult(3_000, true)
  945. expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
  946. expect(session.surface.replaceGeneration).toBe(1)
  947. expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
  948. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  949. .toMatchObject({ error: 'summary unavailable after prune' })
  950. expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface'))
  951. })
  952. it('lets cancellation win when summary throws after a durable prune', async () => {
  953. const ctx = createContext(10_000)
  954. const controller = new AbortController()
  955. void new ToolResultPruneService(ctx, {
  956. thresholdChars: 100,
  957. headChars: 20,
  958. tailChars: 10,
  959. })
  960. const compact = new TestCompactService(ctx, {
  961. thresholdRatio: 1,
  962. retainTokens: 900,
  963. })
  964. compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') }
  965. compact.error = new Error('summary cancelled after prune')
  966. const session = oversizedToolResult(3_000, true)
  967. expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
  968. .toEqual({ action: 'fail' })
  969. expect(session.surface.replaceGeneration).toBe(1)
  970. })
  971. it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
  972. const ctx = createContext()
  973. void new TestCompactService(ctx, {
  974. thresholdRatio: 1,
  975. retainTokens: 90,
  976. })
  977. const session = toolConversation()
  978. const newestAssistant = session.surface.nodes.at(-2)!
  979. const newestResult = session.surface.nodes.at(-1)!
  980. expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
  981. const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
  982. const currentResult = session.surface.nodes.find(node => node === newestResult)
  983. expect(currentAssistant).toBeDefined()
  984. expect(currentResult).toBeDefined()
  985. expect(toolPairingBalancedBefore(session, currentAssistant!)).toBe(true)
  986. expect(toolPairingBalancedAfter(session, currentResult!)).toBe(true)
  987. })
  988. it('does not retry when a backend reports success without replacing the surface', async () => {
  989. const ctx = createContext()
  990. const compact = new TestCompactService(ctx)
  991. const session = conversation(2)
  992. const fakeResult: CompactionResult = {
  993. startSeq: 1,
  994. summarySeq: 2,
  995. endSeq: 3,
  996. summary: [{ type: 'text', text: 'fake' }],
  997. shadowedRange: { start: 1, end: 2 },
  998. shadowedSeqs: [1, 2],
  999. shadowedTokenCount: 10,
  1000. }
  1001. vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult)
  1002. expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
  1003. expect(session.surface.replaceGeneration).toBe(0)
  1004. })
  1005. it('delegates downstream exactly once when no replacement is available', async () => {
  1006. const ctx = createContext()
  1007. const compact = new TestCompactService(ctx)
  1008. vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(null)
  1009. const downstream = new Error('downstream recovery failed')
  1010. let calls = 0
  1011. await expect(recover(
  1012. ctx,
  1013. agent(conversation(2), MODEL),
  1014. overflow(),
  1015. 0,
  1016. SIGNAL,
  1017. () => {
  1018. calls += 1
  1019. return Promise.reject(downstream)
  1020. },
  1021. )).rejects.toBe(downstream)
  1022. expect(calls).toBe(1)
  1023. })
  1024. it('preserves the original provider error when recovery throws', async () => {
  1025. const ctx = createContext()
  1026. const warnings: string[] = []
  1027. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  1028. const compact = new TestCompactService(ctx)
  1029. compact.error = new Error('summary unavailable')
  1030. const original = overflow('original provider overflow')
  1031. expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' })
  1032. expect(original).toMatchObject({
  1033. message: 'original provider overflow',
  1034. code: CONTEXT_WINDOW_EXCEEDED_CODE,
  1035. })
  1036. expect(warnings).toContainEqual(expect.stringContaining('preserving the original request error'))
  1037. })
  1038. it('delegates once when overflow recovery throws a non-Error value', async () => {
  1039. const ctx = createContext()
  1040. const warnings: string[] = []
  1041. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  1042. const compact = new TestCompactService(ctx)
  1043. compact.error = 'non-error recovery failure'
  1044. const session = conversation(3)
  1045. const generation = session.surface.replaceGeneration
  1046. const original = overflow('original provider failure')
  1047. let delegations = 0
  1048. const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
  1049. delegations += 1
  1050. return Promise.resolve({ action: 'fail' })
  1051. })
  1052. expect(decision).toEqual({ action: 'fail' })
  1053. expect(delegations).toBe(1)
  1054. expect(session.surface.replaceGeneration).toBe(generation)
  1055. expect(original).toMatchObject({
  1056. message: 'original provider failure',
  1057. code: CONTEXT_WINDOW_EXCEEDED_CODE,
  1058. })
  1059. expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure'))
  1060. })
  1061. it('recovers an overflow for an unlisted routed model', async () => {
  1062. const ctx = createContext()
  1063. void new TestCompactService(ctx)
  1064. const session = conversation(2)
  1065. session.append('request/header', {
  1066. header: { config: { provider: 'unknown-routed-provider', model: 'unknown-routed-model' } },
  1067. reason: 'resume',
  1068. })
  1069. expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
  1070. .toEqual({ action: 'retry' })
  1071. })
  1072. it('honors retry caps, non-context failures, and cancellation', async () => {
  1073. const ctx = createContext()
  1074. const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 })
  1075. const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
  1076. const owner = agent(conversation(3), MODEL)
  1077. expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' })))
  1078. .toEqual({ action: 'fail' })
  1079. expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' })
  1080. const controller = new AbortController()
  1081. controller.abort('cancelled')
  1082. expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' })
  1083. expect(compactSpy).not.toHaveBeenCalled()
  1084. })
  1085. it('does not retry when cancellation lands during an awaited compaction', async () => {
  1086. const ctx = createContext()
  1087. const compact = new TestCompactService(ctx)
  1088. const controller = new AbortController()
  1089. compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') }
  1090. const session = conversation(3)
  1091. const generation = session.surface.replaceGeneration
  1092. expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
  1093. .toEqual({ action: 'fail' })
  1094. expect(session.surface.replaceGeneration).toBe(generation + 1)
  1095. })
  1096. it('maxOverflowRetries:0 disables recovery without disabling post-step pressure', async () => {
  1097. const ctx = createContext()
  1098. void new TestCompactService(ctx, {
  1099. maxOverflowRetries: 0,
  1100. thresholdRatio: 0.5,
  1101. retainTokens: 180,
  1102. })
  1103. const session = conversation(4)
  1104. await postStep(ctx, agent(session, MODEL))
  1105. const summaries = session.events.filter(event => event.type === 'compact/summary').length
  1106. expect(summaries).toBe(1)
  1107. expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
  1108. expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries)
  1109. })
  1110. it('auto:false installs neither automatic listener', async () => {
  1111. const ctx = createContext()
  1112. void new TestCompactService(ctx, {
  1113. auto: false,
  1114. thresholdRatio: 0.5,
  1115. retainTokens: 180,
  1116. })
  1117. const session = conversation(4)
  1118. await postStep(ctx, agent(session, MODEL))
  1119. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  1120. expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
  1121. })
  1122. it('loads and disposes the real zero-config service stack', async () => {
  1123. const ctx = new Context()
  1124. await ctx.plugin(LlmService)
  1125. const meterFiber = await ctx.plugin(TokenMeterService)
  1126. const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
  1127. expect(ctx.tokenMeter.contextWindow).toBe(128_000)
  1128. expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
  1129. await compactFiber.dispose()
  1130. expect(ctx.get('compact')).toBeUndefined()
  1131. await meterFiber.dispose()
  1132. expect(ctx.get('tokenMeter')).toBeUndefined()
  1133. })
  1134. it('removes its automatic listener with the plugin fiber', async () => {
  1135. const ctx = new Context()
  1136. await ctx.plugin(LlmService)
  1137. await ctx.plugin(TokenMeterService, { contextWindow: 1_000 })
  1138. const fiber = await ctx.plugin(TestCompactService, {
  1139. thresholdRatio: 0.5,
  1140. retainTokens: 180,
  1141. })
  1142. await fiber.dispose()
  1143. const session = conversation(4)
  1144. await postStep(ctx, agent(session, MODEL))
  1145. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  1146. expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
  1147. })
  1148. })