command-compact.spec.ts 9.7 KB

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