compact-basic.spec.ts 48 KB

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