compact-basic.spec.ts 60 KB

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