commands.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { createScope } from '@deepseek-ai/dsh-scope'
  4. import type { Scope } from '@deepseek-ai/dsh-scope'
  5. import type { Agent } from '@deepseek-ai/dsh-agent'
  6. import type { SessionId } from '@deepseek-ai/dsh-session'
  7. import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
  8. function command(name: string, text = `ran:${name}`): CommandDefinition {
  9. return {
  10. name,
  11. description: `command ${name}`,
  12. handler: () => ({ kind: 'success', text }),
  13. }
  14. }
  15. async function mount(): Promise<Context> {
  16. const ctx = new Context()
  17. await ctx.plugin(CommandService)
  18. return ctx
  19. }
  20. /** Mint a scope whose key is sufficient for registry lookup and invocation. */
  21. async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
  22. const agent = { id: name as SessionId } as Agent
  23. let scope!: Scope
  24. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
  25. return { scope, agent }
  26. }
  27. describe('parseCommand()', () => {
  28. it.each([
  29. ['/goal', { name: 'goal', rawInput: '' }],
  30. ['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }],
  31. ['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }],
  32. ['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }],
  33. ] as const)('parses %j without normalizing trailing input', (line, expected) => {
  34. expect(parseCommand(line)).toEqual(expected)
  35. })
  36. it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => {
  37. expect(parseCommand(line)).toBeUndefined()
  38. })
  39. })
  40. describe('CommandService', () => {
  41. it('lists immutable global descriptors with input metadata', async () => {
  42. const ctx = await mount()
  43. const { agent } = await mintAgentScope(ctx, 'a')
  44. const definition: CommandDefinition = {
  45. name: 'inspect',
  46. description: 'Inspect state',
  47. input: { hint: '<target>' },
  48. handler: () => ({ kind: 'success' }),
  49. }
  50. ctx.commands.register(definition)
  51. const listed = ctx.commands.list(agent)
  52. expect(listed).toEqual([{
  53. name: 'inspect',
  54. description: 'Inspect state',
  55. input: { hint: '<target>' },
  56. }])
  57. expect(Object.isFrozen(listed)).toBe(true)
  58. expect(Object.isFrozen(listed[0])).toBe(true)
  59. expect(Object.isFrozen(listed[0]?.input)).toBe(true)
  60. expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
  61. expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
  62. })
  63. it('sorts distinct effective command names', async () => {
  64. const ctx = await mount()
  65. const { agent } = await mintAgentScope(ctx, 'a')
  66. ctx.commands.register(command('zeta'))
  67. ctx.commands.register(command('alpha'))
  68. ctx.commands.register(command('middle'))
  69. expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
  70. })
  71. it('uses agent-scoped shadows and removes them with their scope', async () => {
  72. const ctx = await mount()
  73. const { scope, agent } = await mintAgentScope(ctx, 'a')
  74. const other = { id: 'other' as SessionId } as Agent
  75. ctx.commands.register(command('shared', 'global'))
  76. scope.ctx.commands.register(command('shared', 'scoped'))
  77. expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
  78. expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
  79. expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
  80. expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
  81. .toEqual({ kind: 'success', text: 'scoped' })
  82. await scope.dispose()
  83. expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
  84. })
  85. it('removes a registration when its contributing plugin fiber is disposed', async () => {
  86. const ctx = await mount()
  87. const { agent } = await mintAgentScope(ctx, 'a')
  88. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  89. inner.commands.register(command('temporary'))
  90. }, { inject: ['commands'] }))
  91. expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
  92. await fiber.dispose()
  93. expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
  94. })
  95. it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
  96. const ctx = await mount()
  97. const { scope } = await mintAgentScope(ctx, 'a')
  98. ctx.commands.register(command('same'))
  99. expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/)
  100. scope.ctx.commands.register(command('same'))
  101. expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/)
  102. })
  103. it('notifies on registration and disposal while containing broken observers', async () => {
  104. const ctx = await mount()
  105. const changed = vi.fn()
  106. ctx.on('commands/change', changed)
  107. const dispose = ctx.commands.register(command('live'))
  108. dispose()
  109. dispose()
  110. expect(changed).toHaveBeenCalledTimes(2)
  111. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  112. ctx.on('commands/change', () => { throw new Error('observer threw') })
  113. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
  114. ctx.on('commands/change', () => Promise.reject(new Error('observer rejected')))
  115. const afterFailures = vi.fn()
  116. ctx.on('commands/change', afterFailures)
  117. const removeContained = ctx.commands.register(command('contained'))
  118. const { agent } = await mintAgentScope(ctx, 'a')
  119. expect(ctx.commands.find(agent, 'contained')).toBeDefined()
  120. expect(afterFailures).toHaveBeenCalledTimes(1)
  121. await vi.waitFor(() => {
  122. expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
  123. expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
  124. })
  125. removeContained()
  126. expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
  127. expect(afterFailures).toHaveBeenCalledTimes(2)
  128. })
  129. it('rejects non-string descriptions and input hints with boundary diagnostics', async () => {
  130. const ctx = await mount()
  131. expect(() => ctx.commands.register({
  132. ...command('description-type'),
  133. description: undefined,
  134. } as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string')
  135. expect(() => ctx.commands.register({
  136. ...command('hint-type'),
  137. input: { hint: 42 },
  138. } as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string')
  139. expect(() => ctx.commands.register({
  140. ...command('input-type'),
  141. input: null,
  142. } as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string')
  143. })
  144. it('passes exact invocation context and detaches valid handler results', async () => {
  145. const ctx = await mount()
  146. const { agent } = await mintAgentScope(ctx, 'a')
  147. const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
  148. ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
  149. const controller = new AbortController()
  150. const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
  151. expect(result).toEqual({ kind: 'success', text: 'ok' })
  152. expect(Object.isFrozen(result)).toBe(true)
  153. expect(seen).toHaveBeenCalledWith(expect.objectContaining({
  154. agent,
  155. rawInput: ' untouched ',
  156. signal: controller.signal,
  157. }))
  158. await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined()
  159. await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined()
  160. })
  161. it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
  162. const ctx = await mount()
  163. const { agent } = await mintAgentScope(ctx, 'a')
  164. let release!: (result: { kind: 'success'; text: string }) => void
  165. ctx.commands.register({
  166. name: 'wait',
  167. description: 'Wait',
  168. handler: () => new Promise((resolve) => { release = resolve }),
  169. })
  170. const running = new AbortController()
  171. const promise = ctx.commands.execute(agent, '/wait', running.signal)
  172. running.abort('operator cancelled command')
  173. await expect(promise).rejects.toThrow('operator cancelled command')
  174. release({ kind: 'success', text: 'late' })
  175. const already = new AbortController()
  176. already.abort(new Error('already gone'))
  177. await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone')
  178. const defaultReason = new AbortController()
  179. defaultReason.abort({ source: 'test' })
  180. await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
  181. })
  182. it('propagates an asynchronously rejected handler', async () => {
  183. const ctx = await mount()
  184. const { agent } = await mintAgentScope(ctx, 'a')
  185. ctx.commands.register({
  186. name: 'reject',
  187. description: 'Reject',
  188. handler: () => Promise.reject(new Error('handler rejected')),
  189. })
  190. await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
  191. .rejects.toThrow('handler rejected')
  192. ctx.commands.register({
  193. name: 'reject-value',
  194. description: 'Reject a non-Error value',
  195. // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
  196. handler: () => Promise.reject('not an Error'),
  197. })
  198. await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
  199. .rejects.toThrow('command handler rejected with a non-Error value: not an Error')
  200. const hostile = { toString(): string { throw new Error('cannot render') } }
  201. ctx.commands.register({
  202. name: 'reject-hostile',
  203. description: 'Reject an unrenderable value',
  204. // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
  205. handler: () => Promise.reject(hostile),
  206. })
  207. await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))
  208. .rejects.toMatchObject({
  209. message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
  210. cause: hostile,
  211. })
  212. })
  213. it('observes an abort triggered synchronously inside the handler', async () => {
  214. const ctx = await mount()
  215. const { agent } = await mintAgentScope(ctx, 'a')
  216. const controller = new AbortController()
  217. ctx.commands.register({
  218. name: 'self-abort',
  219. description: 'Abort before returning',
  220. handler: () => {
  221. controller.abort('aborted in handler')
  222. return { kind: 'success' }
  223. },
  224. })
  225. await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
  226. .rejects.toThrow('aborted in handler')
  227. })
  228. it('returns a detached expected-error result', async () => {
  229. const ctx = await mount()
  230. const { agent } = await mintAgentScope(ctx, 'a')
  231. ctx.commands.register({
  232. name: 'denied',
  233. description: 'Denied',
  234. handler: () => ({ kind: 'error', text: 'not now' }),
  235. })
  236. const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
  237. expect(result).toEqual({ kind: 'error', text: 'not now' })
  238. expect(Object.isFrozen(result)).toBe(true)
  239. ctx.commands.register({
  240. name: 'silent',
  241. description: 'No output',
  242. handler: () => ({ kind: 'success' }),
  243. })
  244. const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
  245. expect(silent).toEqual({ kind: 'success' })
  246. expect(Object.isFrozen(silent)).toBe(true)
  247. })
  248. it.each([
  249. [{ ...command('Bad') }, /command name/],
  250. [{ ...command('empty-description'), description: ' ' }, /description/],
  251. [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
  252. [{ ...command('bad-handler'), handler: undefined }, /handler/],
  253. ] as const)('rejects invalid definition %#', async (definition, expected) => {
  254. const ctx = await mount()
  255. expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
  256. })
  257. it.each([
  258. [undefined, /CommandResult/],
  259. [null, /CommandResult/],
  260. [{}, /CommandResult/],
  261. [{ kind: 'success', text: 1 }, /success text/],
  262. [{ kind: 'error', text: '' }, /error text/],
  263. [{ kind: 'error', text: 1 }, /error text/],
  264. [{ kind: 'future', text: 'x' }, /unknown result kind/],
  265. ] as const)('rejects malformed handler result %j', async (output, expected) => {
  266. const ctx = await mount()
  267. const { agent } = await mintAgentScope(ctx, 'a')
  268. ctx.commands.register({
  269. name: 'broken',
  270. description: 'Broken',
  271. handler: () => output as never,
  272. })
  273. await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
  274. })
  275. })