manual-compaction.spec.ts 36 KB

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