command-compact.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import Loader from '@deepseek-ai/cordis-plugin-loader'
  4. import type { Agent } from '@deepseek-ai/dsh-agent'
  5. import CommandRuntime, { type CommandResult } from '@deepseek-ai/dsh-commands'
  6. import {
  7. CompactionId,
  8. CompactionEngine,
  9. ManualCompactionError,
  10. type CompactionAgentContext,
  11. type CompactionResult,
  12. type CompactionTrigger,
  13. type ManualCompactAgentContext,
  14. } from '@deepseek-ai/dsh-compaction'
  15. import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
  16. import * as commandCompact from '@deepseek-ai/dsh-command-compact'
  17. const COMPACTION_ID = CompactionId('command-compact-test')
  18. const RESULT: CompactionResult = {
  19. compactionId: COMPACTION_ID,
  20. startSeq: SessionSeq(1),
  21. summarySeq: SessionSeq(2),
  22. endSeq: SessionSeq(3),
  23. summary: [{ type: 'text', text: 'summary' }],
  24. shadowedRange: { start: SessionSeq(1), end: SessionSeq(7) },
  25. shadowedSeqs: [SessionSeq(1), SessionSeq(3), SessionSeq(7)],
  26. shadowedTokenCount: 42,
  27. }
  28. class StubCompactionEngine extends CompactionEngine {
  29. result: CompactionResult | null = RESULT
  30. failure: unknown
  31. operation: (() => Promise<CompactionResult | null>) | undefined
  32. calls: { agent: ManualCompactAgentContext; signal: AbortSignal }[] = []
  33. override compactIfNeeded(
  34. _agent: CompactionAgentContext,
  35. _trigger: CompactionTrigger,
  36. _signal: AbortSignal,
  37. ): Promise<CompactionResult | null> {
  38. return Promise.resolve(null)
  39. }
  40. override compactRegion(): Promise<CompactionResult> {
  41. return Promise.resolve(RESULT)
  42. }
  43. override compactNow(
  44. agent: ManualCompactAgentContext,
  45. signal: AbortSignal,
  46. sourceCommandId?: Parameters<CompactionEngine['compactNow']>[2],
  47. ): Promise<CompactionResult | null> {
  48. this.calls.push({ agent, signal })
  49. if (this.operation !== undefined) return this.operation()
  50. return this.failure === undefined
  51. ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result, sourceCommandId))
  52. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values.
  53. : Promise.reject(this.failure)
  54. }
  55. private appendResult(
  56. agent: ManualCompactAgentContext,
  57. result: CompactionResult,
  58. sourceCommandId: Parameters<CompactionEngine['compactNow']>[2],
  59. ): CompactionResult {
  60. const provenance = {
  61. compactionId: result.compactionId,
  62. ...sourceCommandId === undefined ? {} : { sourceCommandId },
  63. }
  64. agent.session.append('compaction/start', { ...provenance, turn: null })
  65. agent.session.append('compaction/summary', {
  66. ...provenance,
  67. summary: result.summary,
  68. shadowedRange: result.shadowedRange,
  69. shadowedSeqs: result.shadowedSeqs,
  70. shadowedTokenCount: result.shadowedTokenCount,
  71. provider: 'command-test',
  72. model: 'command-test',
  73. })
  74. agent.session.append('compaction/end', { ...provenance, turn: null })
  75. return { ...result, ...provenance }
  76. }
  77. }
  78. interface Harness {
  79. readonly ctx: Context
  80. readonly compact: StubCompactionEngine
  81. readonly agent: Agent
  82. readonly plugin: Awaited<ReturnType<Context['plugin']>>
  83. }
  84. async function harness(): Promise<Harness> {
  85. const ctx = new Context()
  86. await ctx.plugin(CommandRuntime)
  87. const compact = new StubCompactionEngine(ctx)
  88. const plugin = await ctx.plugin(commandCompact)
  89. const session = Session.create(SessionId('command-compact'))
  90. const agent = {
  91. session,
  92. status: 'idle',
  93. options: {},
  94. reserveTurnAdmission: () => () => undefined,
  95. } as unknown as Agent
  96. return { ctx, compact, agent, plugin }
  97. }
  98. async function run(
  99. test: Harness,
  100. suffix = '',
  101. controller = new AbortController(),
  102. ): Promise<NonNullable<Awaited<ReturnType<CommandRuntime['execute']>>>> {
  103. const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, [], controller.signal)
  104. if (execution === undefined) throw new Error('compact command was not registered')
  105. return execution
  106. }
  107. /** Assert the executor-owned lifecycle pair and absence from model history. */
  108. function expectLastLifecycle(
  109. test: Harness,
  110. args: string,
  111. outcome: CommandResult,
  112. ): string {
  113. const lifecycle = test.agent.session.snapshotEvents()
  114. .filter(event => event.type === 'command/run' || event.type === 'command/done')
  115. .slice(-2)
  116. const runEvent = lifecycle[0]
  117. const doneEvent = lifecycle[1]
  118. if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') {
  119. throw new Error(`expected command lifecycle pair, got ${lifecycle.map(event => event.type).join(',')}`)
  120. }
  121. expect(lifecycle.map(event => ({ type: event.type, data: event.data }))).toEqual([
  122. {
  123. type: 'command/run',
  124. data: {
  125. commandId: runEvent.data.commandId,
  126. name: 'compact',
  127. args,
  128. source: { kind: 'user' },
  129. },
  130. },
  131. {
  132. type: 'command/done',
  133. data: {
  134. commandId: runEvent.data.commandId,
  135. ...outcome,
  136. },
  137. },
  138. ])
  139. expect(doneEvent.data.commandId).toBe(runEvent.data.commandId)
  140. expect(test.agent.session.surface.nodes).toEqual([])
  141. expect(test.agent.session.deriveMessages()).toEqual([])
  142. return runEvent.data.commandId
  143. }
  144. describe('@deepseek-ai/dsh-command-compact registration', () => {
  145. it('registers one argument-free command with Loader-safe exports and disposes it', async () => {
  146. const test = await harness()
  147. expect(commandCompact.name).toBe('command-compact')
  148. expect(commandCompact.inject).toEqual(['commands', 'compaction'])
  149. expect('default' in commandCompact).toBe(false)
  150. const loader = Object.create(Loader.prototype) as Loader
  151. expect(loader.unwrapExports(commandCompact)).toBe(commandCompact)
  152. expect(test.ctx.commands.list(test.agent)).toContainEqual({
  153. definitionId: '@deepseek-ai/dsh-command-compact',
  154. name: 'compact',
  155. description: 'Compact older conversation history',
  156. })
  157. await test.plugin.dispose()
  158. expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
  159. })
  160. })
  161. describe('/compact human command', () => {
  162. it('reports success with useful accounting and forwards the exact target and signal', async () => {
  163. const test = await harness()
  164. const controller = new AbortController()
  165. const execution = await run(test, '', controller)
  166. expect(execution.result).toEqual({
  167. kind: 'success',
  168. text: 'Compacted 3 history items (~42 tokens).',
  169. sourceEventSeq: RESULT.summarySeq,
  170. })
  171. expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
  172. expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }])
  173. })
  174. it('returns direct no-history and argument-rejection results', async () => {
  175. const test = await harness()
  176. test.compact.result = null
  177. const empty = await run(test)
  178. expect(empty.result).toEqual({
  179. kind: 'success',
  180. text: 'No compactable history yet.',
  181. })
  182. expect(empty.commandId).toBe(expectLastLifecycle(test, '', empty.result))
  183. const rejected = await run(test, ' now')
  184. expect(rejected.result).toEqual({
  185. kind: 'error',
  186. text: 'Usage: /compact (no arguments)',
  187. })
  188. expect(rejected.commandId).toBe(expectLastLifecycle(test, ' now', rejected.result))
  189. expect(test.compact.calls).toHaveLength(1)
  190. })
  191. it.each([
  192. ['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'],
  193. ['cancelled', 'Compaction cancelled.'],
  194. ['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'],
  195. ['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'],
  196. ['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'],
  197. ['persistence', 'Compaction finished, but the session could not be saved.'],
  198. ] as const)('maps expected %s failures to direct errors', async (code, text) => {
  199. const test = await harness()
  200. test.compact.failure = new ManualCompactionError(code, 'backend detail')
  201. const execution = await run(test)
  202. expect(execution.result).toEqual({ kind: 'error', text })
  203. expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
  204. })
  205. it('preserves cancellation and unexpected implementation failures', async () => {
  206. const cancelled = await harness()
  207. const controller = new AbortController()
  208. const abort = new Error('operator cancelled')
  209. cancelled.compact.operation = () => {
  210. controller.abort(abort)
  211. return Promise.reject(new ManualCompactionError('summary', 'late failure'))
  212. }
  213. await expect(run(cancelled, '', controller)).rejects.toBe(abort)
  214. expectLastLifecycle(cancelled, '', { kind: 'error', text: abort.message })
  215. const unexpected = await harness()
  216. const bug = new Error('unexpected backend bug')
  217. unexpected.compact.failure = bug
  218. await expect(run(unexpected)).rejects.toBe(bug)
  219. expectLastLifecycle(unexpected, '', { kind: 'error', text: bug.message })
  220. })
  221. it('drains an aborted handler through close and flush before plugin disposal settles', async () => {
  222. const test = await harness()
  223. const controller = new AbortController()
  224. const abort = new Error('operator cancelled')
  225. const started = Promise.withResolvers<undefined>()
  226. const allowClose = Promise.withResolvers<undefined>()
  227. const closed = Promise.withResolvers<undefined>()
  228. const allowFlush = Promise.withResolvers<undefined>()
  229. const flushed = Promise.withResolvers<undefined>()
  230. test.compact.operation = async () => {
  231. started.resolve(undefined)
  232. await allowClose.promise
  233. closed.resolve(undefined)
  234. await allowFlush.promise
  235. flushed.resolve(undefined)
  236. throw abort
  237. }
  238. const execution = run(test, '', controller)
  239. await started.promise
  240. controller.abort(abort)
  241. await expect(execution).rejects.toBe(abort)
  242. let disposed = false
  243. const disposal = test.plugin.dispose()
  244. void disposal.then(() => { disposed = true })
  245. await new Promise(resolve => setTimeout(resolve, 0))
  246. expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
  247. expect(disposed).toBe(false)
  248. allowClose.resolve(undefined)
  249. await closed.promise
  250. await new Promise(resolve => setTimeout(resolve, 0))
  251. expect(disposed).toBe(false)
  252. allowFlush.resolve(undefined)
  253. await flushed.promise
  254. await disposal
  255. expect(disposed).toBe(true)
  256. })
  257. })