compact-basic.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  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('detects scalar/surface revision disagreement', async () => {
  228. const ctx = createContext()
  229. const meter = ctx.tokenMeter
  230. const original = meter.measureSurface.bind(meter)
  231. vi.spyOn(meter, 'measureSurface').mockImplementation((session) => {
  232. const measurement = original(session)
  233. return { ...measurement, logRevision: measurement.logRevision - 1 }
  234. })
  235. const compact = service(compactConfig, ctx)
  236. await expect(compactIfNeeded(compact, conversation(4))).rejects.toThrow(/revision/)
  237. })
  238. it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
  239. const compact = service({
  240. auto: false,
  241. compactionRetries: 0,
  242. thresholdRatio: 0.3,
  243. retainTokens: 180,
  244. })
  245. compact.summary = Array.from({ length: 7 }, (_, index) => ({
  246. type: 'text',
  247. text: `summary ${index}`,
  248. }))
  249. await expect(compactIfNeeded(compact, conversation(4)))
  250. .rejects.toThrow(/still above threshold after 1 compaction attempts/)
  251. })
  252. it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => {
  253. const compact = service({
  254. auto: false,
  255. thresholdRatio: 0.8,
  256. retainTokens: 80,
  257. }, createContext(4_000))
  258. const session = toolConversation()
  259. const result = await compactIfNeeded(compact, session)
  260. expect(result).not.toBeNull()
  261. const messages = session.deriveMessages()
  262. const calls = new Set<string>()
  263. for (const message of messages) {
  264. for (const block of message.content) {
  265. if (block.type === 'tool-call') calls.add(block.id)
  266. if (block.type === 'tool-result') expect(calls.has(block.toolCallId)).toBe(true)
  267. }
  268. }
  269. })
  270. it('rejects a priced surface that is not the current positional surface', () => {
  271. const ctx = createContext()
  272. const session = conversation(2)
  273. const priced = ctx.tokenMeter.measureSurface(session)
  274. expect(() => selectCompactableRange(session, {
  275. ...priced,
  276. nodes: priced.nodes.slice(1),
  277. }, 1)).toThrow(/does not match/)
  278. })
  279. it('declines when rounding a cut would consume the only tool pair', () => {
  280. const ctx = createContext()
  281. const session = new Session(SessionId('one-tool-pair'))
  282. const callId = CallId('only')
  283. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  284. session.append('step/start', { turn: 1, step: 1 })
  285. session.append('assistant/message', {
  286. turn: 1,
  287. step: 1,
  288. content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
  289. }, { surfaceOp: 'append' })
  290. session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
  291. session.append('tool/result', {
  292. turn: 1,
  293. step: 1,
  294. callId,
  295. content: [{ type: 'text', text: 'result' }],
  296. isError: false,
  297. }, { surfaceOp: 'append' })
  298. session.append('step/end', { turn: 1, step: 1 })
  299. const priced = ctx.tokenMeter.measureSurface(session)
  300. expect(selectCompactableRange(session, priced, 1)).toBeNull()
  301. })
  302. })
  303. describe('compaction region transaction', () => {
  304. it('rejects an agent that does not own the exact target session before mutation', async () => {
  305. const compact = service()
  306. const target = conversation(2)
  307. const owner = conversation(1)
  308. const targetEvents = [...target.events]
  309. const ownerEvents = [...owner.events]
  310. const nodes = target.surface.nodes
  311. await expect(compact.compactRegion(
  312. target,
  313. nodes[0]!.seq,
  314. nodes[1]!.seq,
  315. agent(owner),
  316. )).rejects.toThrow('compactRegion: agent.session must be the exact target session')
  317. expect(target.events).toEqual(targetEvents)
  318. expect(owner.events).toEqual(ownerEvents)
  319. expect(compact.calls).toEqual([])
  320. })
  321. it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
  322. const compact = service()
  323. const session = conversation(3)
  324. const before = session.surface.nodes
  325. const result = await compact.compactRegion(
  326. session,
  327. before[0]!.seq,
  328. before[3]!.seq,
  329. agent(session, MODEL),
  330. SIGNAL,
  331. )
  332. expect(result.shadowedSeqs).toEqual(before.slice(0, 4).map(node => node.seq))
  333. expect(result.shadowedTokenCount).toBeGreaterThan(0)
  334. expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
  335. expect(compact.calls[0]?.text).toContain('fixture user 1')
  336. const summary = session.events.findLast(event => event.type === 'compact/summary')
  337. expect(summary?.data).toMatchObject({
  338. shadowedSeqs: result.shadowedSeqs,
  339. shadowedTokenCount: result.shadowedTokenCount,
  340. model: 'summary-model',
  341. maxTokens: 123,
  342. })
  343. const head = session.deriveMessages()[0]!
  344. expect(head.content[0]?.type).toBe('text')
  345. expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
  346. expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
  347. const replay = new Session(SessionId('replay'), [...session.events])
  348. expect(replay.deriveMessages()).toEqual(session.deriveMessages())
  349. })
  350. it.each([
  351. ['start missing', 9_001, undefined, /start seq 9001 not found/],
  352. ['end missing', undefined, 9_002, /end seq 9002 not found/],
  353. ])('rejects %s', async (_label, startOverride, endOverride, pattern) => {
  354. const compact = service()
  355. const session = conversation(2)
  356. const nodes = session.surface.nodes
  357. await expect(compact.compactRegion(
  358. session,
  359. startOverride ?? nodes[0]!.seq,
  360. endOverride ?? nodes[1]!.seq,
  361. agent(session, MODEL),
  362. )).rejects.toThrow(pattern)
  363. })
  364. it('rejects reversed and tool-unbalanced positional boundaries', async () => {
  365. const compact = service()
  366. const plain = conversation(2)
  367. const nodes = plain.surface.nodes
  368. await expect(compact.compactRegion(
  369. plain,
  370. nodes[2]!.seq,
  371. nodes[1]!.seq,
  372. agent(plain, MODEL),
  373. )).rejects.toThrow(/is after end/)
  374. const tools = toolConversation()
  375. const toolNodes = tools.surface.nodes
  376. await expect(compact.compactRegion(
  377. tools,
  378. toolNodes[2]!.seq,
  379. toolNodes[4]!.seq,
  380. agent(tools, MODEL),
  381. )).rejects.toThrow(/start seq .* not a balanced boundary/)
  382. await expect(compact.compactRegion(
  383. tools,
  384. toolNodes[0]!.seq,
  385. toolNodes[1]!.seq,
  386. agent(tools, MODEL),
  387. )).rejects.toThrow(/end seq .* not a balanced boundary/)
  388. })
  389. it('requires an open turn and an idle compaction bracket', async () => {
  390. const compact = service()
  391. const closed = conversation(1)
  392. closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  393. const nodes = closed.surface.nodes
  394. await expect(compact.compactRegion(
  395. closed,
  396. nodes[0]!.seq,
  397. nodes[1]!.seq,
  398. agent(closed, MODEL),
  399. )).rejects.toThrow(/no open turn/)
  400. const locked = conversation(1)
  401. locked.append('compact/start', { turn: 2 })
  402. const lockedNodes = locked.surface.nodes
  403. await expect(compact.compactRegion(
  404. locked,
  405. lockedNodes[0]!.seq,
  406. lockedNodes[1]!.seq,
  407. agent(locked, MODEL),
  408. )).rejects.toThrow(/already in progress/)
  409. })
  410. it('rejects a session with no turn boundary at all', async () => {
  411. const compact = service()
  412. const session = new Session(SessionId('turnless'))
  413. session.append('user/message', {
  414. content: [{ type: 'text', text: 'orphan' }],
  415. source: { kind: 'user' },
  416. }, { surfaceOp: 'append' })
  417. const node = session.surface.nodes[0]!
  418. await expect(compact.compactRegion(
  419. session,
  420. node.seq,
  421. node.seq,
  422. agent(session, MODEL),
  423. )).rejects.toThrow(/no open turn/)
  424. })
  425. it('rejects a meter snapshot that changed before summarization began', async () => {
  426. const ctx = createContext()
  427. const meter = ctx.tokenMeter
  428. const original = meter.measureSurface.bind(meter)
  429. vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => {
  430. const measurement = original(session)
  431. return { ...measurement, nodes: measurement.nodes.slice(1) }
  432. })
  433. const compact = service({ auto: false }, ctx)
  434. const session = conversation(2)
  435. const nodes = session.surface.nodes
  436. await expect(compact.compactRegion(
  437. session,
  438. nodes[0]!.seq,
  439. nodes[2]!.seq,
  440. agent(session, MODEL),
  441. )).rejects.toThrow(/selected surface changed/)
  442. })
  443. it('records summarizer failures without mutating the surface', async () => {
  444. const compact = service()
  445. compact.error = new Error('summary unavailable')
  446. const session = conversation(2)
  447. const before = session.surface.nodes
  448. await expect(compact.compactRegion(
  449. session,
  450. before[0]!.seq,
  451. before[2]!.seq,
  452. agent(session, MODEL),
  453. )).rejects.toThrow('summary unavailable')
  454. expect(session.surface.nodes).toEqual(before)
  455. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  456. .toMatchObject({ error: 'summary unavailable' })
  457. })
  458. it('stringifies non-Error failures in the durable end bracket', async () => {
  459. const compact = service()
  460. compact.error = 'plain failure'
  461. const session = conversation(2)
  462. const nodes = session.surface.nodes
  463. await expect(compact.compactRegion(
  464. session,
  465. nodes[0]!.seq,
  466. nodes[2]!.seq,
  467. agent(session, MODEL),
  468. )).rejects.toBe('plain failure')
  469. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  470. .toMatchObject({ error: 'plain failure' })
  471. })
  472. it('rejects concurrent durable appends before committing the replacement', async () => {
  473. const compact = service()
  474. const session = conversation(2)
  475. compact.mutateDuringSummary = () => {
  476. session.append('request/header', {
  477. header: { config: { model: MODEL } },
  478. reason: 'initial',
  479. })
  480. }
  481. const nodes = session.surface.nodes
  482. await expect(compact.compactRegion(
  483. session,
  484. nodes[0]!.seq,
  485. nodes[2]!.seq,
  486. agent(session, MODEL),
  487. )).rejects.toThrow(/session log changed/)
  488. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  489. })
  490. it('rejects a non-shrinking framed summary under the conversation meter', async () => {
  491. const compact = service()
  492. compact.summary = Array.from({ length: 100 }, (_, index) => ({
  493. type: 'text',
  494. text: `verbose ${index}`,
  495. }))
  496. const session = conversation(2)
  497. const nodes = session.surface.nodes
  498. await expect(compact.compactRegion(
  499. session,
  500. nodes[0]!.seq,
  501. nodes[2]!.seq,
  502. agent(session, MODEL),
  503. )).rejects.toThrow(/summary is not smaller/)
  504. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  505. })
  506. it('lets a model-independent custom summarizer compact without a conversation model', async () => {
  507. const compact = service()
  508. const session = conversation(1)
  509. const nodes = session.surface.nodes
  510. await expect(compact.compactRegion(
  511. session,
  512. nodes[0]!.seq,
  513. nodes[1]!.seq,
  514. agent(session),
  515. )).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
  516. })
  517. })
  518. class ScriptedAdapter extends LlmAdapter {
  519. lastOptions: GenerateOptions | undefined
  520. constructor(
  521. private readonly blocks: readonly ContentBlock[],
  522. private readonly finish: (StreamChunk & { type: 'finish' })['reason'] = { kind: 'stop' },
  523. ) {
  524. super()
  525. }
  526. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  527. this.lastOptions = options
  528. for (const [index, block] of this.blocks.entries()) {
  529. yield { type: 'block-start', index, blockType: block.type }
  530. if (block.type === 'text') {
  531. yield { type: 'text-delta', index, text: block.text }
  532. } else if (block.type === 'reasoning') {
  533. yield { type: 'reasoning-delta', index, text: block.text }
  534. } else {
  535. yield { type: 'block-end', index, block }
  536. }
  537. }
  538. yield { type: 'finish', reason: this.finish }
  539. }
  540. }
  541. async function summarizerHarness(
  542. blocks: readonly ContentBlock[],
  543. finish?: (StreamChunk & { type: 'finish' })['reason'],
  544. model = MODEL,
  545. config: BasicCompactConfig = { auto: false },
  546. ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
  547. const ctx = new Context()
  548. await ctx.plugin(LlmService)
  549. void new TokenMeterService(ctx, { contextWindow: 1_000 })
  550. const adapter = new ScriptedAdapter(blocks, finish)
  551. ctx.llm.registerAdapter([model], adapter)
  552. const compact = new BasicCompactService(ctx, config)
  553. return { ctx, adapter, compact }
  554. }
  555. describe('default one-shot summarizer', () => {
  556. it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
  557. const { adapter, compact } = await summarizerHarness([
  558. { type: 'reasoning', text: 'private' },
  559. { type: 'text', text: 'public summary' },
  560. { type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
  561. ], undefined, MODEL, {
  562. auto: false,
  563. summarizationModel: MODEL,
  564. maxTokens: 321,
  565. })
  566. const session = conversation(1)
  567. const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL)
  568. expect(output).toEqual({
  569. summary: [{ type: 'text', text: 'public summary' }],
  570. model: MODEL,
  571. maxTokens: 321,
  572. })
  573. expect(adapter.lastOptions).toMatchObject({
  574. model: MODEL,
  575. maxTokens: 321,
  576. signal: SIGNAL,
  577. sessionId: session.id,
  578. })
  579. expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent')
  580. })
  581. it('resolves latest routed model before AgentOptions.model', async () => {
  582. const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed')
  583. const session = conversation(1)
  584. session.append('request/header', {
  585. header: { config: { model: 'routed' } },
  586. reason: 'initial',
  587. })
  588. const output = await compact.summarize('history', agent(session, 'fallback'))
  589. expect(output.model).toBe('routed')
  590. expect(adapter.lastOptions?.model).toBe('routed')
  591. })
  592. it('fails clearly when no summarization model can be resolved', async () => {
  593. const ctx = new Context()
  594. await ctx.plugin(LlmService)
  595. void new TokenMeterService(ctx)
  596. const compact = new BasicCompactService(ctx, { auto: false })
  597. await expect(compact.summarize('history', agent(new Session(SessionId('model-less')))))
  598. .rejects.toThrow(/no model available for summarization/)
  599. })
  600. it.each([
  601. [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/],
  602. [{ kind: 'error', message: 'opaque' }, undefined, /opaque/],
  603. [{ kind: 'aborted' }, 'ABORTED', /aborted/],
  604. [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/],
  605. ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) (
  606. 'rejects terminal finish %#',
  607. async (finish, code, pattern) => {
  608. const { compact } = await summarizerHarness([], finish)
  609. let thrown: unknown
  610. try {
  611. await compact.summarize('history', agent(conversation(1), MODEL))
  612. } catch (error: unknown) {
  613. thrown = error
  614. }
  615. expect(thrown).toBeInstanceOf(Error)
  616. expect((thrown as Error).message).toMatch(pattern)
  617. expect((thrown as Error & { code?: string }).code).toBe(code)
  618. },
  619. )
  620. it('rejects empty or reasoning-only successful output', async () => {
  621. const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
  622. await expect(compact.summarize('history', agent(conversation(1), MODEL)))
  623. .rejects.toThrow(/no text summary content/)
  624. })
  625. })
  626. describe('automatic listener and loader composition', () => {
  627. function preStep(ctx: Context, owner: Agent): Promise<unknown> {
  628. return ctx.serial('agent/pre-step', owner, 1, 1, '', [], SIGNAL)
  629. }
  630. it('compacts above threshold and remains idle below it', async () => {
  631. const ctx = createContext()
  632. const compact = new TestCompactService(ctx, {
  633. thresholdRatio: 0.5,
  634. retainTokens: 180,
  635. })
  636. const pressured = conversation(4)
  637. await preStep(ctx, agent(pressured, MODEL))
  638. expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true)
  639. const small = conversation(1)
  640. await preStep(ctx, agent(small, MODEL))
  641. expect(small.events.some(event => event.type === 'compact/start')).toBe(false)
  642. expect(compact.calls).toHaveLength(1)
  643. })
  644. it('warns and continues after operational failures, including non-Errors', async () => {
  645. const ctx = createContext()
  646. const warnings: string[] = []
  647. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  648. const compact = new TestCompactService(ctx, {
  649. thresholdRatio: 0.5,
  650. retainTokens: 180,
  651. })
  652. compact.error = 'temporary failure'
  653. const session = conversation(4)
  654. await expect(preStep(ctx, agent(session, MODEL))).resolves.toBeUndefined()
  655. expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
  656. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  657. })
  658. it('auto:false installs no listener', async () => {
  659. const ctx = createContext()
  660. void new TestCompactService(ctx, {
  661. auto: false,
  662. thresholdRatio: 0.5,
  663. retainTokens: 180,
  664. })
  665. const session = conversation(4)
  666. await preStep(ctx, agent(session, MODEL))
  667. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  668. })
  669. it('loads and disposes the real zero-config service stack', async () => {
  670. const ctx = new Context()
  671. await ctx.plugin(LlmService)
  672. const meterFiber = await ctx.plugin(TokenMeterService)
  673. const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
  674. expect(ctx.tokenMeter.contextWindow).toBe(128_000)
  675. expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
  676. await compactFiber.dispose()
  677. expect(ctx.get('compact')).toBeUndefined()
  678. await meterFiber.dispose()
  679. expect(ctx.get('tokenMeter')).toBeUndefined()
  680. })
  681. it('removes its automatic listener with the plugin fiber', async () => {
  682. const ctx = new Context()
  683. await ctx.plugin(LlmService)
  684. await ctx.plugin(TokenMeterService, { contextWindow: 1_000 })
  685. const fiber = await ctx.plugin(TestCompactService, {
  686. thresholdRatio: 0.5,
  687. retainTokens: 180,
  688. })
  689. await fiber.dispose()
  690. const session = conversation(4)
  691. await preStep(ctx, agent(session, MODEL))
  692. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  693. })
  694. })