commands-upload-file.host.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  3. import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
  4. import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
  5. import type {
  6. FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
  7. } from '@deepseek-ai/dsh-attachment'
  8. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  9. import type { UserMessage } from '@deepseek-ai/dsh-llm'
  10. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  11. import CommandRuntime from '@deepseek-ai/dsh-commands'
  12. import { createScope } from '@deepseek-ai/dsh-scope'
  13. import FileUploads from '@deepseek-ai/dsh-client-file-upload'
  14. import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
  15. import { describe, expect, it, vi } from 'vitest'
  16. import type { ApiSessionAgentController } from '../src/agent.ts'
  17. import { SessionCommandController } from '../src/commands.ts'
  18. import type { SessionRequestId } from '../src/types.ts'
  19. const SESSION = SessionId('upload-session')
  20. async function uploadHarness(origin?: 'subagent'): Promise<{
  21. ctx: Context
  22. controller: SessionCommandController
  23. uploads: FileUploads
  24. agent: Agent
  25. followup: ReturnType<typeof vi.fn>
  26. saveFile: ReturnType<typeof vi.fn>
  27. saveFileStream: ReturnType<typeof vi.fn>
  28. saveImages: ReturnType<typeof vi.fn>
  29. disposeAgent: () => void
  30. uploadRoute: (request: Request) => Promise<Response>
  31. }> {
  32. const ctx = new Context()
  33. await ctx.plugin(SessionStore)
  34. await ctx.plugin(AgentRegistry)
  35. await ctx.plugin(CommandRuntime)
  36. const session = ctx.sessions.create(SESSION, {
  37. meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
  38. })
  39. const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  40. const followup = vi.fn()
  41. const agent = {
  42. id: session.id,
  43. session,
  44. inbox,
  45. status: 'idle',
  46. ctx: undefined,
  47. steer: vi.fn(),
  48. followup,
  49. cancel: vi.fn(),
  50. } as unknown as Agent
  51. ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx
  52. const disposeAgent = ctx.agents.register(agent)
  53. const saveFile = vi.fn((input: SaveFileAttachment): Promise<FileAttachmentRef> => Promise.resolve({
  54. attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
  55. name: input.name ?? 'file',
  56. bytes: input.data.byteLength,
  57. }))
  58. const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise<FileAttachmentRef> => {
  59. let bytes = 0
  60. for await (const chunk of input.data) bytes += chunk.byteLength
  61. return {
  62. attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`),
  63. name: input.name ?? 'file',
  64. bytes,
  65. }
  66. })
  67. const saveImages = vi.fn((): Promise<readonly ImageAttachmentRef[]> =>
  68. Promise.reject(new Error('fixture did not expect image persistence')))
  69. ctx.provide('attachments', Object.setPrototypeOf(
  70. { saveFile, saveFileStream, saveImages },
  71. AttachmentStore.prototype,
  72. ) as never)
  73. let uploadRoute: ((request: Request) => Promise<Response>) | undefined
  74. ctx.provide('connection', {
  75. fetch: {
  76. register: (route: { readonly fetch: (request: Request) => Promise<Response> }) => {
  77. uploadRoute = route.fetch
  78. return () => {}
  79. },
  80. },
  81. } as never)
  82. ctx.provide('llm', {
  83. listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
  84. resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
  85. } as never)
  86. const selection: ModelSelectionRef = {
  87. current: { provider: 'fixture', model: 'fixture-model' },
  88. assembled: undefined,
  89. }
  90. const agents = {
  91. resolveAgent: () => Promise.resolve({ agent }),
  92. selectionFor: () => selection,
  93. serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
  94. } as unknown as ApiSessionAgentController
  95. const uploads = new FileUploads(ctx)
  96. if (uploadRoute === undefined) throw new Error('file upload route was not registered')
  97. return {
  98. ctx,
  99. controller: new SessionCommandController(ctx, agents, '/workspace'),
  100. uploads,
  101. agent,
  102. followup,
  103. saveFile,
  104. saveFileStream,
  105. saveImages,
  106. disposeAgent,
  107. uploadRoute,
  108. }
  109. }
  110. function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
  111. return {
  112. requestId: 'req-1' as SessionRequestId,
  113. sessionId: SESSION,
  114. mode: 'queue' as const,
  115. content,
  116. }
  117. }
  118. describe('Session file uploads', () => {
  119. it('registers an HTTP route bound to the upload service', async () => {
  120. const { uploadRoute } = await uploadHarness()
  121. await expect(uploadRoute(new Request('http://host/upload')))
  122. .resolves.toMatchObject({ status: 405 })
  123. })
  124. it('stages one verbatim upload and cites it from a later prompt as a file block', async () => {
  125. const { ctx, controller, uploads, agent, followup, saveFile } = await uploadHarness()
  126. const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal)
  127. expect(saveFile).toHaveBeenCalledTimes(1)
  128. expect(receipt.file.name).toBe('notes.pdf')
  129. expect(receipt.file.bytes).toBe(3)
  130. expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
  131. expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined()
  132. const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const }))
  133. ctx.commands.register({
  134. name: 'files', description: 'Use staged files', input: { hint: '<task>', attachments: true },
  135. handler: commandHandler,
  136. })
  137. await ctx.commands.execute(
  138. agent,
  139. '/files inspect',
  140. [{ type: 'file', receiptId: receipt.receiptId }],
  141. new AbortController().signal,
  142. )
  143. expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({
  144. attachments: [{ type: 'file', attachment: receipt.file }],
  145. })
  146. await controller.prompt(promptRequest([
  147. { type: 'file', receiptId: receipt.receiptId },
  148. { type: 'text', text: 'read it' },
  149. ]))
  150. expect(followup).toHaveBeenCalledTimes(1)
  151. const message = followup.mock.calls[0]?.[0] as UserMessage
  152. expect(message.content).toEqual([
  153. { type: 'file', attachment: receipt.file },
  154. { type: 'text', text: 'read it' },
  155. ])
  156. })
  157. it('stages a bounded byte stream and forwards cancellation to storage', async () => {
  158. const { controller, uploads, followup, saveFileStream } = await uploadHarness()
  159. const abort = new AbortController()
  160. const receipt = await uploads.uploadStream({
  161. sessionId: SESSION,
  162. data: (async function* (): AsyncIterable<Uint8Array> {
  163. yield Uint8Array.of(1, 2)
  164. yield Uint8Array.of(3, 4, 5)
  165. })(),
  166. signal: abort.signal,
  167. name: 'huge.bin',
  168. })
  169. expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({
  170. signal: abort.signal,
  171. name: 'huge.bin',
  172. }))
  173. expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
  174. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  175. expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
  176. { type: 'file', attachment: receipt.file },
  177. ])
  178. })
  179. it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => {
  180. const { uploads, saveFileStream } = await uploadHarness()
  181. const stream = (async function* (): AsyncIterable<Uint8Array> {})()
  182. await expect(uploads.uploadStream({ sessionId: SESSION, data: stream }))
  183. .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } })
  184. expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream })
  185. saveFileStream.mockRejectedValueOnce('disk offline')
  186. await expect(uploads.uploadStream({
  187. sessionId: SESSION,
  188. data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
  189. }))
  190. .rejects.toMatchObject({
  191. code: 'gateway/internal', message: 'failed to store file upload: disk offline',
  192. })
  193. })
  194. it('rejects a prompt citing a file that was never staged for the session', async () => {
  195. const { controller, followup, saveImages } = await uploadHarness()
  196. await expect(controller.prompt(promptRequest([
  197. { type: 'image', mediaType: 'image/png', data: 'AAAA' },
  198. { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
  199. ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
  200. expect(followup).not.toHaveBeenCalled()
  201. expect(saveImages).not.toHaveBeenCalled()
  202. })
  203. it('publishes no receipt when its exact Agent is disposed during storage', async () => {
  204. const { uploads, agent, saveFile, disposeAgent } = await uploadHarness()
  205. const saved = Promise.withResolvers<FileAttachmentRef>()
  206. saveFile.mockReturnValueOnce(saved.promise)
  207. const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal)
  208. await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() })
  209. disposeAgent()
  210. saved.resolve({
  211. attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3,
  212. })
  213. await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' })
  214. })
  215. it('resolves a cold ordinary Agent and releases the resolver registration', async () => {
  216. const { ctx, uploads, agent, disposeAgent } = await uploadHarness()
  217. disposeAgent()
  218. const resolveAgent = vi.fn(async () => {
  219. ctx.agents.register(agent)
  220. return agent
  221. })
  222. const disposeResolver = uploads.registerAgentResolver(resolveAgent)
  223. expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
  224. await expect(uploads.uploadStream({
  225. sessionId: SESSION,
  226. data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
  227. })).resolves.toMatchObject({ file: { bytes: 1 } })
  228. expect(resolveAgent).toHaveBeenCalledWith(SESSION)
  229. disposeResolver()
  230. const replacement = vi.fn(async () => agent)
  231. const disposeReplacement = uploads.registerAgentResolver(replacement)
  232. disposeResolver()
  233. expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered')
  234. expect(disposeReplacement).toBeTypeOf('function')
  235. disposeReplacement()
  236. })
  237. it('rejects a cold upload when no Agent resolver is registered', async () => {
  238. const { uploads, disposeAgent } = await uploadHarness()
  239. disposeAgent()
  240. await expect(uploads.uploadStream({
  241. sessionId: SESSION,
  242. data: (async function* (): AsyncIterable<Uint8Array> {})(),
  243. })).rejects.toMatchObject({ code: 'session/not-found' })
  244. })
  245. it('rejects subagent uploads and access outside the receiving Agent scope', async () => {
  246. const child = await uploadHarness('subagent')
  247. await expect(child.uploads.upload(
  248. child.agent,
  249. { data: 'AAAA' },
  250. new AbortController().signal,
  251. )).rejects.toMatchObject({
  252. code: 'subagent/attachment-invalid',
  253. details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
  254. })
  255. expect(child.saveFile).not.toHaveBeenCalled()
  256. const ordinary = await uploadHarness()
  257. const receipt = await ordinary.uploads.upload(
  258. ordinary.agent,
  259. { data: 'AAAA' },
  260. new AbortController().signal,
  261. )
  262. const foreignScope = { ...ordinary.agent, ctx: ordinary.ctx } as Agent
  263. expect(() => ordinary.uploads.resolve(foreignScope, receipt.receiptId))
  264. .toThrow('operation requires the Agent\'s own scope')
  265. })
  266. it('retires accepted receipts after their rpcId becomes observable', async () => {
  267. const { controller, uploads, agent } = await uploadHarness()
  268. uploads.retirePrompt(agent, 'not-staged')
  269. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  270. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  271. expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
  272. uploads.retirePrompt(agent, 'other-request')
  273. expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
  274. uploads.retirePrompt(agent, 'req-1')
  275. expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
  276. })
  277. it('retires observed prompt receipts and drops staged state with the Session', async () => {
  278. const first = await uploadHarness()
  279. const observed = await first.uploads.upload(
  280. first.agent,
  281. { data: 'AAAA' },
  282. new AbortController().signal,
  283. )
  284. await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]))
  285. first.ctx.emit('session/event', first.agent.session, {
  286. type: 'user/message',
  287. data: createUserMessage({
  288. content: [{ type: 'text', text: 'extension event' }],
  289. source: { kind: 'user', rpcId: 1 } as never,
  290. }),
  291. } as never)
  292. expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file)
  293. first.agent.session.append('user/message', createUserMessage({
  294. content: [{ type: 'text', text: 'observed' }],
  295. source: { kind: 'user', rpcId: 'req-1' as SessionRequestId },
  296. }), { surfaceOp: 'append' })
  297. expect(first.uploads.resolve(first.agent, observed.receiptId)).toBeUndefined()
  298. const second = await uploadHarness()
  299. const abandoned = await second.uploads.upload(
  300. second.agent,
  301. { data: 'AAAA' },
  302. new AbortController().signal,
  303. )
  304. second.ctx.emit('session/disposed', second.agent.session)
  305. expect(second.uploads.resolve(second.agent, abandoned.receiptId)).toBeUndefined()
  306. })
  307. it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
  308. const { controller, agent, followup } = await uploadHarness()
  309. const request = promptRequest([{ type: 'text', text: 'once' }])
  310. await controller.prompt(request)
  311. agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
  312. await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
  313. expect(followup).toHaveBeenCalledOnce()
  314. })
  315. it('deduplicates a retried rpcId already present in the durable log', async () => {
  316. const { controller, agent, followup } = await uploadHarness()
  317. const request = promptRequest([{ type: 'text', text: 'once' }])
  318. agent.session.append('turn/start', { turn: 1 })
  319. agent.session.append('user/message', createUserMessage({
  320. content: [{ type: 'text', text: 'unidentified' }],
  321. source: { kind: 'user' },
  322. }), { surfaceOp: 'append' })
  323. agent.session.append('user/message', createUserMessage({
  324. content: [{ type: 'text', text: 'accepted' }],
  325. source: { kind: 'user', rpcId: request.requestId },
  326. }), { surfaceOp: 'append' })
  327. await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
  328. expect(followup).not.toHaveBeenCalled()
  329. })
  330. it('rejects when the Agent disappears during prompt admission', async () => {
  331. const { controller, saveImages, disposeAgent } = await uploadHarness()
  332. const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
  333. saveImages.mockReturnValueOnce(admitted.promise)
  334. const prompting = controller.prompt(promptRequest([
  335. { type: 'image', mediaType: 'image/png', data: 'AAAA' },
  336. ]))
  337. await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
  338. disposeAgent()
  339. admitted.resolve([{
  340. attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
  341. }])
  342. await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' })
  343. })
  344. it('rejects when a previously bound receipt retires during image admission', async () => {
  345. const { controller, uploads, agent, saveImages } = await uploadHarness()
  346. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  347. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  348. const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
  349. saveImages.mockReturnValueOnce(admitted.promise)
  350. const prompting = controller.prompt({
  351. ...promptRequest([
  352. { type: 'file', receiptId: receipt.receiptId },
  353. { type: 'image', mediaType: 'image/png', data: 'AAAA' },
  354. ]),
  355. requestId: 'req-2' as SessionRequestId,
  356. })
  357. await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
  358. uploads.retirePrompt(agent, 'req-1')
  359. admitted.resolve([{
  360. attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
  361. }])
  362. await expect(prompting).rejects.toMatchObject({
  363. code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' },
  364. })
  365. })
  366. it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
  367. const { controller, uploads, agent, followup } = await uploadHarness()
  368. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  369. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  370. followup.mockImplementationOnce(() => { throw new Error('busy') })
  371. await expect(controller.prompt({
  372. ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
  373. requestId: 'req-2' as SessionRequestId,
  374. })).rejects.toMatchObject({ code: 'session/agent-busy' })
  375. uploads.retirePrompt(agent, 'req-1')
  376. expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
  377. })
  378. it('restores an unbound receipt after prompt delivery fails', async () => {
  379. const { controller, uploads, agent, followup } = await uploadHarness()
  380. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  381. followup.mockImplementationOnce(() => { throw new Error('busy') })
  382. await expect(controller.prompt(promptRequest([
  383. { type: 'file', receiptId: receipt.receiptId },
  384. ]))).rejects.toMatchObject({ code: 'session/agent-busy' })
  385. uploads.retirePrompt(agent, 'req-1')
  386. expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
  387. })
  388. it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
  389. const { controller, uploads, agent, followup } = await uploadHarness()
  390. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  391. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  392. const queued = followup.mock.calls[0]?.[0] as UserMessage
  393. agent.inbox.append('next-turn', queued)
  394. expect(controller.updateQueue({
  395. sessionId: SESSION,
  396. itemId: queued.id,
  397. action: { kind: 'remove' },
  398. })).toEqual({ accepted: true })
  399. expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
  400. })
  401. it('keeps separate names for identical bytes uploaded more than once', async () => {
  402. const { controller, uploads, agent, followup } = await uploadHarness()
  403. const first = await uploads.upload(agent, { data: 'AAAA', name: 'first.txt' }, new AbortController().signal)
  404. const second = await uploads.upload(agent, { data: 'AAAA', name: 'second.txt' }, new AbortController().signal)
  405. expect(first.file.attachmentId).toBe(second.file.attachmentId)
  406. expect(first.receiptId).not.toBe(second.receiptId)
  407. await controller.prompt(promptRequest([
  408. { type: 'file', receiptId: first.receiptId },
  409. { type: 'file', receiptId: second.receiptId },
  410. ]))
  411. const message = followup.mock.calls[0]?.[0] as UserMessage
  412. expect(message.content).toEqual([
  413. { type: 'file', attachment: first.file },
  414. { type: 'file', attachment: second.file },
  415. ])
  416. })
  417. it('maps a non-canonical payload to the wire attachment error', async () => {
  418. const { uploads, agent, saveFile } = await uploadHarness()
  419. await expect(uploads.upload(agent, { data: 'not base64!!' }, new AbortController().signal))
  420. .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } })
  421. expect(saveFile).not.toHaveBeenCalled()
  422. })
  423. it('maps an unexpected storage failure to the internal wire error', async () => {
  424. const { uploads, agent, saveFile } = await uploadHarness()
  425. saveFile.mockRejectedValueOnce(new Error('disk unavailable'))
  426. await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal))
  427. .rejects.toMatchObject({
  428. code: 'gateway/internal',
  429. message: 'failed to store file upload: Error: disk unavailable',
  430. })
  431. })
  432. })