commands.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  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 SessionStore, { 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(SessionStore)
  18. await ctx.plugin(CommandService)
  19. return ctx
  20. }
  21. /** Mint a scope whose key is a live agent (real session: the executor logs lifecycle events on it). */
  22. async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
  23. const session = ctx.sessions.create(SessionId(name))
  24. const agent = { id: session.id, session } as Agent
  25. let scope!: Scope
  26. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
  27. return { scope, agent }
  28. }
  29. /** The lifecycle slice of one agent's log (boundary markers stripped). */
  30. function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> {
  31. return agent.session.events
  32. .filter(event => event.type === 'command/run' || event.type === 'command/done')
  33. .map(event => ({ type: event.type, data: event.data }))
  34. }
  35. describe('parseCommand()', () => {
  36. it.each([
  37. ['/goal', { name: 'goal', rawInput: '' }],
  38. ['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }],
  39. ['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }],
  40. ['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }],
  41. ] as const)('parses %j without normalizing trailing input', (line, expected) => {
  42. expect(parseCommand(line)).toEqual(expected)
  43. })
  44. it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => {
  45. expect(parseCommand(line)).toBeUndefined()
  46. })
  47. })
  48. describe('CommandService', () => {
  49. it('lists immutable global descriptors with input metadata', async () => {
  50. const ctx = await mount()
  51. const { agent } = await mintAgentScope(ctx, 'a')
  52. const definition: CommandDefinition = {
  53. name: 'inspect',
  54. description: 'Inspect state',
  55. input: { hint: '<target>' },
  56. handler: () => ({ kind: 'success' }),
  57. }
  58. ctx.commands.register(definition)
  59. const listed = ctx.commands.list(agent)
  60. expect(listed).toEqual([{
  61. name: 'inspect',
  62. description: 'Inspect state',
  63. input: { hint: '<target>' },
  64. }])
  65. expect(Object.isFrozen(listed)).toBe(true)
  66. expect(Object.isFrozen(listed[0])).toBe(true)
  67. expect(Object.isFrozen(listed[0]?.input)).toBe(true)
  68. expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
  69. expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
  70. })
  71. it('sorts distinct effective command names', async () => {
  72. const ctx = await mount()
  73. const { agent } = await mintAgentScope(ctx, 'a')
  74. ctx.commands.register(command('zeta'))
  75. ctx.commands.register(command('alpha'))
  76. ctx.commands.register(command('middle'))
  77. expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
  78. })
  79. it('uses agent-scoped shadows and removes them with their scope', async () => {
  80. const ctx = await mount()
  81. const { scope, agent } = await mintAgentScope(ctx, 'a')
  82. const other = { id: 'other' as SessionId } as Agent
  83. ctx.commands.register(command('shared', 'global'))
  84. scope.ctx.commands.register(command('shared', 'scoped'))
  85. expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
  86. expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
  87. expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
  88. expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result)
  89. .toEqual({ kind: 'success', text: 'scoped' })
  90. await scope.dispose()
  91. expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global')
  92. })
  93. it('removes a registration when its contributing plugin fiber is disposed', async () => {
  94. const ctx = await mount()
  95. const { agent } = await mintAgentScope(ctx, 'a')
  96. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  97. inner.commands.register(command('temporary'))
  98. }, { inject: ['commands'] }))
  99. expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
  100. await fiber.dispose()
  101. expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
  102. })
  103. it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
  104. const ctx = await mount()
  105. const { scope } = await mintAgentScope(ctx, 'a')
  106. ctx.commands.register(command('same'))
  107. expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/)
  108. scope.ctx.commands.register(command('same'))
  109. expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/)
  110. })
  111. it('notifies on registration and disposal while containing broken observers', async () => {
  112. const ctx = await mount()
  113. const changed = vi.fn()
  114. ctx.on('commands/change', changed)
  115. const dispose = ctx.commands.register(command('live'))
  116. dispose()
  117. dispose()
  118. expect(changed).toHaveBeenCalledTimes(2)
  119. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  120. ctx.on('commands/change', () => { throw new Error('observer threw') })
  121. // oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment
  122. ctx.on('commands/change', () => Promise.reject(new Error('observer rejected')))
  123. const afterFailures = vi.fn()
  124. ctx.on('commands/change', afterFailures)
  125. const removeContained = ctx.commands.register(command('contained'))
  126. const { agent } = await mintAgentScope(ctx, 'a')
  127. expect(ctx.commands.find(agent, 'contained')).toBeDefined()
  128. expect(afterFailures).toHaveBeenCalledTimes(1)
  129. await vi.waitFor(() => {
  130. expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
  131. expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
  132. })
  133. removeContained()
  134. expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
  135. expect(afterFailures).toHaveBeenCalledTimes(2)
  136. })
  137. it('rejects non-string descriptions and input hints with boundary diagnostics', async () => {
  138. const ctx = await mount()
  139. expect(() => ctx.commands.register({
  140. ...command('description-type'),
  141. description: undefined,
  142. } as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string')
  143. expect(() => ctx.commands.register({
  144. ...command('hint-type'),
  145. input: { hint: 42 },
  146. } as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string')
  147. expect(() => ctx.commands.register({
  148. ...command('input-type'),
  149. input: null,
  150. } as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string')
  151. })
  152. it('passes exact invocation context and detaches valid handler results', async () => {
  153. const ctx = await mount()
  154. const { agent } = await mintAgentScope(ctx, 'a')
  155. const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
  156. ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
  157. const controller = new AbortController()
  158. const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
  159. expect(execution?.result).toEqual({ kind: 'success', text: 'ok' })
  160. expect(execution?.commandId).toBeTruthy()
  161. expect(Object.isFrozen(execution)).toBe(true)
  162. expect(Object.isFrozen(execution?.result)).toBe(true)
  163. expect(seen).toHaveBeenCalledWith(expect.objectContaining({
  164. agent,
  165. rawInput: ' untouched ',
  166. signal: controller.signal,
  167. }))
  168. await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined()
  169. await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined()
  170. })
  171. it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
  172. const ctx = await mount()
  173. const { agent } = await mintAgentScope(ctx, 'a')
  174. let release!: (result: { kind: 'success'; text: string }) => void
  175. ctx.commands.register({
  176. name: 'wait',
  177. description: 'Wait',
  178. handler: () => new Promise((resolve) => { release = resolve }),
  179. })
  180. const running = new AbortController()
  181. const promise = ctx.commands.execute(agent, '/wait', running.signal)
  182. running.abort('operator cancelled command')
  183. await expect(promise).rejects.toThrow('operator cancelled command')
  184. release({ kind: 'success', text: 'late' })
  185. const already = new AbortController()
  186. already.abort(new Error('already gone'))
  187. await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone')
  188. const defaultReason = new AbortController()
  189. defaultReason.abort({ source: 'test' })
  190. await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
  191. })
  192. it('propagates an asynchronously rejected handler', async () => {
  193. const ctx = await mount()
  194. const { agent } = await mintAgentScope(ctx, 'a')
  195. ctx.commands.register({
  196. name: 'reject',
  197. description: 'Reject',
  198. handler: () => Promise.reject(new Error('handler rejected')),
  199. })
  200. await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
  201. .rejects.toThrow('handler rejected')
  202. ctx.commands.register({
  203. name: 'reject-value',
  204. description: 'Reject a non-Error value',
  205. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise untyped plugin normalization
  206. handler: () => Promise.reject('not an Error'),
  207. })
  208. await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
  209. .rejects.toThrow('command handler rejected with a non-Error value: not an Error')
  210. const hostile = { toString(): string { throw new Error('cannot render') } }
  211. ctx.commands.register({
  212. name: 'reject-hostile',
  213. description: 'Reject an unrenderable value',
  214. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise hostile plugin normalization
  215. handler: () => Promise.reject(hostile),
  216. })
  217. await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))
  218. .rejects.toMatchObject({
  219. message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
  220. cause: hostile,
  221. })
  222. })
  223. it('observes an abort triggered synchronously inside the handler', async () => {
  224. const ctx = await mount()
  225. const { agent } = await mintAgentScope(ctx, 'a')
  226. const controller = new AbortController()
  227. ctx.commands.register({
  228. name: 'self-abort',
  229. description: 'Abort before returning',
  230. handler: () => {
  231. controller.abort('aborted in handler')
  232. return { kind: 'success' }
  233. },
  234. })
  235. await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
  236. .rejects.toThrow('aborted in handler')
  237. })
  238. it('returns a detached expected-error result', async () => {
  239. const ctx = await mount()
  240. const { agent } = await mintAgentScope(ctx, 'a')
  241. ctx.commands.register({
  242. name: 'denied',
  243. description: 'Denied',
  244. handler: () => ({ kind: 'error', text: 'not now' }),
  245. })
  246. const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
  247. expect(execution?.result).toEqual({ kind: 'error', text: 'not now' })
  248. expect(Object.isFrozen(execution?.result)).toBe(true)
  249. ctx.commands.register({
  250. name: 'silent',
  251. description: 'No output',
  252. handler: () => ({ kind: 'success' }),
  253. })
  254. const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
  255. expect(silent?.result).toEqual({ kind: 'success' })
  256. expect(Object.isFrozen(silent?.result)).toBe(true)
  257. })
  258. it.each([
  259. [{ ...command('Bad') }, /command name/],
  260. [{ ...command('empty-description'), description: ' ' }, /description/],
  261. [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
  262. [{ ...command('bad-handler'), handler: undefined }, /handler/],
  263. ] as const)('rejects invalid definition %#', async (definition, expected) => {
  264. const ctx = await mount()
  265. expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
  266. })
  267. it('logs a paired command/run + command/done around a successful handler', async () => {
  268. const ctx = await mount()
  269. const { agent } = await mintAgentScope(ctx, 'a')
  270. ctx.commands.register(command('deploy', 'deployed'))
  271. const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
  272. const lifecycle = lifecycleOf(agent)
  273. expect(lifecycle).toMatchObject([
  274. { type: 'command/run', data: { name: 'deploy', args: ' now', source: { kind: 'user' } } },
  275. { type: 'command/done', data: { kind: 'success', text: 'deployed' } },
  276. ])
  277. const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
  278. expect(ids[0]).toBeTruthy()
  279. expect(ids[0]).toBe(ids[1])
  280. // The execution's pairing id is the logged one (RPC-level correlation).
  281. expect(execution?.commandId).toBe(ids[0])
  282. // Direct log-only appends: no turn is opened for the pair on an idle log.
  283. expect(agent.session.events.map(event => event.type)).toEqual([
  284. 'command/run', 'command/done',
  285. ])
  286. })
  287. it('mints distinct monotonic commandIds across executions', async () => {
  288. const ctx = await mount()
  289. const { agent } = await mintAgentScope(ctx, 'a')
  290. ctx.commands.register(command('first'))
  291. ctx.commands.register(command('second'))
  292. await ctx.commands.execute(agent, '/first', new AbortController().signal)
  293. await ctx.commands.execute(agent, '/second', new AbortController().signal)
  294. const ids = lifecycleOf(agent)
  295. .filter(event => event.type === 'command/run')
  296. .map(event => (event.data as { commandId: string }).commandId)
  297. expect(new Set(ids).size).toBe(2)
  298. })
  299. it('logs command/done kind error for an expected error result', async () => {
  300. const ctx = await mount()
  301. const { agent } = await mintAgentScope(ctx, 'a')
  302. ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) })
  303. await ctx.commands.execute(agent, '/denied', new AbortController().signal)
  304. expect(lifecycleOf(agent)).toMatchObject([
  305. { type: 'command/run', data: { name: 'denied' } },
  306. { type: 'command/done', data: { kind: 'error', text: 'not now' } },
  307. ])
  308. })
  309. it('logs command/done kind error when the handler throws, and preserves the throw', async () => {
  310. const ctx = await mount()
  311. const { agent } = await mintAgentScope(ctx, 'a')
  312. ctx.commands.register({
  313. name: 'boom',
  314. description: 'Throw',
  315. handler: () => { throw new Error('handler exploded') },
  316. })
  317. await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal))
  318. .rejects.toThrow('handler exploded')
  319. expect(lifecycleOf(agent)).toMatchObject([
  320. { type: 'command/run', data: { name: 'boom' } },
  321. { type: 'command/done', data: { kind: 'error', text: 'handler exploded' } },
  322. ])
  323. })
  324. it('logs command/done kind error when the signal aborts a hanging handler', async () => {
  325. const ctx = await mount()
  326. const { agent } = await mintAgentScope(ctx, 'a')
  327. ctx.commands.register({
  328. name: 'hang',
  329. description: 'Hang',
  330. handler: () => new Promise(() => undefined),
  331. })
  332. const controller = new AbortController()
  333. const pending = ctx.commands.execute(agent, '/hang', controller.signal)
  334. // The run append must land before the abort so the pair stays complete.
  335. await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) })
  336. controller.abort('operator cancelled command')
  337. await expect(pending).rejects.toThrow('operator cancelled command')
  338. await vi.waitFor(() => {
  339. expect(lifecycleOf(agent)).toMatchObject([
  340. { type: 'command/run', data: { name: 'hang' } },
  341. { type: 'command/done', data: { kind: 'error', text: 'operator cancelled command' } },
  342. ])
  343. })
  344. })
  345. it('logs nothing for admission misses (syntax or unknown name)', async () => {
  346. const ctx = await mount()
  347. const { agent } = await mintAgentScope(ctx, 'a')
  348. ctx.commands.register(command('real'))
  349. const signal = new AbortController().signal
  350. await ctx.commands.execute(agent, 'not a command', signal)
  351. await ctx.commands.execute(agent, '/missing', signal)
  352. expect(agent.session.events).toEqual([])
  353. })
  354. it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => {
  355. const ctx = await mount()
  356. const { agent } = await mintAgentScope(ctx, 'a')
  357. ctx.commands.register(command('mid'))
  358. agent.session.append('turn/start', { turn: 1 })
  359. await ctx.commands.execute(agent, '/mid', new AbortController().signal)
  360. expect(agent.session.events.map(event => event.type)).toEqual([
  361. 'turn/start', 'command/run', 'command/done',
  362. ])
  363. })
  364. it.each([
  365. [undefined, /CommandResult/],
  366. [null, /CommandResult/],
  367. [{}, /CommandResult/],
  368. [{ kind: 'success', text: 1 }, /success text/],
  369. [{ kind: 'error', text: '' }, /error text/],
  370. [{ kind: 'error', text: 1 }, /error text/],
  371. [{ kind: 'future', text: 'x' }, /unknown result kind/],
  372. ] as const)('rejects malformed handler result %j', async (output, expected) => {
  373. const ctx = await mount()
  374. const { agent } = await mintAgentScope(ctx, 'a')
  375. ctx.commands.register({
  376. name: 'broken',
  377. description: 'Broken',
  378. handler: () => output as never,
  379. })
  380. await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
  381. })
  382. })