compact-basic.spec.ts 40 KB

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