compact-basic.spec.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
  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 type { CompactionResult } from '@deepseek-ai/dsh-compact'
  10. import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
  11. import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm'
  12. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  13. import TokenMeterService, {
  14. TOKEN_METER_MODEL_UNCONFIGURED,
  15. TokenMeterError,
  16. } from '@deepseek-ai/dsh-token-meter'
  17. import type { Agent } from '@deepseek-ai/dsh-agent'
  18. const SIGNAL = new AbortController().signal
  19. const MODEL = 'test-model'
  20. function createContext(
  21. models: Record<string, { contextWindow?: number; charsPerToken?: number }> = {
  22. [MODEL]: { contextWindow: 100, charsPerToken: 1_000 },
  23. },
  24. ): Context {
  25. const ctx = new Context()
  26. void new TokenMeterService(ctx, { models })
  27. return ctx
  28. }
  29. function agent(session: Session, model?: string): Agent {
  30. return { session, options: model === undefined ? {} : { model } } as Agent
  31. }
  32. /** Closed two-message turns followed by one open turn for durable compaction events. */
  33. function conversation(turns = 4, text = 'fixture'): Session {
  34. const session = new Session(SessionId(`conversation-${turns}`))
  35. for (let turn = 1; turn <= turns; turn += 1) {
  36. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  37. session.append('user/message', {
  38. content: [{ type: 'text', text: `${text} user ${turn}` }],
  39. source: { kind: 'user' },
  40. }, { surfaceOp: 'append' })
  41. session.append('step/start', { turn, step: 1 })
  42. session.append('assistant/message', {
  43. turn,
  44. step: 1,
  45. content: [{ type: 'text', text: `${text} assistant ${turn}` }],
  46. }, { surfaceOp: 'append' })
  47. session.append('step/end', { turn, step: 1 })
  48. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  49. }
  50. session.append('turn/start', {
  51. turn: turns + 1,
  52. trigger: { kind: 'message', source: { kind: 'user' } },
  53. })
  54. return session
  55. }
  56. function toolConversation(): Session {
  57. const session = new Session(SessionId('tools'))
  58. for (let turn = 1; turn <= 3; turn += 1) {
  59. const callId = CallId(`call-${turn}`)
  60. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  61. session.append('user/message', {
  62. content: [{ type: 'text', text: `request ${turn} `.repeat(300) }],
  63. source: { kind: 'user' },
  64. }, { surfaceOp: 'append' })
  65. session.append('step/start', { turn, step: 1 })
  66. session.append('assistant/message', {
  67. turn,
  68. step: 1,
  69. content: [
  70. { type: 'text', text: `calling ${turn} `.repeat(300) },
  71. { type: 'tool-call', id: callId, name: 'read', arguments: '{}' },
  72. ],
  73. }, { surfaceOp: 'append' })
  74. session.append('tool/call', { turn, step: 1, callId, name: 'read', arguments: '{}' })
  75. session.append('tool/result', {
  76. turn,
  77. step: 1,
  78. callId,
  79. content: [{ type: 'text', text: `result ${turn} `.repeat(300) }],
  80. isError: false,
  81. }, { surfaceOp: 'append' })
  82. session.append('step/end', { turn, step: 1 })
  83. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  84. }
  85. session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
  86. return session
  87. }
  88. class TestCompactService extends BasicCompactService {
  89. summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
  90. summaryModel = 'summary-model'
  91. error: unknown
  92. mutateDuringSummary: (() => void) | undefined
  93. calls: Array<{ text: string; signal: AbortSignal | undefined }> = []
  94. override async summarize(
  95. text: string,
  96. _agent: Agent,
  97. signal?: AbortSignal,
  98. ): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
  99. this.calls.push({ text, signal })
  100. this.mutateDuringSummary?.()
  101. if (this.error !== undefined) throw this.error
  102. return { summary: this.summary, model: this.summaryModel, maxTokens: 123 }
  103. }
  104. }
  105. function service(
  106. config: BasicCompactConfig = { auto: false },
  107. ctx = createContext(),
  108. ): TestCompactService {
  109. return new TestCompactService(ctx, config)
  110. }
  111. async function compactIfNeeded(
  112. compact: BasicCompactService,
  113. session: Session,
  114. model: string | undefined = MODEL,
  115. system = '',
  116. prefix: readonly Message[] = [],
  117. ): Promise<CompactionResult | null> {
  118. return compact.compactIfNeeded(agent(session, model), system, prefix, SIGNAL)
  119. }
  120. describe('compact configuration and defaults', () => {
  121. it('uses low-friction common and per-profile defaults', () => {
  122. const ctx = createContext({
  123. [MODEL]: { contextWindow: 100, charsPerToken: 1_000 },
  124. large: { contextWindow: 1_000, charsPerToken: 4 },
  125. })
  126. const resolved = resolveConfig({}, ctx.tokenMeter)
  127. expect(resolved).toEqual({
  128. models: {},
  129. summarizationModel: '',
  130. maxTokens: 8192,
  131. compactionRetries: 1,
  132. auto: true,
  133. })
  134. expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve(MODEL))).toEqual({
  135. model: MODEL,
  136. contextWindow: 100,
  137. thresholdRatio: 0.8,
  138. retainTokens: 16,
  139. })
  140. expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve('large')).retainTokens).toBe(160)
  141. expect(Object.isFrozen(resolved)).toBe(true)
  142. })
  143. it('merges threshold and retention overrides field-wise', () => {
  144. const ctx = createContext()
  145. const thresholdOnly = resolveConfig({
  146. models: { [MODEL]: { thresholdRatio: 0.5 } },
  147. }, ctx.tokenMeter)
  148. expect(resolveModelConfig(thresholdOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({
  149. thresholdRatio: 0.5,
  150. retainTokens: 16,
  151. })
  152. const retentionOnly = resolveConfig({
  153. models: { [MODEL]: { retainTokens: 7 } },
  154. }, ctx.tokenMeter)
  155. expect(resolveModelConfig(retentionOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({
  156. thresholdRatio: 0.8,
  157. retainTokens: 7,
  158. })
  159. })
  160. it('validates common values and model policy invariants', () => {
  161. const ctx = createContext()
  162. const bad = [
  163. [{ maxTokens: 0 }, /maxTokens/],
  164. [{ compactionRetries: -1 }, /compactionRetries/],
  165. [{ auto: 'yes' }, /auto must be a boolean/],
  166. [{ summarizationModel: 1 }, /summarizationModel must be a string/],
  167. [{ models: null }, /models must be an object/],
  168. [{ models: { [MODEL]: null } }, /must be an object/],
  169. [{ models: { [MODEL]: { thresholdRatio: 0 } } }, /number in \(0, 1\]/],
  170. [{ models: { [MODEL]: { thresholdRatio: 1.1 } } }, /number in \(0, 1\]/],
  171. [{ models: { [MODEL]: { retainTokens: -1 } } }, /non-negative integer/],
  172. [{ models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 50 } } }, /less than threshold/],
  173. ] as Array<[unknown, RegExp]>
  174. for (const [config, pattern] of bad) {
  175. expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern)
  176. }
  177. })
  178. it('rejects an override for an unknown meter profile with the exact typed error', () => {
  179. const ctx = createContext()
  180. expect(() => resolveConfig({ models: { missing: { retainTokens: 1 } } }, ctx.tokenMeter))
  181. .toThrow(expect.objectContaining({
  182. code: TOKEN_METER_MODEL_UNCONFIGURED,
  183. model: 'missing',
  184. }))
  185. })
  186. })
  187. describe('pressure measurement and retention', () => {
  188. const compactConfig: BasicCompactConfig = {
  189. auto: false,
  190. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  191. }
  192. it('skips the provisional check only when no routed or fallback model exists', async () => {
  193. const compact = service(compactConfig)
  194. const session = conversation()
  195. expect(await compact.compactIfNeeded(agent(session), '', [], SIGNAL)).toBeNull()
  196. expect(compact.calls).toHaveLength(0)
  197. })
  198. it('throws for a named unconfigured model instead of swallowing it', async () => {
  199. const compact = service(compactConfig)
  200. await expect(compactIfNeeded(compact, conversation(), 'missing'))
  201. .rejects.toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED, model: 'missing' })
  202. })
  203. it('does nothing below threshold and compacts a priced head above threshold', async () => {
  204. const compact = service(compactConfig)
  205. expect(await compactIfNeeded(compact, conversation(2))).toBeNull()
  206. const session = conversation(4)
  207. const result = await compactIfNeeded(compact, session)
  208. expect(result).not.toBeNull()
  209. expect(result?.shadowedSeqs.length).toBeGreaterThan(2)
  210. expect(session.surface.nodes.length).toBeLessThan(8)
  211. })
  212. it('counts the current prompt and request prefix without putting either on the surface', async () => {
  213. const compact = service({
  214. auto: false,
  215. models: { [MODEL]: { thresholdRatio: 0.7, retainTokens: 9 } },
  216. })
  217. const session = conversation(2, 'x'.repeat(2_000))
  218. expect(await compactIfNeeded(compact, session)).toBeNull()
  219. const prefix: Message[] = [{
  220. role: 'user',
  221. content: [{ type: 'text', text: 'p'.repeat(10_000) }],
  222. }]
  223. const result = await compactIfNeeded(compact, session, MODEL, 's'.repeat(5_000), prefix)
  224. expect(result).not.toBeNull()
  225. expect(prefix).toHaveLength(1)
  226. expect(session.events.some(event => event.type === 'context/message')).toBe(false)
  227. })
  228. it('uses the latest logged routed model instead of AgentOptions.model', async () => {
  229. const ctx = createContext({
  230. actual: { contextWindow: 100, charsPerToken: 1_000 },
  231. fallback: { contextWindow: 10_000, charsPerToken: 1_000 },
  232. })
  233. const compact = service({
  234. auto: false,
  235. models: { actual: { thresholdRatio: 0.5, retainTokens: 18 } },
  236. }, ctx)
  237. const session = conversation(4)
  238. session.append('request/header', {
  239. header: { config: { model: 'actual' } },
  240. reason: 'initial',
  241. })
  242. const result = await compactIfNeeded(compact, session, 'fallback')
  243. expect(result).not.toBeNull()
  244. })
  245. it('declines when envelope pressure is high but the surface has no compactable range', async () => {
  246. const compact = service(compactConfig)
  247. const empty = new Session(SessionId('empty'))
  248. expect(await compactIfNeeded(compact, empty, MODEL, 'x'.repeat(100_000))).toBeNull()
  249. const retained = conversation(1)
  250. expect(await compactIfNeeded(compact, retained, MODEL, 'x'.repeat(100_000))).toBeNull()
  251. })
  252. it('detects scalar/surface revision disagreement', async () => {
  253. const ctx = createContext()
  254. const meter = ctx.tokenMeter.resolve(MODEL)
  255. const original = meter.measureSurface.bind(meter)
  256. vi.spyOn(meter, 'measureSurface').mockImplementation((session) => {
  257. const measurement = original(session)
  258. return { ...measurement, logRevision: measurement.logRevision - 1 }
  259. })
  260. const compact = service(compactConfig, ctx)
  261. await expect(compactIfNeeded(compact, conversation(4))).rejects.toThrow(/revision/)
  262. })
  263. it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
  264. const compact = service({
  265. auto: false,
  266. compactionRetries: 0,
  267. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  268. })
  269. compact.summary = Array.from({ length: 7 }, (_, index) => ({
  270. type: 'text',
  271. text: `summary ${index}`,
  272. }))
  273. await expect(compactIfNeeded(compact, conversation(4)))
  274. .rejects.toThrow(/still above threshold after 1 compaction attempts/)
  275. })
  276. it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => {
  277. const compact = service({
  278. auto: false,
  279. models: { [MODEL]: { thresholdRatio: 0.8, retainTokens: 8 } },
  280. })
  281. const session = toolConversation()
  282. const result = await compactIfNeeded(compact, session)
  283. expect(result).not.toBeNull()
  284. const messages = session.deriveMessages()
  285. const calls = new Set<string>()
  286. for (const message of messages) {
  287. for (const block of message.content) {
  288. if (block.type === 'tool-call') calls.add(block.id)
  289. if (block.type === 'tool-result') expect(calls.has(block.toolCallId)).toBe(true)
  290. }
  291. }
  292. })
  293. it('rejects a priced surface that is not the current positional surface', () => {
  294. const ctx = createContext()
  295. const session = conversation(2)
  296. const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session)
  297. expect(() => selectCompactableRange(session, {
  298. ...priced,
  299. nodes: priced.nodes.slice(1),
  300. }, 1)).toThrow(/does not match/)
  301. })
  302. it('declines when rounding a cut would consume the only tool pair', () => {
  303. const ctx = createContext()
  304. const session = new Session(SessionId('one-tool-pair'))
  305. const callId = CallId('only')
  306. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  307. session.append('step/start', { turn: 1, step: 1 })
  308. session.append('assistant/message', {
  309. turn: 1,
  310. step: 1,
  311. content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
  312. }, { surfaceOp: 'append' })
  313. session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
  314. session.append('tool/result', {
  315. turn: 1,
  316. step: 1,
  317. callId,
  318. content: [{ type: 'text', text: 'result' }],
  319. isError: false,
  320. }, { surfaceOp: 'append' })
  321. session.append('step/end', { turn: 1, step: 1 })
  322. const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session)
  323. expect(selectCompactableRange(session, priced, 1)).toBeNull()
  324. })
  325. })
  326. describe('compaction region transaction', () => {
  327. it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
  328. const compact = service()
  329. const session = conversation(3)
  330. const before = session.surface.nodes
  331. const result = await compact.compactRegion(
  332. session,
  333. before[0]!.seq,
  334. before[3]!.seq,
  335. agent(session, MODEL),
  336. SIGNAL,
  337. )
  338. expect(result.shadowedSeqs).toEqual(before.slice(0, 4).map(node => node.seq))
  339. expect(result.shadowedTokenCount).toBeGreaterThan(0)
  340. expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
  341. expect(compact.calls[0]?.text).toContain('fixture user 1')
  342. const summary = session.events.findLast(event => event.type === 'compact/summary')
  343. expect(summary?.data).toMatchObject({
  344. shadowedSeqs: result.shadowedSeqs,
  345. shadowedTokenCount: result.shadowedTokenCount,
  346. model: 'summary-model',
  347. maxTokens: 123,
  348. })
  349. const head = session.deriveMessages()[0]!
  350. expect(head.content[0]?.type).toBe('text')
  351. expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
  352. expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
  353. const replay = new Session(SessionId('replay'), [...session.events])
  354. expect(replay.deriveMessages()).toEqual(session.deriveMessages())
  355. })
  356. it.each([
  357. ['start missing', 9_001, undefined, /start seq 9001 not found/],
  358. ['end missing', undefined, 9_002, /end seq 9002 not found/],
  359. ])('rejects %s', async (_label, startOverride, endOverride, pattern) => {
  360. const compact = service()
  361. const session = conversation(2)
  362. const nodes = session.surface.nodes
  363. await expect(compact.compactRegion(
  364. session,
  365. startOverride ?? nodes[0]!.seq,
  366. endOverride ?? nodes[1]!.seq,
  367. agent(session, MODEL),
  368. )).rejects.toThrow(pattern)
  369. })
  370. it('rejects reversed and tool-unbalanced positional boundaries', async () => {
  371. const compact = service()
  372. const plain = conversation(2)
  373. const nodes = plain.surface.nodes
  374. await expect(compact.compactRegion(
  375. plain,
  376. nodes[2]!.seq,
  377. nodes[1]!.seq,
  378. agent(plain, MODEL),
  379. )).rejects.toThrow(/is after end/)
  380. const tools = toolConversation()
  381. const toolNodes = tools.surface.nodes
  382. await expect(compact.compactRegion(
  383. tools,
  384. toolNodes[2]!.seq,
  385. toolNodes[4]!.seq,
  386. agent(tools, MODEL),
  387. )).rejects.toThrow(/start seq .* not a balanced boundary/)
  388. await expect(compact.compactRegion(
  389. tools,
  390. toolNodes[0]!.seq,
  391. toolNodes[1]!.seq,
  392. agent(tools, MODEL),
  393. )).rejects.toThrow(/end seq .* not a balanced boundary/)
  394. })
  395. it('requires an open turn and an idle compaction bracket', async () => {
  396. const compact = service()
  397. const closed = conversation(1)
  398. closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  399. const nodes = closed.surface.nodes
  400. await expect(compact.compactRegion(
  401. closed,
  402. nodes[0]!.seq,
  403. nodes[1]!.seq,
  404. agent(closed, MODEL),
  405. )).rejects.toThrow(/no open turn/)
  406. const locked = conversation(1)
  407. locked.append('compact/start', { turn: 2 })
  408. const lockedNodes = locked.surface.nodes
  409. await expect(compact.compactRegion(
  410. locked,
  411. lockedNodes[0]!.seq,
  412. lockedNodes[1]!.seq,
  413. agent(locked, MODEL),
  414. )).rejects.toThrow(/already in progress/)
  415. })
  416. it('rejects a session with no turn boundary at all', async () => {
  417. const compact = service()
  418. const session = new Session(SessionId('turnless'))
  419. session.append('user/message', {
  420. content: [{ type: 'text', text: 'orphan' }],
  421. source: { kind: 'user' },
  422. }, { surfaceOp: 'append' })
  423. const node = session.surface.nodes[0]!
  424. await expect(compact.compactRegion(
  425. session,
  426. node.seq,
  427. node.seq,
  428. agent(session, MODEL),
  429. )).rejects.toThrow(/no open turn/)
  430. })
  431. it('rejects a meter snapshot that changed before summarization began', async () => {
  432. const ctx = createContext()
  433. const meter = ctx.tokenMeter.resolve(MODEL)
  434. const original = meter.measureSurface.bind(meter)
  435. vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => {
  436. const measurement = original(session)
  437. return { ...measurement, nodes: measurement.nodes.slice(1) }
  438. })
  439. const compact = service({ auto: false }, ctx)
  440. const session = conversation(2)
  441. const nodes = session.surface.nodes
  442. await expect(compact.compactRegion(
  443. session,
  444. nodes[0]!.seq,
  445. nodes[2]!.seq,
  446. agent(session, MODEL),
  447. )).rejects.toThrow(/selected surface changed/)
  448. })
  449. it('records summarizer failures without mutating the surface', async () => {
  450. const compact = service()
  451. compact.error = new Error('summary unavailable')
  452. const session = conversation(2)
  453. const before = session.surface.nodes
  454. await expect(compact.compactRegion(
  455. session,
  456. before[0]!.seq,
  457. before[2]!.seq,
  458. agent(session, MODEL),
  459. )).rejects.toThrow('summary unavailable')
  460. expect(session.surface.nodes).toEqual(before)
  461. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  462. .toMatchObject({ error: 'summary unavailable' })
  463. })
  464. it('stringifies non-Error failures in the durable end bracket', async () => {
  465. const compact = service()
  466. compact.error = 'plain failure'
  467. const session = conversation(2)
  468. const nodes = session.surface.nodes
  469. await expect(compact.compactRegion(
  470. session,
  471. nodes[0]!.seq,
  472. nodes[2]!.seq,
  473. agent(session, MODEL),
  474. )).rejects.toBe('plain failure')
  475. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  476. .toMatchObject({ error: 'plain failure' })
  477. })
  478. it('rejects concurrent durable appends before committing the replacement', async () => {
  479. const compact = service()
  480. const session = conversation(2)
  481. compact.mutateDuringSummary = () => {
  482. session.append('request/header', {
  483. header: { config: { model: MODEL } },
  484. reason: 'initial',
  485. })
  486. }
  487. const nodes = session.surface.nodes
  488. await expect(compact.compactRegion(
  489. session,
  490. nodes[0]!.seq,
  491. nodes[2]!.seq,
  492. agent(session, MODEL),
  493. )).rejects.toThrow(/session log changed/)
  494. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  495. })
  496. it('rejects a non-shrinking framed summary under the conversation meter', async () => {
  497. const compact = service()
  498. compact.summary = Array.from({ length: 20 }, (_, index) => ({
  499. type: 'text',
  500. text: `verbose ${index}`,
  501. }))
  502. const session = conversation(2)
  503. const nodes = session.surface.nodes
  504. await expect(compact.compactRegion(
  505. session,
  506. nodes[0]!.seq,
  507. nodes[2]!.seq,
  508. agent(session, MODEL),
  509. )).rejects.toThrow(/summary is not smaller/)
  510. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  511. })
  512. it('requires a conversation model for pricing', async () => {
  513. const compact = service()
  514. const session = conversation(1)
  515. const nodes = session.surface.nodes
  516. await expect(compact.compactRegion(
  517. session,
  518. nodes[0]!.seq,
  519. nodes[1]!.seq,
  520. agent(session),
  521. )).rejects.toThrow(/no routed or configured conversation model/)
  522. })
  523. })
  524. class ScriptedAdapter extends LlmAdapter {
  525. lastOptions: GenerateOptions | undefined
  526. constructor(
  527. private readonly blocks: readonly ContentBlock[],
  528. private readonly finish: (StreamChunk & { type: 'finish' })['reason'] = { kind: 'stop' },
  529. ) {
  530. super()
  531. }
  532. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  533. this.lastOptions = options
  534. for (const [index, block] of this.blocks.entries()) {
  535. yield { type: 'block-start', index, blockType: block.type }
  536. if (block.type === 'text') {
  537. yield { type: 'text-delta', index, text: block.text }
  538. } else if (block.type === 'reasoning') {
  539. yield { type: 'reasoning-delta', index, text: block.text }
  540. } else {
  541. yield { type: 'block-end', index, block }
  542. }
  543. }
  544. yield { type: 'finish', reason: this.finish }
  545. }
  546. }
  547. async function summarizerHarness(
  548. blocks: readonly ContentBlock[],
  549. finish?: (StreamChunk & { type: 'finish' })['reason'],
  550. model = MODEL,
  551. config: BasicCompactConfig = { auto: false },
  552. ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
  553. const ctx = new Context()
  554. await ctx.plugin(LlmService)
  555. void new TokenMeterService(ctx, { models: { [model]: { contextWindow: 100 } } })
  556. const adapter = new ScriptedAdapter(blocks, finish)
  557. ctx.llm.registerAdapter([model], adapter)
  558. const compact = new BasicCompactService(ctx, config)
  559. return { ctx, adapter, compact }
  560. }
  561. describe('default one-shot summarizer', () => {
  562. it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
  563. const { adapter, compact } = await summarizerHarness([
  564. { type: 'reasoning', text: 'private' },
  565. { type: 'text', text: 'public summary' },
  566. { type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
  567. ], undefined, MODEL, {
  568. auto: false,
  569. summarizationModel: MODEL,
  570. maxTokens: 321,
  571. })
  572. const session = conversation(1)
  573. const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL)
  574. expect(output).toEqual({
  575. summary: [{ type: 'text', text: 'public summary' }],
  576. model: MODEL,
  577. maxTokens: 321,
  578. })
  579. expect(adapter.lastOptions).toMatchObject({
  580. model: MODEL,
  581. maxTokens: 321,
  582. signal: SIGNAL,
  583. sessionId: session.id,
  584. })
  585. expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent')
  586. })
  587. it('resolves latest routed model before AgentOptions.model', async () => {
  588. const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed')
  589. const session = conversation(1)
  590. session.append('request/header', {
  591. header: { config: { model: 'routed' } },
  592. reason: 'initial',
  593. })
  594. const output = await compact.summarize('history', agent(session, 'fallback'))
  595. expect(output.model).toBe('routed')
  596. expect(adapter.lastOptions?.model).toBe('routed')
  597. })
  598. it('fails clearly when no summarization model can be resolved', async () => {
  599. const ctx = new Context()
  600. await ctx.plugin(LlmService)
  601. void new TokenMeterService(ctx)
  602. const compact = new BasicCompactService(ctx, { auto: false })
  603. await expect(compact.summarize('history', agent(new Session(SessionId('model-less')))))
  604. .rejects.toThrow(/no model available for summarization/)
  605. })
  606. it.each([
  607. [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/],
  608. [{ kind: 'error', message: 'opaque' }, undefined, /opaque/],
  609. [{ kind: 'aborted' }, 'ABORTED', /aborted/],
  610. [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/],
  611. ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) (
  612. 'rejects terminal finish %#',
  613. async (finish, code, pattern) => {
  614. const { compact } = await summarizerHarness([], finish)
  615. let thrown: unknown
  616. try {
  617. await compact.summarize('history', agent(conversation(1), MODEL))
  618. } catch (error: unknown) {
  619. thrown = error
  620. }
  621. expect(thrown).toBeInstanceOf(Error)
  622. expect((thrown as Error).message).toMatch(pattern)
  623. expect((thrown as Error & { code?: string }).code).toBe(code)
  624. },
  625. )
  626. it('rejects empty or reasoning-only successful output', async () => {
  627. const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
  628. await expect(compact.summarize('history', agent(conversation(1), MODEL)))
  629. .rejects.toThrow(/no text summary content/)
  630. })
  631. })
  632. describe('automatic listener and loader composition', () => {
  633. function preStep(ctx: Context, owner: Agent): Promise<unknown> {
  634. return ctx.serial('agent/pre-step', owner, 1, 1, '', [], SIGNAL)
  635. }
  636. it('compacts above threshold and remains idle below it', async () => {
  637. const ctx = createContext()
  638. const compact = new TestCompactService(ctx, {
  639. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  640. })
  641. const pressured = conversation(4)
  642. await preStep(ctx, agent(pressured, MODEL))
  643. expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true)
  644. const small = conversation(1)
  645. await preStep(ctx, agent(small, MODEL))
  646. expect(small.events.some(event => event.type === 'compact/start')).toBe(false)
  647. expect(compact.calls).toHaveLength(1)
  648. })
  649. it('warns and continues after operational failures, including non-Errors', async () => {
  650. const ctx = createContext()
  651. const warnings: string[] = []
  652. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  653. const compact = new TestCompactService(ctx, {
  654. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  655. })
  656. compact.error = 'temporary failure'
  657. const session = conversation(4)
  658. await expect(preStep(ctx, agent(session, MODEL))).resolves.toBeUndefined()
  659. expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
  660. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  661. })
  662. it('propagates a named unknown-model configuration failure', async () => {
  663. const ctx = createContext()
  664. void new TestCompactService(ctx)
  665. await expect(preStep(ctx, agent(conversation(4), 'missing'))).rejects.toMatchObject({
  666. code: TOKEN_METER_MODEL_UNCONFIGURED,
  667. model: 'missing',
  668. })
  669. })
  670. it('auto:false installs no listener', async () => {
  671. const ctx = createContext()
  672. void new TestCompactService(ctx, {
  673. auto: false,
  674. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  675. })
  676. const session = conversation(4)
  677. await preStep(ctx, agent(session, MODEL))
  678. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  679. })
  680. it('loads and disposes the real zero-config service stack', async () => {
  681. const ctx = new Context()
  682. await ctx.plugin(LlmService)
  683. const meterFiber = await ctx.plugin(TokenMeterService)
  684. const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
  685. expect(ctx.tokenMeter.resolve('deepseek-v4-flash').contextWindow).toBe(128_000)
  686. expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
  687. await compactFiber.dispose()
  688. expect(ctx.get('compact')).toBeUndefined()
  689. await meterFiber.dispose()
  690. expect(ctx.get('tokenMeter')).toBeUndefined()
  691. })
  692. it('removes its automatic listener with the plugin fiber', async () => {
  693. const ctx = new Context()
  694. await ctx.plugin(LlmService)
  695. await ctx.plugin(TokenMeterService, {
  696. models: { [MODEL]: { contextWindow: 100, charsPerToken: 1_000 } },
  697. })
  698. const fiber = await ctx.plugin(TestCompactService, {
  699. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  700. })
  701. await fiber.dispose()
  702. const session = conversation(4)
  703. await preStep(ctx, agent(session, MODEL))
  704. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  705. })
  706. })
  707. describe('typed unknown-model boundary', () => {
  708. it('uses TokenMeterError identity rather than message matching', () => {
  709. const ctx = createContext()
  710. let thrown: unknown
  711. try {
  712. ctx.tokenMeter.resolve('missing')
  713. } catch (error: unknown) {
  714. thrown = error
  715. }
  716. expect(thrown).toBeInstanceOf(TokenMeterError)
  717. expect(thrown).toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED })
  718. })
  719. })