compact-basic.spec.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  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('rejects an agent that does not own the exact target session before mutation', async () => {
  328. const compact = service()
  329. const target = conversation(2)
  330. const owner = conversation(1)
  331. const targetEvents = [...target.events]
  332. const ownerEvents = [...owner.events]
  333. const nodes = target.surface.nodes
  334. await expect(compact.compactRegion(
  335. target,
  336. nodes[0]!.seq,
  337. nodes[1]!.seq,
  338. agent(owner),
  339. )).rejects.toThrow('compactRegion: agent.session must be the exact target session')
  340. expect(target.events).toEqual(targetEvents)
  341. expect(owner.events).toEqual(ownerEvents)
  342. expect(compact.calls).toEqual([])
  343. })
  344. it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
  345. const compact = service()
  346. const session = conversation(3)
  347. const before = session.surface.nodes
  348. const result = await compact.compactRegion(
  349. session,
  350. before[0]!.seq,
  351. before[3]!.seq,
  352. agent(session, MODEL),
  353. SIGNAL,
  354. )
  355. expect(result.shadowedSeqs).toEqual(before.slice(0, 4).map(node => node.seq))
  356. expect(result.shadowedTokenCount).toBeGreaterThan(0)
  357. expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
  358. expect(compact.calls[0]?.text).toContain('fixture user 1')
  359. const summary = session.events.findLast(event => event.type === 'compact/summary')
  360. expect(summary?.data).toMatchObject({
  361. shadowedSeqs: result.shadowedSeqs,
  362. shadowedTokenCount: result.shadowedTokenCount,
  363. model: 'summary-model',
  364. maxTokens: 123,
  365. })
  366. const head = session.deriveMessages()[0]!
  367. expect(head.content[0]?.type).toBe('text')
  368. expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
  369. expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
  370. const replay = new Session(SessionId('replay'), [...session.events])
  371. expect(replay.deriveMessages()).toEqual(session.deriveMessages())
  372. })
  373. it.each([
  374. ['start missing', 9_001, undefined, /start seq 9001 not found/],
  375. ['end missing', undefined, 9_002, /end seq 9002 not found/],
  376. ])('rejects %s', async (_label, startOverride, endOverride, pattern) => {
  377. const compact = service()
  378. const session = conversation(2)
  379. const nodes = session.surface.nodes
  380. await expect(compact.compactRegion(
  381. session,
  382. startOverride ?? nodes[0]!.seq,
  383. endOverride ?? nodes[1]!.seq,
  384. agent(session, MODEL),
  385. )).rejects.toThrow(pattern)
  386. })
  387. it('rejects reversed and tool-unbalanced positional boundaries', async () => {
  388. const compact = service()
  389. const plain = conversation(2)
  390. const nodes = plain.surface.nodes
  391. await expect(compact.compactRegion(
  392. plain,
  393. nodes[2]!.seq,
  394. nodes[1]!.seq,
  395. agent(plain, MODEL),
  396. )).rejects.toThrow(/is after end/)
  397. const tools = toolConversation()
  398. const toolNodes = tools.surface.nodes
  399. await expect(compact.compactRegion(
  400. tools,
  401. toolNodes[2]!.seq,
  402. toolNodes[4]!.seq,
  403. agent(tools, MODEL),
  404. )).rejects.toThrow(/start seq .* not a balanced boundary/)
  405. await expect(compact.compactRegion(
  406. tools,
  407. toolNodes[0]!.seq,
  408. toolNodes[1]!.seq,
  409. agent(tools, MODEL),
  410. )).rejects.toThrow(/end seq .* not a balanced boundary/)
  411. })
  412. it('requires an open turn and an idle compaction bracket', async () => {
  413. const compact = service()
  414. const closed = conversation(1)
  415. closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  416. const nodes = closed.surface.nodes
  417. await expect(compact.compactRegion(
  418. closed,
  419. nodes[0]!.seq,
  420. nodes[1]!.seq,
  421. agent(closed, MODEL),
  422. )).rejects.toThrow(/no open turn/)
  423. const locked = conversation(1)
  424. locked.append('compact/start', { turn: 2 })
  425. const lockedNodes = locked.surface.nodes
  426. await expect(compact.compactRegion(
  427. locked,
  428. lockedNodes[0]!.seq,
  429. lockedNodes[1]!.seq,
  430. agent(locked, MODEL),
  431. )).rejects.toThrow(/already in progress/)
  432. })
  433. it('rejects a session with no turn boundary at all', async () => {
  434. const compact = service()
  435. const session = new Session(SessionId('turnless'))
  436. session.append('user/message', {
  437. content: [{ type: 'text', text: 'orphan' }],
  438. source: { kind: 'user' },
  439. }, { surfaceOp: 'append' })
  440. const node = session.surface.nodes[0]!
  441. await expect(compact.compactRegion(
  442. session,
  443. node.seq,
  444. node.seq,
  445. agent(session, MODEL),
  446. )).rejects.toThrow(/no open turn/)
  447. })
  448. it('rejects a meter snapshot that changed before summarization began', async () => {
  449. const ctx = createContext()
  450. const meter = ctx.tokenMeter.resolve(MODEL)
  451. const original = meter.measureSurface.bind(meter)
  452. vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => {
  453. const measurement = original(session)
  454. return { ...measurement, nodes: measurement.nodes.slice(1) }
  455. })
  456. const compact = service({ auto: false }, ctx)
  457. const session = conversation(2)
  458. const nodes = session.surface.nodes
  459. await expect(compact.compactRegion(
  460. session,
  461. nodes[0]!.seq,
  462. nodes[2]!.seq,
  463. agent(session, MODEL),
  464. )).rejects.toThrow(/selected surface changed/)
  465. })
  466. it('records summarizer failures without mutating the surface', async () => {
  467. const compact = service()
  468. compact.error = new Error('summary unavailable')
  469. const session = conversation(2)
  470. const before = session.surface.nodes
  471. await expect(compact.compactRegion(
  472. session,
  473. before[0]!.seq,
  474. before[2]!.seq,
  475. agent(session, MODEL),
  476. )).rejects.toThrow('summary unavailable')
  477. expect(session.surface.nodes).toEqual(before)
  478. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  479. .toMatchObject({ error: 'summary unavailable' })
  480. })
  481. it('stringifies non-Error failures in the durable end bracket', async () => {
  482. const compact = service()
  483. compact.error = 'plain failure'
  484. const session = conversation(2)
  485. const nodes = session.surface.nodes
  486. await expect(compact.compactRegion(
  487. session,
  488. nodes[0]!.seq,
  489. nodes[2]!.seq,
  490. agent(session, MODEL),
  491. )).rejects.toBe('plain failure')
  492. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  493. .toMatchObject({ error: 'plain failure' })
  494. })
  495. it('rejects concurrent durable appends before committing the replacement', async () => {
  496. const compact = service()
  497. const session = conversation(2)
  498. compact.mutateDuringSummary = () => {
  499. session.append('request/header', {
  500. header: { config: { model: MODEL } },
  501. reason: 'initial',
  502. })
  503. }
  504. const nodes = session.surface.nodes
  505. await expect(compact.compactRegion(
  506. session,
  507. nodes[0]!.seq,
  508. nodes[2]!.seq,
  509. agent(session, MODEL),
  510. )).rejects.toThrow(/session log changed/)
  511. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  512. })
  513. it('rejects a non-shrinking framed summary under the conversation meter', async () => {
  514. const compact = service()
  515. compact.summary = Array.from({ length: 20 }, (_, index) => ({
  516. type: 'text',
  517. text: `verbose ${index}`,
  518. }))
  519. const session = conversation(2)
  520. const nodes = session.surface.nodes
  521. await expect(compact.compactRegion(
  522. session,
  523. nodes[0]!.seq,
  524. nodes[2]!.seq,
  525. agent(session, MODEL),
  526. )).rejects.toThrow(/summary is not smaller/)
  527. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  528. })
  529. it('requires a conversation model for pricing', async () => {
  530. const compact = service()
  531. const session = conversation(1)
  532. const nodes = session.surface.nodes
  533. await expect(compact.compactRegion(
  534. session,
  535. nodes[0]!.seq,
  536. nodes[1]!.seq,
  537. agent(session),
  538. )).rejects.toThrow(/no routed or configured conversation model/)
  539. })
  540. })
  541. class ScriptedAdapter extends LlmAdapter {
  542. lastOptions: GenerateOptions | undefined
  543. constructor(
  544. private readonly blocks: readonly ContentBlock[],
  545. private readonly finish: (StreamChunk & { type: 'finish' })['reason'] = { kind: 'stop' },
  546. ) {
  547. super()
  548. }
  549. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  550. this.lastOptions = options
  551. for (const [index, block] of this.blocks.entries()) {
  552. yield { type: 'block-start', index, blockType: block.type }
  553. if (block.type === 'text') {
  554. yield { type: 'text-delta', index, text: block.text }
  555. } else if (block.type === 'reasoning') {
  556. yield { type: 'reasoning-delta', index, text: block.text }
  557. } else {
  558. yield { type: 'block-end', index, block }
  559. }
  560. }
  561. yield { type: 'finish', reason: this.finish }
  562. }
  563. }
  564. async function summarizerHarness(
  565. blocks: readonly ContentBlock[],
  566. finish?: (StreamChunk & { type: 'finish' })['reason'],
  567. model = MODEL,
  568. config: BasicCompactConfig = { auto: false },
  569. ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
  570. const ctx = new Context()
  571. await ctx.plugin(LlmService)
  572. void new TokenMeterService(ctx, { models: { [model]: { contextWindow: 100 } } })
  573. const adapter = new ScriptedAdapter(blocks, finish)
  574. ctx.llm.registerAdapter([model], adapter)
  575. const compact = new BasicCompactService(ctx, config)
  576. return { ctx, adapter, compact }
  577. }
  578. describe('default one-shot summarizer', () => {
  579. it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
  580. const { adapter, compact } = await summarizerHarness([
  581. { type: 'reasoning', text: 'private' },
  582. { type: 'text', text: 'public summary' },
  583. { type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
  584. ], undefined, MODEL, {
  585. auto: false,
  586. summarizationModel: MODEL,
  587. maxTokens: 321,
  588. })
  589. const session = conversation(1)
  590. const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL)
  591. expect(output).toEqual({
  592. summary: [{ type: 'text', text: 'public summary' }],
  593. model: MODEL,
  594. maxTokens: 321,
  595. })
  596. expect(adapter.lastOptions).toMatchObject({
  597. model: MODEL,
  598. maxTokens: 321,
  599. signal: SIGNAL,
  600. sessionId: session.id,
  601. })
  602. expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent')
  603. })
  604. it('resolves latest routed model before AgentOptions.model', async () => {
  605. const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed')
  606. const session = conversation(1)
  607. session.append('request/header', {
  608. header: { config: { model: 'routed' } },
  609. reason: 'initial',
  610. })
  611. const output = await compact.summarize('history', agent(session, 'fallback'))
  612. expect(output.model).toBe('routed')
  613. expect(adapter.lastOptions?.model).toBe('routed')
  614. })
  615. it('fails clearly when no summarization model can be resolved', async () => {
  616. const ctx = new Context()
  617. await ctx.plugin(LlmService)
  618. void new TokenMeterService(ctx)
  619. const compact = new BasicCompactService(ctx, { auto: false })
  620. await expect(compact.summarize('history', agent(new Session(SessionId('model-less')))))
  621. .rejects.toThrow(/no model available for summarization/)
  622. })
  623. it.each([
  624. [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/],
  625. [{ kind: 'error', message: 'opaque' }, undefined, /opaque/],
  626. [{ kind: 'aborted' }, 'ABORTED', /aborted/],
  627. [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/],
  628. ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) (
  629. 'rejects terminal finish %#',
  630. async (finish, code, pattern) => {
  631. const { compact } = await summarizerHarness([], finish)
  632. let thrown: unknown
  633. try {
  634. await compact.summarize('history', agent(conversation(1), MODEL))
  635. } catch (error: unknown) {
  636. thrown = error
  637. }
  638. expect(thrown).toBeInstanceOf(Error)
  639. expect((thrown as Error).message).toMatch(pattern)
  640. expect((thrown as Error & { code?: string }).code).toBe(code)
  641. },
  642. )
  643. it('rejects empty or reasoning-only successful output', async () => {
  644. const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
  645. await expect(compact.summarize('history', agent(conversation(1), MODEL)))
  646. .rejects.toThrow(/no text summary content/)
  647. })
  648. })
  649. describe('automatic listener and loader composition', () => {
  650. function preStep(ctx: Context, owner: Agent): Promise<unknown> {
  651. return ctx.serial('agent/pre-step', owner, 1, 1, '', [], SIGNAL)
  652. }
  653. it('compacts above threshold and remains idle below it', async () => {
  654. const ctx = createContext()
  655. const compact = new TestCompactService(ctx, {
  656. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  657. })
  658. const pressured = conversation(4)
  659. await preStep(ctx, agent(pressured, MODEL))
  660. expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true)
  661. const small = conversation(1)
  662. await preStep(ctx, agent(small, MODEL))
  663. expect(small.events.some(event => event.type === 'compact/start')).toBe(false)
  664. expect(compact.calls).toHaveLength(1)
  665. })
  666. it('warns and continues after operational failures, including non-Errors', async () => {
  667. const ctx = createContext()
  668. const warnings: string[] = []
  669. ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
  670. const compact = new TestCompactService(ctx, {
  671. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  672. })
  673. compact.error = 'temporary failure'
  674. const session = conversation(4)
  675. await expect(preStep(ctx, agent(session, MODEL))).resolves.toBeUndefined()
  676. expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
  677. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  678. })
  679. it('propagates a named unknown-model configuration failure', async () => {
  680. const ctx = createContext()
  681. void new TestCompactService(ctx)
  682. await expect(preStep(ctx, agent(conversation(4), 'missing'))).rejects.toMatchObject({
  683. code: TOKEN_METER_MODEL_UNCONFIGURED,
  684. model: 'missing',
  685. })
  686. })
  687. it('auto:false installs no listener', async () => {
  688. const ctx = createContext()
  689. void new TestCompactService(ctx, {
  690. auto: false,
  691. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  692. })
  693. const session = conversation(4)
  694. await preStep(ctx, agent(session, MODEL))
  695. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  696. })
  697. it('loads and disposes the real zero-config service stack', async () => {
  698. const ctx = new Context()
  699. await ctx.plugin(LlmService)
  700. const meterFiber = await ctx.plugin(TokenMeterService)
  701. const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
  702. expect(ctx.tokenMeter.resolve('deepseek-v4-flash').contextWindow).toBe(128_000)
  703. expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
  704. await compactFiber.dispose()
  705. expect(ctx.get('compact')).toBeUndefined()
  706. await meterFiber.dispose()
  707. expect(ctx.get('tokenMeter')).toBeUndefined()
  708. })
  709. it('removes its automatic listener with the plugin fiber', async () => {
  710. const ctx = new Context()
  711. await ctx.plugin(LlmService)
  712. await ctx.plugin(TokenMeterService, {
  713. models: { [MODEL]: { contextWindow: 100, charsPerToken: 1_000 } },
  714. })
  715. const fiber = await ctx.plugin(TestCompactService, {
  716. models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
  717. })
  718. await fiber.dispose()
  719. const session = conversation(4)
  720. await preStep(ctx, agent(session, MODEL))
  721. expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
  722. })
  723. })
  724. describe('typed unknown-model boundary', () => {
  725. it('uses TokenMeterError identity rather than message matching', () => {
  726. const ctx = createContext()
  727. let thrown: unknown
  728. try {
  729. ctx.tokenMeter.resolve('missing')
  730. } catch (error: unknown) {
  731. thrown = error
  732. }
  733. expect(thrown).toBeInstanceOf(TokenMeterError)
  734. expect(thrown).toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED })
  735. })
  736. })