compact-basic.spec.ts 43 KB

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