manual-compaction.spec.ts 38 KB

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