compact-basic.spec.ts 27 KB

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