compact-basic.spec.ts 58 KB

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