manual-compact.spec.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  4. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  5. import InvariantService from '@deepseek-ai/dsh-invariants'
  6. import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
  7. import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
  8. import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
  9. import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
  10. import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant'
  11. import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
  12. import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact'
  13. import type { CompactionResult } from '@deepseek-ai/dsh-compact'
  14. import {
  15. createAssistantMessage,
  16. createUserMessage,
  17. LlmAdapter,
  18. } from '@deepseek-ai/dsh-llm'
  19. import type {
  20. ContentBlock,
  21. LlmResolvedModelInfo,
  22. Message,
  23. StreamChunk,
  24. TokenUsage,
  25. } from '@deepseek-ai/dsh-llm'
  26. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  27. import LlmService from '@deepseek-ai/dsh-llm'
  28. import TokenMeterService from '@deepseek-ai/dsh-token-meter'
  29. import type { Agent } from '@deepseek-ai/dsh-agent'
  30. import type {
  31. SummarizationInput,
  32. SummaryResult,
  33. } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
  34. const MODEL = 'mock'
  35. const SIGNAL = new AbortController().signal
  36. const PROMPT = 'older conversation history '.repeat(60)
  37. /** A summarizer under test control: it can block, fail, or mutate mid-call. */
  38. class GatedCompactService extends BasicCompactService {
  39. summary: ContentBlock[] = [{ type: 'text', text: 'checkpoint' }]
  40. rawOutput: ContentBlock[] | undefined
  41. usage: TokenUsage | undefined
  42. error: unknown
  43. gate: Promise<undefined> | undefined
  44. duringSummary: (() => void) | undefined
  45. calls: SummarizationInput[] = []
  46. override async summarize(
  47. input: SummarizationInput,
  48. _agent: Agent,
  49. _signal?: AbortSignal,
  50. ): Promise<SummaryResult> {
  51. this.calls.push(input)
  52. this.duringSummary?.()
  53. if (this.gate !== undefined) await this.gate
  54. if (this.error !== undefined) throw this.error
  55. return {
  56. summary: this.summary,
  57. ...this.rawOutput === undefined ? {} : { rawOutput: this.rawOutput },
  58. provider: 'summary-provider',
  59. model: 'summary-model',
  60. ...this.usage === undefined ? {} : { usage: this.usage },
  61. }
  62. }
  63. }
  64. /** One text answer per request, with a context window large enough to avoid pressure. */
  65. class TextAdapter extends LlmAdapter {
  66. readonly requests: Message[][] = []
  67. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  68. return Promise.resolve({
  69. provider,
  70. id: model,
  71. name: model,
  72. context: { contextWindow: 100_000 },
  73. })
  74. }
  75. override async * stream(options: { messages: readonly Message[] }): AsyncIterable<StreamChunk> {
  76. this.requests.push([...options.messages])
  77. yield { type: 'block-start', index: 0, blockType: 'text' }
  78. yield { type: 'block-end', index: 0, block: { type: 'text', text: 'answer' } }
  79. yield { type: 'finish', reason: { kind: 'stop' } }
  80. }
  81. }
  82. interface LoopHarness {
  83. readonly ctx: Context
  84. readonly agent: Agent
  85. readonly compact: GatedCompactService
  86. readonly adapter: TextAdapter
  87. readonly log: string[]
  88. }
  89. /** Real loop, session store, and invariant companions around manual compaction. */
  90. async function loopHarness(): Promise<LoopHarness> {
  91. const ctx = new Context()
  92. await mountAgentLoopTestDependencies(ctx)
  93. await ctx.plugin(InvariantService)
  94. await ctx.plugin(SessionInvariant)
  95. await ctx.plugin(AgentInvariant)
  96. await ctx.plugin(AgentLoopInvariant)
  97. await ctx.plugin(CompactInvariant)
  98. await ctx.plugin(CompactBasicInvariant)
  99. await ctx.plugin(AgentLoop, { agents: [] })
  100. await ctx.plugin(TokenMeterService)
  101. const adapter = new TextAdapter()
  102. ctx.llm.registerAdapter([MODEL], adapter)
  103. const compact = new GatedCompactService(ctx, { auto: false })
  104. const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL })
  105. const log: string[] = []
  106. ctx.on('session/event', (_session, event) => {
  107. if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`)
  108. if (event.type === 'turn/end') log.push('turn/end')
  109. if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`)
  110. if (event.type === 'compact/summary') log.push('compact/summary')
  111. if (event.type === 'compact/end') log.push(`compact/end:${String(event.data.turn)}`)
  112. if (event.type === 'user/message') log.push('user/message')
  113. })
  114. ctx.on('session/flush', () => { log.push('flush') })
  115. return { ctx, agent, compact, adapter, log }
  116. }
  117. /** Drive one real turn so the closed history holds a compactable older span. */
  118. async function seedHistory(harness: LoopHarness): Promise<void> {
  119. harness.agent.followup(createUserMessage({
  120. content: [{ type: 'text', text: PROMPT }],
  121. source: { kind: 'user' },
  122. }))
  123. await harness.agent.whenIdle()
  124. harness.log.length = 0
  125. }
  126. /** Text of every derived model-visible message, in request order. */
  127. function derivedText(session: Session): string[] {
  128. return session.deriveMessages().map((message: Message) => message.content
  129. .map(block => block.type === 'text' ? block.text : '')
  130. .join(''))
  131. }
  132. /** Await one classified manual-compaction rejection. */
  133. async function rejection(operation: Promise<unknown>): Promise<ManualCompactionError> {
  134. const caught: unknown = await operation.then(
  135. (value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) },
  136. (error: unknown) => error,
  137. )
  138. if (!(caught instanceof ManualCompactionError)) {
  139. throw new Error(`expected a ManualCompactionError, got ${String(caught)}`)
  140. }
  141. return caught
  142. }
  143. /** The Error a classified failure wraps. */
  144. function causeOf(error: ManualCompactionError): Error {
  145. const { cause } = error
  146. if (!(cause instanceof Error)) throw new Error(`expected an Error cause, got ${String(cause)}`)
  147. return cause
  148. }
  149. function deferred(): { promise: Promise<undefined>; resolve: () => void } {
  150. const { promise, resolve } = Promise.withResolvers<undefined>()
  151. return { promise, resolve: () => { resolve(undefined) } }
  152. }
  153. /** A closed-tail session with compactable exchanges and no live agent. */
  154. function closedConversation(turns = 2, lastTurnNumber = turns): Session {
  155. const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
  156. for (let index = 1; index <= turns; index += 1) {
  157. const turn = index === turns ? lastTurnNumber : index
  158. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  159. session.append('user/message', createUserMessage({
  160. content: [{ type: 'text', text: `${PROMPT} ${turn}` }],
  161. source: { kind: 'user' },
  162. }), { surfaceOp: 'append' })
  163. session.append('step/start', { turn, step: 1 })
  164. if (index === 1) {
  165. session.append('request/header', {
  166. header: { config: { provider: MODEL, model: MODEL } },
  167. reason: 'initial',
  168. })
  169. }
  170. session.append('assistant/message', {
  171. turn,
  172. step: 1,
  173. message: createAssistantMessage({
  174. content: [{ type: 'text', text: `answer ${turn}` }],
  175. source: { provider: MODEL, model: MODEL },
  176. }),
  177. }, { surfaceOp: 'append' })
  178. session.append('step/end', { turn, step: 1 })
  179. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  180. }
  181. return session
  182. }
  183. /** A fake idle agent whose admission reservation is scripted per test. */
  184. function fakeAgent(
  185. session: Session,
  186. reserve: () => (() => void) | undefined,
  187. ): Agent {
  188. return {
  189. session,
  190. options: { provider: MODEL, model: MODEL },
  191. reserveTurnAdmission: reserve,
  192. } as unknown as Agent
  193. }
  194. /** Service over a store-detached session for failure classification. */
  195. function detachedService(): { ctx: Context; compact: GatedCompactService; flushes: () => number } {
  196. const ctx = new Context()
  197. void new LlmService(ctx)
  198. void new SessionStore(ctx)
  199. void new TokenMeterService(ctx)
  200. ctx.llm.registerAdapter([MODEL], new TextAdapter())
  201. let flushes = 0
  202. vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => {
  203. flushes += 1
  204. return Promise.resolve()
  205. })
  206. return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes }
  207. }
  208. function compactEvents(session: Session): Array<Session['events'][number]> {
  209. return session.events.filter(event => event.type.startsWith('compact/'))
  210. }
  211. describe('compactNow through the real loop', () => {
  212. it('holds a prompt accepted during summarization until the standalone bracket is flushed', async () => {
  213. const harness = await loopHarness()
  214. const { agent, compact, adapter, log } = harness
  215. await seedHistory(harness)
  216. const gate = deferred()
  217. compact.gate = gate.promise
  218. const running = compact.compactNow(agent, SIGNAL)
  219. await Promise.resolve()
  220. expect(log).toEqual(['compact/start:null'])
  221. agent.followup(createUserMessage({
  222. content: [{ type: 'text', text: 'after compaction' }],
  223. source: { kind: 'user' },
  224. }))
  225. await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
  226. expect(agent.status).toBe('idle')
  227. expect(adapter.requests).toHaveLength(1)
  228. expect(log).toEqual(['compact/start:null'])
  229. gate.resolve()
  230. const result = await running
  231. expect(result).not.toBeNull()
  232. await agent.whenIdle()
  233. const start = log.indexOf('compact/start:null')
  234. const summary = log.indexOf('compact/summary')
  235. const end = log.indexOf('compact/end:null')
  236. const flush = log.indexOf('flush')
  237. const nextTurn = log.indexOf('turn/start:message')
  238. expect(start).toBeLessThan(summary)
  239. expect(summary).toBeLessThan(end)
  240. expect(end).toBeLessThan(flush)
  241. expect(flush).toBeLessThan(nextTurn)
  242. expect(adapter.requests).toHaveLength(2)
  243. const second = (adapter.requests[1] ?? []).map(message => message.content
  244. .map(block => block.type === 'text' ? block.text : '')
  245. .join(''))
  246. expect(second[0]).toContain('checkpoint')
  247. expect(second.at(-1)).toBe('after compaction')
  248. expect(second.some(text => text.includes(PROMPT))).toBe(false)
  249. })
  250. it('keeps context injected during summarization between the markers and after the checkpoint', async () => {
  251. const harness = await loopHarness()
  252. const { agent, compact } = harness
  253. await seedHistory(harness)
  254. compact.duringSummary = () => {
  255. agent.inject(createUserMessage({
  256. content: [{ type: 'text', text: 'INJECTED CONTEXT' }],
  257. source: { kind: 'plugin', plugin: 'test' },
  258. }))
  259. }
  260. const result = await compact.compactNow(agent, SIGNAL)
  261. expect(result).not.toBeNull()
  262. const start = agent.session.events.findLast(event => event.type === 'compact/start')
  263. const injected = agent.session.events.findLast(event => event.type === 'user/message'
  264. && event.data.source.kind === 'plugin' && event.data.source.plugin === 'test')
  265. const end = agent.session.events.findLast(event => event.type === 'compact/end')
  266. expect(start).toBeDefined()
  267. expect(injected).toBeDefined()
  268. expect(end).toBeDefined()
  269. expect(start!.seq).toBeLessThan(injected!.seq)
  270. expect(injected!.seq).toBeLessThan(end!.seq)
  271. expect(result?.shadowedSeqs).not.toContain(injected?.seq)
  272. const messages = derivedText(agent.session)
  273. expect(messages[0]).toContain('checkpoint')
  274. expect(messages.at(-1)).toContain('INJECTED CONTEXT')
  275. expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1)
  276. })
  277. it('keeps the marker order when listeners attempt a re-entrant injection', async () => {
  278. const harness = await loopHarness()
  279. const { ctx, agent, compact } = harness
  280. await seedHistory(harness)
  281. const attempts: string[] = []
  282. ctx.on('session/event', (_session, event) => {
  283. if (event.type !== 'compact/start' && event.type !== 'compact/summary') return
  284. attempts.push(event.type)
  285. agent.inject(createUserMessage({
  286. content: [{ type: 'text', text: `from ${event.type}` }],
  287. source: { kind: 'plugin', plugin: 'listener' },
  288. }))
  289. })
  290. const result = await compact.compactNow(agent, SIGNAL)
  291. expect(attempts).toEqual(['compact/start', 'compact/summary'])
  292. expect(result).not.toBeNull()
  293. expect(derivedText(agent.session)[0]).toContain('checkpoint')
  294. expect(agent.session.events.filter(event => event.type === 'user/message'
  295. && event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0)
  296. const types = compactEvents(agent.session).map(event => event.type)
  297. expect(types).toEqual(['compact/start', 'compact/summary', 'compact/end'])
  298. })
  299. it('reports busy without summarizing when a prompt already owns the next turn', async () => {
  300. const harness = await loopHarness()
  301. const { agent, compact, adapter } = harness
  302. await seedHistory(harness)
  303. agent.followup(createUserMessage({
  304. content: [{ type: 'text', text: 'first in line' }],
  305. source: { kind: 'user' },
  306. }))
  307. expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
  308. expect(compact.calls).toHaveLength(0)
  309. await agent.whenIdle()
  310. expect(adapter.requests).toHaveLength(2)
  311. expect(agent.session.events.some(event => event.type === 'compact/start')).toBe(false)
  312. })
  313. it('releases turn admission after a summarizer failure and records the failed attempt', async () => {
  314. const harness = await loopHarness()
  315. const { agent, compact, adapter } = harness
  316. await seedHistory(harness)
  317. compact.error = new Error('summarizer unavailable')
  318. const before = [...agent.session.surface.nodes]
  319. expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('summary')
  320. expect(agent.session.surface.nodes).toEqual(before)
  321. const markers = compactEvents(agent.session)
  322. expect(markers.map(event => event.type)).toEqual(['compact/start', 'compact/end'])
  323. expect(markers[1]?.type === 'compact/end' && markers[1].data.error)
  324. .toContain('summarizer unavailable')
  325. agent.followup(createUserMessage({
  326. content: [{ type: 'text', text: 'runs after the failure' }],
  327. source: { kind: 'user' },
  328. }))
  329. await agent.whenIdle()
  330. expect(adapter.requests).toHaveLength(2)
  331. })
  332. })
  333. describe('compactNow transaction and failure classification', () => {
  334. it('returns null without writing a bracket for history that cannot be compacted', async () => {
  335. const { compact } = detachedService()
  336. const session = new Session(SessionId('empty'))
  337. let released = 0
  338. const agent = fakeAgent(session, () => () => { released += 1 })
  339. expect(await compact.compactNow(agent, SIGNAL)).toBeNull()
  340. expect(released).toBe(1)
  341. expect(compact.calls).toHaveLength(0)
  342. expect(compactEvents(session)).toEqual([])
  343. })
  344. it('commits a standalone bracket without consuming a turn number and checkpoints durability', async () => {
  345. const { compact, flushes } = detachedService()
  346. const session = closedConversation(2, 7)
  347. const agent = fakeAgent(session, () => () => undefined)
  348. const result = await compact.compactNow(agent, SIGNAL)
  349. expect(result).not.toBeNull()
  350. expect(flushes()).toBe(1)
  351. expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7)
  352. expect(session.events.findLast(event => event.type === 'compact/start')?.data)
  353. .toEqual({ turn: null })
  354. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  355. .toEqual({ turn: null })
  356. })
  357. it('reports a live unmatched bracket as busy without summarizing', async () => {
  358. const { compact } = detachedService()
  359. const session = closedConversation(2)
  360. session.append('compact/start', { turn: null })
  361. const agent = fakeAgent(session, () => () => undefined)
  362. const error = await rejection(compact.compactNow(agent, SIGNAL))
  363. expect(error.code).toBe('busy')
  364. expect(error.message).toContain('compaction lock is already active')
  365. expect(compact.calls).toHaveLength(0)
  366. })
  367. it('ignores an unmatched bracket inherited before a later end-seed marker', async () => {
  368. const { compact } = detachedService()
  369. const original = closedConversation(2)
  370. original.append('compact/start', { turn: null })
  371. const reloaded = new Session(SessionId('stale-orphan'), [...original.events])
  372. const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
  373. const orphan = reloaded.events.find(event => event.type === 'compact/start')
  374. const agent = fakeAgent(reloaded, () => () => undefined)
  375. expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER)
  376. await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
  377. expect(compact.calls).toHaveLength(1)
  378. })
  379. it('scans a stale orphan independently of later repaired turn state', async () => {
  380. const { compact } = detachedService()
  381. const original = closedConversation(2)
  382. original.append('compact/start', { turn: null })
  383. original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
  384. original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
  385. const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
  386. const agent = fakeAgent(reloaded, () => () => undefined)
  387. await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
  388. expect(compact.calls).toHaveLength(1)
  389. })
  390. it('refuses an open turn in the log', async () => {
  391. const { compact } = detachedService()
  392. const session = closedConversation(2)
  393. session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
  394. const agent = fakeAgent(session, () => () => undefined)
  395. const error = await rejection(compact.compactNow(agent, SIGNAL))
  396. expect(error.code).toBe('busy')
  397. expect(error.message).toContain('already has an open turn')
  398. })
  399. it('reports busy and skips summarization when admission is unavailable', async () => {
  400. const { compact } = detachedService()
  401. const agent = fakeAgent(closedConversation(2), () => undefined)
  402. expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
  403. expect(compact.calls).toHaveLength(0)
  404. })
  405. it('rejects a selected span replaced during summarization and records an error close', async () => {
  406. const { compact, flushes } = detachedService()
  407. const session = closedConversation(2)
  408. let released = 0
  409. const agent = fakeAgent(session, () => () => { released += 1 })
  410. compact.duringSummary = () => {
  411. const [head] = session.surface.nodes
  412. session.append('user/message', createUserMessage({
  413. content: [{ type: 'text', text: 'competing replacement' }],
  414. source: { kind: 'plugin', plugin: 'rival' },
  415. }), {
  416. surfaceOp: { op: 'replace', start: head!, end: head! },
  417. sourceEventSeqs: [head!],
  418. })
  419. }
  420. expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed')
  421. expect(released).toBe(1)
  422. expect(flushes()).toBe(1)
  423. expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
  424. })
  425. it('rejects a selected span whose middle node was replaced during summarization', async () => {
  426. const { compact } = detachedService()
  427. const session = closedConversation(3)
  428. const agent = fakeAgent(session, () => () => undefined)
  429. compact.duringSummary = () => {
  430. const middle = session.surface.nodes[1]
  431. session.append('user/message', createUserMessage({
  432. content: [{ type: 'text', text: 'rewritten middle node' }],
  433. source: { kind: 'plugin', plugin: 'rival' },
  434. }), {
  435. surfaceOp: { op: 'replace', start: middle!, end: middle! },
  436. sourceEventSeqs: [middle!],
  437. })
  438. }
  439. const error = await rejection(compact.compactNow(agent, SIGNAL))
  440. expect(error.code).toBe('changed')
  441. expect(causeOf(error).message).toContain('span changed during summarization')
  442. })
  443. it('revalidates the selected span after the summarizer continuation settles', async () => {
  444. const { compact, flushes } = detachedService()
  445. const session = closedConversation(2)
  446. const gate = deferred()
  447. compact.gate = gate.promise
  448. let released = 0
  449. const agent = fakeAgent(session, () => () => { released += 1 })
  450. const head = session.surface.nodes[0]!
  451. const generation = session.surface.replaceGeneration
  452. const running = compact.compactNow(agent, SIGNAL)
  453. await Promise.resolve()
  454. expect(compact.calls).toHaveLength(1)
  455. gate.resolve()
  456. queueMicrotask(() => {
  457. queueMicrotask(() => {
  458. session.append('user/message', createUserMessage({
  459. content: [{ type: 'text', text: 'late competing replacement' }],
  460. source: { kind: 'plugin', plugin: 'rival' },
  461. }), {
  462. surfaceOp: { op: 'replace', start: head, end: head },
  463. sourceEventSeqs: [head],
  464. })
  465. })
  466. })
  467. const error = await rejection(running)
  468. expect(error.code).toBe('changed')
  469. expect(causeOf(error).message).toContain('selected span')
  470. expect(released).toBe(1)
  471. expect(flushes()).toBe(1)
  472. expect(session.surface.replaceGeneration).toBe(generation + 1)
  473. expect(session.surface.nodes).not.toContain(head)
  474. expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
  475. expect(session.events.some(event => event.type === 'user/message'
  476. && isCompactCheckpointSource(event.data.source))).toBe(false)
  477. })
  478. it('classifies a failing compact/end as commit failure and leaves one orphan', async () => {
  479. const { compact, flushes } = detachedService()
  480. const session = closedConversation(2)
  481. const agent = fakeAgent(session, () => () => undefined)
  482. const append = session.append.bind(session)
  483. vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
  484. if (type === 'compact/end') throw new Error('boundary rejected')
  485. return (append as (...args: never[]) => unknown)(type as never, ...rest)
  486. }) as never)
  487. const error = await rejection(compact.compactNow(agent, SIGNAL))
  488. expect(error.code).toBe('commit')
  489. expect(causeOf(error).message).toBe('boundary rejected')
  490. vi.restoreAllMocks()
  491. expect(flushes()).toBe(0)
  492. expect(session.events.findLast(event => event.type.startsWith('compact/'))?.type)
  493. .toBe('compact/summary')
  494. expect(compactEvents(session).filter(event => event.type === 'compact/start')).toHaveLength(1)
  495. const calls = compact.calls.length
  496. expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
  497. expect(compact.calls).toHaveLength(calls)
  498. })
  499. it('keeps a failed error-close as the commit failure and does not flush', async () => {
  500. const { compact, flushes } = detachedService()
  501. const session = closedConversation(2)
  502. let released = 0
  503. const agent = fakeAgent(session, () => () => { released += 1 })
  504. compact.error = new Error('summary rejected')
  505. const append = session.append.bind(session)
  506. vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
  507. if (type === 'compact/end') throw new Error('error boundary rejected')
  508. return (append as (...args: never[]) => unknown)(type as never, ...rest)
  509. }) as never)
  510. const error = await rejection(compact.compactNow(agent, SIGNAL))
  511. vi.restoreAllMocks()
  512. expect(error.code).toBe('commit')
  513. expect(causeOf(error).message).toBe('error boundary rejected')
  514. expect(released).toBe(1)
  515. expect(flushes()).toBe(0)
  516. expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start'])
  517. })
  518. it('rejects a selected span whose pricing changed during summarization', async () => {
  519. const { ctx, compact } = detachedService()
  520. const session = closedConversation(2)
  521. const agent = fakeAgent(session, () => () => undefined)
  522. const meter = ctx.tokenMeter
  523. const original = meter.measure.bind(meter)
  524. compact.duringSummary = () => {
  525. vi.spyOn(meter, 'measure').mockImplementationOnce((target) => {
  526. const measurement = original(target)
  527. return {
  528. ...measurement,
  529. nodes: measurement.nodes.map((node, index) =>
  530. index === 0 ? { ...node, tokens: node.tokens + 1 } : node),
  531. }
  532. })
  533. }
  534. expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed')
  535. vi.restoreAllMocks()
  536. })
  537. it('classifies a commit-body failure and still releases admission', async () => {
  538. const { compact } = detachedService()
  539. const session = closedConversation(2)
  540. let released = 0
  541. const agent = fakeAgent(session, () => () => { released += 1 })
  542. const append = session.append.bind(session)
  543. vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
  544. if (type === 'compact/summary') throw new Error('provenance rejected')
  545. return (append as (...args: never[]) => unknown)(type as never, ...rest)
  546. }) as never)
  547. const error = await rejection(compact.compactNow(agent, SIGNAL))
  548. vi.restoreAllMocks()
  549. expect(error.code).toBe('commit')
  550. expect(released).toBe(1)
  551. const end = session.events.findLast(event => event.type === 'compact/end')
  552. expect(end?.type === 'compact/end' && end.data.error).toContain('provenance rejected')
  553. expect(end?.type === 'compact/end' && end.data.turn).toBeNull()
  554. })
  555. it('keeps a commit failure when the durability checkpoint also fails', async () => {
  556. const { ctx, compact } = detachedService()
  557. const session = closedConversation(2)
  558. const agent = fakeAgent(session, () => () => undefined)
  559. const append = session.append.bind(session)
  560. vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
  561. if (type === 'compact/summary') throw new Error('provenance rejected')
  562. return (append as (...args: never[]) => unknown)(type as never, ...rest)
  563. }) as never)
  564. vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
  565. const error = await rejection(compact.compactNow(agent, SIGNAL))
  566. expect(error.code).toBe('commit')
  567. expect(causeOf(error).message).toBe('provenance rejected')
  568. vi.restoreAllMocks()
  569. })
  570. it('compacts a session with no durable turn boundary without creating one', async () => {
  571. const { compact } = detachedService()
  572. const session = new Session(SessionId('turnless'))
  573. for (const text of [PROMPT, 'recent tail']) {
  574. session.append('user/message', createUserMessage({
  575. content: [{ type: 'text', text }],
  576. source: { kind: 'user' },
  577. }), { surfaceOp: 'append' })
  578. }
  579. const agent = fakeAgent(session, () => () => undefined)
  580. const result = await compact.compactNow(agent, SIGNAL)
  581. expect(result).not.toBeNull()
  582. expect(session.events.some(event => event.type === 'turn/start')).toBe(false)
  583. expect(session.events.find(event => event.type === 'compact/start')?.data)
  584. .toEqual({ turn: null })
  585. })
  586. it('classifies a durability failure after the standalone bracket committed', async () => {
  587. const { ctx, compact } = detachedService()
  588. const session = closedConversation(2)
  589. const agent = fakeAgent(session, () => () => undefined)
  590. vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
  591. expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence')
  592. vi.restoreAllMocks()
  593. expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
  594. expect(session.events.findLast(event => event.type === 'compact/end')?.data)
  595. .toEqual({ turn: null })
  596. })
  597. it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
  598. const cases = [
  599. { name: 'busy', session: closedConversation(2), release: undefined },
  600. { name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
  601. { name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
  602. ] as const
  603. for (const testCase of cases) {
  604. const { ctx, compact } = detachedService()
  605. const reserve = vi.fn(() => testCase.release)
  606. const measure = vi.spyOn(ctx.tokenMeter, 'measure')
  607. const agent = fakeAgent(testCase.session, reserve)
  608. const before = [...testCase.session.events]
  609. const reason = Object.freeze({ kind: 'cancelled', case: testCase.name })
  610. const controller = new AbortController()
  611. controller.abort(reason)
  612. await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
  613. expect(reserve).not.toHaveBeenCalled()
  614. expect(measure).not.toHaveBeenCalled()
  615. expect(compact.calls).toHaveLength(0)
  616. expect(testCase.session.events).toEqual(before)
  617. vi.restoreAllMocks()
  618. }
  619. })
  620. it('preserves the exact cancellation reason when the summarizer also rejects', async () => {
  621. const { compact, flushes } = detachedService()
  622. const controller = new AbortController()
  623. const reason = new Error('cancelled by the caller')
  624. let released = 0
  625. const session = closedConversation(2)
  626. const agent = fakeAgent(session, () => () => { released += 1 })
  627. compact.duringSummary = () => { controller.abort(reason) }
  628. compact.error = new Error('summarizer aborted')
  629. await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
  630. expect(released).toBe(1)
  631. expect(flushes()).toBe(1)
  632. const events = compactEvents(session)
  633. expect(events.map(event => event.type)).toEqual(['compact/start', 'compact/end'])
  634. expect(events[1]?.type === 'compact/end' && events[1].data.error)
  635. .toContain('summarizer aborted')
  636. })
  637. it('aborts before committing when cancellation lands after summarization', async () => {
  638. const { compact } = detachedService()
  639. const controller = new AbortController()
  640. const reason = new Error('cancelled by the caller')
  641. const session = closedConversation(2)
  642. const agent = fakeAgent(session, () => () => undefined)
  643. compact.duringSummary = () => { controller.abort(reason) }
  644. await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
  645. expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
  646. expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
  647. })
  648. it('waits for the durability checkpoint before cancellation wins and admission releases', async () => {
  649. const { ctx, compact } = detachedService()
  650. const controller = new AbortController()
  651. const reason = new Error('cancelled during flush')
  652. const flushGate = Promise.withResolvers<undefined>()
  653. const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise)
  654. const session = closedConversation(2)
  655. let released = 0
  656. const agent = fakeAgent(session, () => () => { released += 1 })
  657. const running = compact.compactNow(agent, controller.signal)
  658. let settled = false
  659. void running.then(
  660. () => { settled = true },
  661. () => { settled = true },
  662. )
  663. await vi.waitFor(() => {
  664. expect(flush).toHaveBeenCalledWith(session)
  665. })
  666. controller.abort(reason)
  667. await Promise.resolve()
  668. expect(settled).toBe(false)
  669. expect(released).toBe(0)
  670. flushGate.resolve(undefined)
  671. await expect(running).rejects.toBe(reason)
  672. expect(released).toBe(1)
  673. })
  674. it('preserves raw output and usage in the manual summary event', async () => {
  675. const { compact } = detachedService()
  676. const session = closedConversation(2)
  677. const agent = fakeAgent(session, () => () => undefined)
  678. compact.rawOutput = [
  679. { type: 'text', text: 'checkpoint' },
  680. { type: 'reasoning', text: 'hidden reasoning' },
  681. ]
  682. compact.usage = { inputTokens: 40, outputTokens: 5 }
  683. await compact.compactNow(agent, SIGNAL)
  684. const summary = session.events.find(event => event.type === 'compact/summary')
  685. expect(summary?.type === 'compact/summary' && summary.data.rawOutput).toEqual(compact.rawOutput)
  686. expect(summary?.type === 'compact/summary' && summary.data.usage).toEqual(compact.usage)
  687. })
  688. it('makes duration derivable from the opening and closing marker times', async () => {
  689. const { compact } = detachedService()
  690. const session = closedConversation(2)
  691. const agent = fakeAgent(session, () => () => undefined)
  692. compact.gate = new Promise<undefined>((resolve) => {
  693. setTimeout(() => { resolve(undefined) }, 5)
  694. })
  695. await compact.compactNow(agent, SIGNAL)
  696. const start = session.events.findLast(event => event.type === 'compact/start')
  697. const end = session.events.findLast(event => event.type === 'compact/end')
  698. expect(start).toBeDefined()
  699. expect(end).toBeDefined()
  700. expect(end!.time - start!.time).toBeGreaterThan(0)
  701. })
  702. it('excludes concurrent automatic and manual compaction of one session', async () => {
  703. const { compact } = detachedService()
  704. const session = closedConversation(3)
  705. const agent = fakeAgent(session, () => () => undefined)
  706. const gate = deferred()
  707. compact.gate = gate.promise
  708. const manual = compact.compactNow(agent, SIGNAL)
  709. await Promise.resolve()
  710. const nodes = session.surface.nodes
  711. await expect(compact.compactRegion(
  712. nodes[0]!,
  713. nodes[1]!,
  714. agent,
  715. )).rejects.toThrow('compaction lock is already active')
  716. gate.resolve()
  717. compact.gate = undefined
  718. const result: CompactionResult | null = await manual
  719. expect(result).not.toBeNull()
  720. })
  721. it('excludes a manual request while an explicit region compaction runs', async () => {
  722. const { compact } = detachedService()
  723. const session = closedConversation(3)
  724. session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
  725. const agent = fakeAgent(session, () => () => undefined)
  726. const gate = deferred()
  727. compact.gate = gate.promise
  728. const nodes = session.surface.nodes
  729. const region = compact.compactRegion(nodes[0]!, nodes[1]!, agent)
  730. await Promise.resolve()
  731. expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
  732. gate.resolve()
  733. compact.gate = undefined
  734. await expect(region).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 2) })
  735. })
  736. })