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

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