commands.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/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 CommandRuntime, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
  8. import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
  9. function command(name: string, text = `ran:${name}`): CommandDefinition {
  10. return {
  11. name,
  12. description: `command ${name}`,
  13. handler: () => ({ kind: 'success', text }),
  14. }
  15. }
  16. async function mount(): Promise<Context> {
  17. const ctx = new Context()
  18. await ctx.plugin(SessionStore)
  19. await ctx.plugin(CommandRuntime)
  20. return ctx
  21. }
  22. /** Mint a scope whose key is a live agent (real session: the executor logs lifecycle events on it). */
  23. async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
  24. const session = ctx.sessions.create(SessionId(name))
  25. const agent = { id: session.id, session } as Agent
  26. let scope!: Scope
  27. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
  28. return { scope, agent }
  29. }
  30. /** The lifecycle slice of one agent's log (boundary markers stripped). */
  31. function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> {
  32. return agent.session.events
  33. .filter(event => event.type === 'command/run' || event.type === 'command/done')
  34. .map(event => ({ type: event.type, data: event.data }))
  35. }
  36. describe('parseCommand()', () => {
  37. it.each([
  38. ['/goal', { name: 'goal', rawInput: '' }],
  39. ['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }],
  40. ['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }],
  41. ['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }],
  42. ] as const)('parses %j without normalizing trailing input', (line, expected) => {
  43. expect(parseCommand(line)).toEqual(expected)
  44. })
  45. it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => {
  46. expect(parseCommand(line)).toBeUndefined()
  47. })
  48. })
  49. describe('CommandRuntime', () => {
  50. it('lists immutable global descriptors with input metadata', async () => {
  51. const ctx = await mount()
  52. const { agent } = await mintAgentScope(ctx, 'a')
  53. const definition: CommandDefinition = {
  54. name: 'inspect',
  55. description: 'Inspect state',
  56. input: { hint: '<target>' },
  57. handler: () => ({ kind: 'success' }),
  58. }
  59. ctx.commands.register(definition)
  60. const listed = ctx.commands.list(agent)
  61. expect(listed).toEqual([{
  62. name: 'inspect',
  63. description: 'Inspect state',
  64. input: { hint: '<target>' },
  65. }])
  66. expect(Object.isFrozen(listed)).toBe(true)
  67. expect(Object.isFrozen(listed[0])).toBe(true)
  68. expect(Object.isFrozen(listed[0]?.input)).toBe(true)
  69. expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
  70. expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
  71. })
  72. it('sorts distinct effective command names', async () => {
  73. const ctx = await mount()
  74. const { agent } = await mintAgentScope(ctx, 'a')
  75. ctx.commands.register(command('zeta'))
  76. ctx.commands.register(command('alpha'))
  77. ctx.commands.register(command('middle'))
  78. expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
  79. })
  80. it('uses agent-scoped shadows and removes them with their scope', async () => {
  81. const ctx = await mount()
  82. const { scope, agent } = await mintAgentScope(ctx, 'a')
  83. const other = { id: 'other' as SessionId } as Agent
  84. ctx.commands.register(command('shared', 'global'))
  85. scope.ctx.commands.register(command('shared', 'scoped'))
  86. expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
  87. expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
  88. expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
  89. expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result)
  90. .toEqual({ kind: 'success', text: 'scoped' })
  91. await scope.dispose()
  92. expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result.text).toBe('global')
  93. })
  94. it('removes a registration when its contributing plugin fiber is disposed', async () => {
  95. const ctx = await mount()
  96. const { agent } = await mintAgentScope(ctx, 'a')
  97. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  98. inner.commands.register(command('temporary'))
  99. }, { inject: ['commands'] }))
  100. expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
  101. await fiber.dispose()
  102. expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
  103. })
  104. it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
  105. const ctx = await mount()
  106. const { scope } = await mintAgentScope(ctx, 'a')
  107. ctx.commands.register(command('same'))
  108. expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/)
  109. scope.ctx.commands.register(command('same'))
  110. expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/)
  111. })
  112. it('notifies on registration and disposal while containing broken observers', async () => {
  113. const ctx = await mount()
  114. const changed = vi.fn()
  115. ctx.on('commands/change', changed)
  116. const dispose = ctx.commands.register(command('live'))
  117. dispose()
  118. dispose()
  119. expect(changed).toHaveBeenCalledTimes(2)
  120. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  121. ctx.on('commands/change', () => { throw new Error('observer threw') })
  122. // oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment
  123. ctx.on('commands/change', () => Promise.reject(new Error('observer rejected')))
  124. const afterFailures = vi.fn()
  125. ctx.on('commands/change', afterFailures)
  126. const removeContained = ctx.commands.register(command('contained'))
  127. const { agent } = await mintAgentScope(ctx, 'a')
  128. expect(ctx.commands.find(agent, 'contained')).toBeDefined()
  129. expect(afterFailures).toHaveBeenCalledTimes(1)
  130. await vi.waitFor(() => {
  131. expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
  132. expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
  133. })
  134. removeContained()
  135. expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
  136. expect(afterFailures).toHaveBeenCalledTimes(2)
  137. })
  138. it('rejects non-string descriptions and input hints with boundary diagnostics', async () => {
  139. const ctx = await mount()
  140. expect(() => ctx.commands.register({
  141. ...command('description-type'),
  142. description: undefined,
  143. } as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string')
  144. expect(() => ctx.commands.register({
  145. ...command('hint-type'),
  146. input: { hint: 42 },
  147. } as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string')
  148. expect(() => ctx.commands.register({
  149. ...command('input-type'),
  150. input: null,
  151. } as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string')
  152. })
  153. it('passes exact invocation context and detaches valid handler results', async () => {
  154. const ctx = await mount()
  155. const { agent } = await mintAgentScope(ctx, 'a')
  156. const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
  157. ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
  158. const controller = new AbortController()
  159. const execution = await ctx.commands.execute(agent, '/run untouched ', [], controller.signal)
  160. expect(execution?.result).toEqual({ kind: 'success', text: 'ok' })
  161. expect(execution?.commandId).toBeTruthy()
  162. expect(Object.isFrozen(execution)).toBe(true)
  163. expect(Object.isFrozen(execution?.result)).toBe(true)
  164. expect(seen).toHaveBeenCalledWith(expect.objectContaining({
  165. agent,
  166. rawInput: ' untouched ',
  167. signal: controller.signal,
  168. }))
  169. await expect(ctx.commands.execute(agent, 'run', [], controller.signal)).resolves.toBeUndefined()
  170. await expect(ctx.commands.execute(agent, '/missing', [], controller.signal)).resolves.toBeUndefined()
  171. })
  172. it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
  173. const ctx = await mount()
  174. const { agent } = await mintAgentScope(ctx, 'a')
  175. let release!: (result: { kind: 'success'; text: string }) => void
  176. ctx.commands.register({
  177. name: 'wait',
  178. description: 'Wait',
  179. handler: () => new Promise((resolve) => { release = resolve }),
  180. })
  181. const running = new AbortController()
  182. const promise = ctx.commands.execute(agent, '/wait', [], running.signal)
  183. running.abort('operator cancelled command')
  184. await expect(promise).rejects.toThrow('operator cancelled command')
  185. release({ kind: 'success', text: 'late' })
  186. const already = new AbortController()
  187. already.abort(new Error('already gone'))
  188. await expect(ctx.commands.execute(agent, '/wait', [], already.signal)).rejects.toThrow('already gone')
  189. const defaultReason = new AbortController()
  190. defaultReason.abort({ source: 'test' })
  191. await expect(ctx.commands.execute(agent, '/wait', [], defaultReason.signal)).rejects.toThrow('command aborted')
  192. })
  193. it('propagates an asynchronously rejected handler', async () => {
  194. const ctx = await mount()
  195. const { agent } = await mintAgentScope(ctx, 'a')
  196. ctx.commands.register({
  197. name: 'reject',
  198. description: 'Reject',
  199. handler: () => Promise.reject(new Error('handler rejected')),
  200. })
  201. await expect(ctx.commands.execute(agent, '/reject', [], new AbortController().signal))
  202. .rejects.toThrow('handler rejected')
  203. ctx.commands.register({
  204. name: 'reject-value',
  205. description: 'Reject a non-Error value',
  206. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise untyped plugin normalization
  207. handler: () => Promise.reject('not an Error'),
  208. })
  209. await expect(ctx.commands.execute(agent, '/reject-value', [], new AbortController().signal))
  210. .rejects.toThrow('command handler rejected with a non-Error value: not an Error')
  211. const hostile = { toString(): string { throw new Error('cannot render') } }
  212. ctx.commands.register({
  213. name: 'reject-hostile',
  214. description: 'Reject an unrenderable value',
  215. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise hostile plugin normalization
  216. handler: () => Promise.reject(hostile),
  217. })
  218. await expect(ctx.commands.execute(agent, '/reject-hostile', [], new AbortController().signal))
  219. .rejects.toMatchObject({
  220. message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
  221. cause: hostile,
  222. })
  223. })
  224. it('observes an abort triggered synchronously inside the handler', async () => {
  225. const ctx = await mount()
  226. const { agent } = await mintAgentScope(ctx, 'a')
  227. const controller = new AbortController()
  228. ctx.commands.register({
  229. name: 'self-abort',
  230. description: 'Abort before returning',
  231. handler: () => {
  232. controller.abort('aborted in handler')
  233. return { kind: 'success' }
  234. },
  235. })
  236. await expect(ctx.commands.execute(agent, '/self-abort', [], controller.signal))
  237. .rejects.toThrow('aborted in handler')
  238. })
  239. it('returns a detached expected-error result', async () => {
  240. const ctx = await mount()
  241. const { agent } = await mintAgentScope(ctx, 'a')
  242. ctx.commands.register({
  243. name: 'denied',
  244. description: 'Denied',
  245. handler: () => ({ kind: 'error', text: 'not now' }),
  246. })
  247. const execution = await ctx.commands.execute(agent, '/denied', [], new AbortController().signal)
  248. expect(execution?.result).toEqual({ kind: 'error', text: 'not now' })
  249. expect(Object.isFrozen(execution?.result)).toBe(true)
  250. ctx.commands.register({
  251. name: 'silent',
  252. description: 'No output',
  253. handler: () => ({ kind: 'success' }),
  254. })
  255. const silent = await ctx.commands.execute(agent, '/silent', [], new AbortController().signal)
  256. expect(silent?.result).toEqual({ kind: 'success' })
  257. expect(Object.isFrozen(silent?.result)).toBe(true)
  258. })
  259. it.each([
  260. [{ ...command('Bad') }, /command name/],
  261. [{ ...command('empty-description'), description: ' ' }, /description/],
  262. [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
  263. [{ ...command('bad-handler'), handler: undefined }, /handler/],
  264. ] as const)('rejects invalid definition %#', async (definition, expected) => {
  265. const ctx = await mount()
  266. expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
  267. })
  268. it('logs a paired command/run + command/done around a successful handler', async () => {
  269. const ctx = await mount()
  270. const { agent } = await mintAgentScope(ctx, 'a')
  271. ctx.commands.register(command('deploy', 'deployed'))
  272. const execution = await ctx.commands.execute(agent, '/deploy now', [], new AbortController().signal)
  273. const lifecycle = lifecycleOf(agent)
  274. expect(lifecycle).toMatchObject([
  275. { type: 'command/run', data: { name: 'deploy', args: ' now', source: { kind: 'user' } } },
  276. { type: 'command/done', data: { kind: 'success', text: 'deployed' } },
  277. ])
  278. const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
  279. expect(ids[0]).toBeTruthy()
  280. expect(ids[0]).toBe(ids[1])
  281. // The execution's pairing id is the logged one (RPC-level correlation).
  282. expect(execution?.commandId).toBe(ids[0])
  283. // Direct log-only appends: no turn is opened for the pair on an idle log.
  284. expect(agent.session.events.map(event => event.type)).toEqual([
  285. 'command/run', 'command/done',
  286. ])
  287. })
  288. it('preserves an earlier authoritative domain-event reference on successful settlement', async () => {
  289. const ctx = await mount()
  290. const { agent } = await mintAgentScope(ctx, 'a')
  291. const source = agent.session.append('turn/start', { turn: 1 })
  292. ctx.commands.register({
  293. name: 'linked',
  294. description: 'Link outcome',
  295. handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }),
  296. })
  297. const execution = await ctx.commands.execute(agent, '/linked', [], new AbortController().signal)
  298. expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq })
  299. expect(lifecycleOf(agent)).toMatchObject([
  300. { type: 'command/run', data: { name: 'linked' } },
  301. { type: 'command/done', data: { kind: 'success', text: 'linked', sourceEventSeq: source.seq } },
  302. ])
  303. })
  304. it('omits raw input from command/run when an authoritative domain event owns it', async () => {
  305. const ctx = await mount()
  306. const { agent } = await mintAgentScope(ctx, 'a')
  307. const seen = vi.fn(() => ({ kind: 'success' as const }))
  308. ctx.commands.register({
  309. name: 'private',
  310. description: 'Record privately',
  311. recordInput: false,
  312. handler: seen,
  313. })
  314. await ctx.commands.execute(agent, '/private keep this once', [], new AbortController().signal)
  315. expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' }))
  316. const run = agent.session.events.find(event => event.type === 'command/run')
  317. expect(run?.type).toBe('command/run')
  318. expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false)
  319. })
  320. it('mints distinct monotonic commandIds across executions', async () => {
  321. const ctx = await mount()
  322. const { agent } = await mintAgentScope(ctx, 'a')
  323. ctx.commands.register(command('first'))
  324. ctx.commands.register(command('second'))
  325. await ctx.commands.execute(agent, '/first', [], new AbortController().signal)
  326. await ctx.commands.execute(agent, '/second', [], new AbortController().signal)
  327. const ids = lifecycleOf(agent)
  328. .filter(event => event.type === 'command/run')
  329. .map(event => (event.data as { commandId: string }).commandId)
  330. expect(new Set(ids).size).toBe(2)
  331. })
  332. it('logs command/done kind error for an expected error result', async () => {
  333. const ctx = await mount()
  334. const { agent } = await mintAgentScope(ctx, 'a')
  335. ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) })
  336. await ctx.commands.execute(agent, '/denied', [], new AbortController().signal)
  337. expect(lifecycleOf(agent)).toMatchObject([
  338. { type: 'command/run', data: { name: 'denied' } },
  339. { type: 'command/done', data: { kind: 'error', text: 'not now' } },
  340. ])
  341. })
  342. it('logs command/done kind error when the handler throws, and preserves the throw', async () => {
  343. const ctx = await mount()
  344. const { agent } = await mintAgentScope(ctx, 'a')
  345. ctx.commands.register({
  346. name: 'boom',
  347. description: 'Throw',
  348. handler: () => { throw new Error('handler exploded') },
  349. })
  350. await expect(ctx.commands.execute(agent, '/boom', [], new AbortController().signal))
  351. .rejects.toThrow('handler exploded')
  352. expect(lifecycleOf(agent)).toMatchObject([
  353. { type: 'command/run', data: { name: 'boom' } },
  354. { type: 'command/done', data: { kind: 'error', text: 'handler exploded' } },
  355. ])
  356. })
  357. it('logs command/done kind error when the signal aborts a hanging handler', async () => {
  358. const ctx = await mount()
  359. const { agent } = await mintAgentScope(ctx, 'a')
  360. ctx.commands.register({
  361. name: 'hang',
  362. description: 'Hang',
  363. handler: () => new Promise(() => undefined),
  364. })
  365. const controller = new AbortController()
  366. const pending = ctx.commands.execute(agent, '/hang', [], controller.signal)
  367. // The run append must land before the abort so the pair stays complete.
  368. await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) })
  369. controller.abort('operator cancelled command')
  370. await expect(pending).rejects.toThrow('operator cancelled command')
  371. await vi.waitFor(() => {
  372. expect(lifecycleOf(agent)).toMatchObject([
  373. { type: 'command/run', data: { name: 'hang' } },
  374. { type: 'command/done', data: { kind: 'error', text: 'operator cancelled command' } },
  375. ])
  376. })
  377. })
  378. it('logs nothing for admission misses (syntax or unknown name)', async () => {
  379. const ctx = await mount()
  380. const { agent } = await mintAgentScope(ctx, 'a')
  381. ctx.commands.register(command('real'))
  382. const signal = new AbortController().signal
  383. await ctx.commands.execute(agent, 'not a command', [], signal)
  384. await ctx.commands.execute(agent, '/missing', [], signal)
  385. expect(agent.session.events).toEqual([])
  386. })
  387. it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => {
  388. const ctx = await mount()
  389. const { agent } = await mintAgentScope(ctx, 'a')
  390. ctx.commands.register(command('mid'))
  391. agent.session.append('turn/start', { turn: 1 })
  392. await ctx.commands.execute(agent, '/mid', [], new AbortController().signal)
  393. expect(agent.session.events.map(event => event.type)).toEqual([
  394. 'turn/start', 'command/run', 'command/done',
  395. ])
  396. })
  397. it.each([
  398. [undefined, /CommandResult/],
  399. [null, /CommandResult/],
  400. [{}, /CommandResult/],
  401. [{ kind: 'success', text: 1 }, /success text/],
  402. [{ kind: 'success', sourceEventSeq: -1 }, /sourceEventSeq/],
  403. [{ kind: 'success', sourceEventSeq: 1.5 }, /sourceEventSeq/],
  404. [{ kind: 'success', sourceEventSeq: '1' }, /sourceEventSeq/],
  405. [{ kind: 'error', text: '' }, /error text/],
  406. [{ kind: 'error', text: 1 }, /error text/],
  407. [{ kind: 'future', text: 'x' }, /unknown result kind/],
  408. ] as const)('rejects malformed handler result %j', async (output, expected) => {
  409. const ctx = await mount()
  410. const { agent } = await mintAgentScope(ctx, 'a')
  411. ctx.commands.register({
  412. name: 'broken',
  413. description: 'Broken',
  414. handler: () => output as never,
  415. })
  416. await expect(ctx.commands.execute(agent, '/broken', [], new AbortController().signal)).rejects.toThrow(expected)
  417. })
  418. })
  419. describe('image attachments', () => {
  420. const PNG = 'AAAA'
  421. function storeOf() {
  422. let saved = 0
  423. const store = {
  424. imageLimits: {
  425. maxImageBytes: 1024, maxImagesPerMessage: 2, maxMessageImageBytes: 1024,
  426. maxImagePixels: 1_000_000, maxImageDimension: 2000, mediaTypes: ['image/png'],
  427. },
  428. validateImage: vi.fn(() => Promise.resolve()),
  429. saveImage: vi.fn((input: { mediaType: string; name?: string }) => {
  430. saved += 1
  431. return Promise.resolve({
  432. attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1,
  433. ...input.name === undefined ? {} : { name: input.name },
  434. })
  435. }),
  436. validateImageBatch(inputs: readonly unknown[]) {
  437. const validate = AttachmentStore.prototype as unknown as {
  438. validateImageBatch(this: unknown, batch: readonly unknown[]): void
  439. }
  440. validate.validateImageBatch.call(this, inputs)
  441. },
  442. // The real base-class batch method over this double's limits and members.
  443. saveImages(inputs: readonly unknown[]) {
  444. return (AttachmentStore.prototype.saveImages as (this: unknown, batch: readonly unknown[]) => Promise<unknown[]>).call(this, inputs)
  445. },
  446. }
  447. return store
  448. }
  449. function accepting(handler: CommandDefinition['handler']): CommandDefinition {
  450. return {
  451. name: 'vision',
  452. description: 'accepts images',
  453. input: { hint: '<objective>', images: true },
  454. handler,
  455. }
  456. }
  457. it('rejects a boolean-typed images flag violation at registration', async () => {
  458. const ctx = await mount()
  459. expect(() => ctx.commands.register({
  460. ...command('flag-type'),
  461. input: { hint: 'x', images: 'yes' },
  462. } as unknown as CommandDefinition)).toThrow('command "flag-type" input images flag must be a boolean')
  463. })
  464. it('lists images acceptance on the descriptor and omits a false flag', async () => {
  465. const ctx = await mount()
  466. const { agent } = await mintAgentScope(ctx, 'a')
  467. ctx.commands.register(accepting(() => ({ kind: 'success' })))
  468. ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', images: false } })
  469. const byName = new Map(ctx.commands.list(agent).map(descriptor => [descriptor.name, descriptor]))
  470. expect(byName.get('vision')?.input).toEqual({ hint: '<objective>', images: true })
  471. expect(byName.get('plain-input')?.input).toEqual({ hint: 'x' })
  472. })
  473. it('settles images sent to a non-declaring command as a logged error before the handler', async () => {
  474. const ctx = await mount()
  475. const { agent } = await mintAgentScope(ctx, 'a')
  476. const handler = vi.fn(() => ({ kind: 'success' as const }))
  477. ctx.commands.register({ ...command('deploy'), handler })
  478. const execution = await ctx.commands.execute(
  479. agent, '/deploy now', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal)
  480. expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept image attachments' })
  481. expect(handler).not.toHaveBeenCalled()
  482. expect(lifecycleOf(agent)).toMatchObject([
  483. { type: 'command/run', data: { name: 'deploy' } },
  484. { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept image attachments' } },
  485. ])
  486. })
  487. it('settles a declaring command as a logged error when no attachment store is composed', async () => {
  488. const ctx = await mount()
  489. const { agent } = await mintAgentScope(ctx, 'a')
  490. ctx.commands.register(accepting(() => ({ kind: 'success' })))
  491. const execution = await ctx.commands.execute(
  492. agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal)
  493. expect(execution?.result).toEqual({
  494. kind: 'error',
  495. text: '/vision: image attachments are unavailable because no attachment store is composed',
  496. })
  497. })
  498. it('admits and hands the handler frozen ordered image blocks; plain invocations stay empty', async () => {
  499. const ctx = await mount()
  500. ctx.provide('attachments', storeOf())
  501. const { agent } = await mintAgentScope(ctx, 'a')
  502. const seen = vi.fn((invocation: { attachments: readonly unknown[] }) => {
  503. expect(Object.isFrozen(invocation.attachments)).toBe(true)
  504. return { kind: 'success' as const }
  505. })
  506. ctx.commands.register(accepting(seen))
  507. await ctx.commands.execute(agent, '/vision x', [
  508. { mediaType: 'image/png', data: PNG, name: 'a.png' },
  509. { mediaType: 'image/png', data: PNG, name: 'b.png' },
  510. ], new AbortController().signal)
  511. const invocation = seen.mock.calls[0]?.[0] as { attachments: ReadonlyArray<{ type: string; attachment: { name?: string } }> }
  512. expect(invocation.attachments.map(block => [block.type, block.attachment.name])).toEqual([
  513. ['image', 'a.png'], ['image', 'b.png'],
  514. ])
  515. await ctx.commands.execute(agent, '/vision y', [], new AbortController().signal)
  516. expect((seen.mock.calls[1]?.[0] as { attachments: readonly unknown[] }).attachments).toEqual([])
  517. })
  518. it('settles an admission limit failure as a logged error result', async () => {
  519. const ctx = await mount()
  520. ctx.provide('attachments', storeOf())
  521. const { agent } = await mintAgentScope(ctx, 'a')
  522. const handler = vi.fn(() => ({ kind: 'success' as const }))
  523. ctx.commands.register(accepting(handler))
  524. const three = [1, 2, 3].map(() => ({ mediaType: 'image/png' as const, data: PNG }))
  525. const execution = await ctx.commands.execute(agent, '/vision x', three, new AbortController().signal)
  526. expect(execution?.result).toEqual({ kind: 'error', text: 'Image batch exceeds the configured image-count limit.' })
  527. expect(handler).not.toHaveBeenCalled()
  528. expect(lifecycleOf(agent).at(-1)).toMatchObject({ type: 'command/done', data: { kind: 'error' } })
  529. })
  530. it('honors a cancellation that lands during admission before entering the handler', async () => {
  531. const ctx = await mount()
  532. const controller = new AbortController()
  533. const store = storeOf()
  534. store.saveImage.mockImplementationOnce((input: { mediaType: string }) => {
  535. controller.abort('operator cancelled during admission')
  536. return Promise.resolve({
  537. attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1,
  538. })
  539. })
  540. ctx.provide('attachments', store)
  541. const { agent } = await mintAgentScope(ctx, 'a')
  542. const handler = vi.fn(() => ({ kind: 'success' as const }))
  543. ctx.commands.register(accepting(handler))
  544. await expect(ctx.commands.execute(
  545. agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], controller.signal,
  546. )).rejects.toThrow('operator cancelled during admission')
  547. expect(handler).not.toHaveBeenCalled()
  548. expect(lifecycleOf(agent).at(-1)).toMatchObject({
  549. type: 'command/done',
  550. data: { kind: 'error', text: 'operator cancelled during admission' },
  551. })
  552. })
  553. it('logs and rethrows a non-attachment admission failure', async () => {
  554. const ctx = await mount()
  555. const store = storeOf()
  556. store.saveImage.mockRejectedValueOnce(new Error('disk gone'))
  557. ctx.provide('attachments', store)
  558. const { agent } = await mintAgentScope(ctx, 'a')
  559. ctx.commands.register(accepting(() => ({ kind: 'success' })))
  560. await expect(ctx.commands.execute(
  561. agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal,
  562. )).rejects.toThrow('disk gone')
  563. expect(lifecycleOf(agent).at(-1)).toMatchObject({
  564. type: 'command/done',
  565. data: { kind: 'error', text: 'disk gone' },
  566. })
  567. })
  568. })