| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- import { Context } from '@deepseek-ai/cordis'
- import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
- import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
- import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
- import type {
- FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
- } from '@deepseek-ai/dsh-attachment'
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
- import type { UserMessage } from '@deepseek-ai/dsh-llm'
- import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
- import CommandRuntime from '@deepseek-ai/dsh-commands'
- import { createScope } from '@deepseek-ai/dsh-scope'
- import FileUploads from '@deepseek-ai/dsh-client-file-upload'
- import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
- import { describe, expect, it, vi } from 'vitest'
- import type { ApiSessionAgentController } from '../src/agent.ts'
- import { SessionCommandController } from '../src/commands.ts'
- import type { SessionRequestId } from '../src/types.ts'
- const SESSION = SessionId('upload-session')
- async function uploadHarness(origin?: 'subagent'): Promise<{
- ctx: Context
- controller: SessionCommandController
- uploads: FileUploads
- agent: Agent
- followup: ReturnType<typeof vi.fn>
- saveFile: ReturnType<typeof vi.fn>
- saveFileStream: ReturnType<typeof vi.fn>
- saveImages: ReturnType<typeof vi.fn>
- disposeAgent: () => void
- uploadRoute: (request: Request) => Promise<Response>
- }> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(CommandRuntime)
- const session = ctx.sessions.create(SESSION, {
- meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
- })
- const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
- const followup = vi.fn()
- const agent = {
- id: session.id,
- session,
- inbox,
- status: 'idle',
- ctx: undefined,
- steer: vi.fn(),
- followup,
- cancel: vi.fn(),
- } as unknown as Agent
- ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx
- const disposeAgent = ctx.agents.register(agent)
- const saveFile = vi.fn((input: SaveFileAttachment): Promise<FileAttachmentRef> => Promise.resolve({
- attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
- name: input.name ?? 'file',
- bytes: input.data.byteLength,
- }))
- const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise<FileAttachmentRef> => {
- let bytes = 0
- for await (const chunk of input.data) bytes += chunk.byteLength
- return {
- attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`),
- name: input.name ?? 'file',
- bytes,
- }
- })
- const saveImages = vi.fn((): Promise<readonly ImageAttachmentRef[]> =>
- Promise.reject(new Error('fixture did not expect image persistence')))
- ctx.provide('attachments', Object.setPrototypeOf(
- { saveFile, saveFileStream, saveImages },
- AttachmentStore.prototype,
- ) as never)
- let uploadRoute: ((request: Request) => Promise<Response>) | undefined
- ctx.provide('connection', {
- fetch: {
- register: (route: { readonly fetch: (request: Request) => Promise<Response> }) => {
- uploadRoute = route.fetch
- return () => {}
- },
- },
- } as never)
- ctx.provide('llm', {
- listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
- resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
- } as never)
- const selection: ModelSelectionRef = {
- current: { provider: 'fixture', model: 'fixture-model' },
- assembled: undefined,
- }
- const agents = {
- resolveAgent: () => Promise.resolve({ agent }),
- selectionFor: () => selection,
- serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
- } as unknown as ApiSessionAgentController
- const uploads = new FileUploads(ctx)
- if (uploadRoute === undefined) throw new Error('file upload route was not registered')
- return {
- ctx,
- controller: new SessionCommandController(ctx, agents, '/workspace'),
- uploads,
- agent,
- followup,
- saveFile,
- saveFileStream,
- saveImages,
- disposeAgent,
- uploadRoute,
- }
- }
- function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
- return {
- requestId: 'req-1' as SessionRequestId,
- sessionId: SESSION,
- mode: 'queue' as const,
- content,
- }
- }
- describe('Session file uploads', () => {
- it('registers an HTTP route bound to the upload service', async () => {
- const { uploadRoute } = await uploadHarness()
- await expect(uploadRoute(new Request('http://host/upload')))
- .resolves.toMatchObject({ status: 405 })
- })
- it('stages one verbatim upload and preserves its order with an admitted image', async () => {
- const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness()
- const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal)
- expect(saveFile).toHaveBeenCalledTimes(1)
- expect(receipt.file.name).toBe('notes.pdf')
- expect(receipt.file.bytes).toBe(3)
- expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
- expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined()
- const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const }))
- ctx.commands.register({
- name: 'files', description: 'Use staged files', input: { hint: '<task>', attachments: true },
- handler: commandHandler,
- })
- await ctx.commands.execute(
- agent,
- '/files inspect',
- [{ type: 'file', receiptId: receipt.receiptId }],
- new AbortController().signal,
- )
- expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({
- attachments: [{ type: 'file', attachment: receipt.file }],
- })
- const image: ImageAttachmentRef = {
- attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`),
- mediaType: 'image/png',
- bytes: 3,
- width: 1,
- height: 1,
- }
- saveImages.mockResolvedValueOnce([image])
- await controller.prompt(promptRequest([
- { type: 'file', receiptId: receipt.receiptId },
- { type: 'image', mediaType: 'image/png', data: 'AAAA' },
- { type: 'text', text: 'read it' },
- ]))
- expect(followup).toHaveBeenCalledTimes(1)
- const message = followup.mock.calls[0]?.[0] as UserMessage
- expect(message.content).toEqual([
- { type: 'file', attachment: receipt.file },
- { type: 'image', attachment: image },
- { type: 'text', text: 'read it' },
- ])
- })
- it('stages a bounded byte stream and forwards cancellation to storage', async () => {
- const { controller, uploads, followup, saveFileStream } = await uploadHarness()
- const abort = new AbortController()
- const receipt = await uploads.uploadStream({
- sessionId: SESSION,
- data: (async function* (): AsyncIterable<Uint8Array> {
- yield Uint8Array.of(1, 2)
- yield Uint8Array.of(3, 4, 5)
- })(),
- signal: abort.signal,
- name: 'huge.bin',
- })
- expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({
- signal: abort.signal,
- name: 'huge.bin',
- }))
- expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
- await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
- expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
- { type: 'file', attachment: receipt.file },
- ])
- })
- it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => {
- const { uploads, saveFileStream } = await uploadHarness()
- const stream = (async function* (): AsyncIterable<Uint8Array> {})()
- await expect(uploads.uploadStream({ sessionId: SESSION, data: stream }))
- .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } })
- expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream })
- saveFileStream.mockRejectedValueOnce('disk offline')
- await expect(uploads.uploadStream({
- sessionId: SESSION,
- data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
- }))
- .rejects.toMatchObject({
- code: 'gateway/internal', message: 'failed to store file upload: disk offline',
- })
- })
- it('rejects a prompt citing a file that was never staged for the session', async () => {
- const { controller, followup, saveImages } = await uploadHarness()
- await expect(controller.prompt(promptRequest([
- { type: 'image', mediaType: 'image/png', data: 'AAAA' },
- { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
- ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
- expect(followup).not.toHaveBeenCalled()
- expect(saveImages).not.toHaveBeenCalled()
- })
- it('publishes no receipt when its exact Agent is disposed during storage', async () => {
- const { uploads, agent, saveFile, disposeAgent } = await uploadHarness()
- const saved = Promise.withResolvers<FileAttachmentRef>()
- saveFile.mockReturnValueOnce(saved.promise)
- const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal)
- await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() })
- disposeAgent()
- saved.resolve({
- attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3,
- })
- await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' })
- })
- it('resolves a cold ordinary Agent and releases the resolver registration', async () => {
- const { ctx, uploads, agent, disposeAgent } = await uploadHarness()
- disposeAgent()
- const resolveAgent = vi.fn(async () => {
- ctx.agents.register(agent)
- return agent
- })
- const disposeResolver = uploads.registerAgentResolver(resolveAgent)
- expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
- await expect(uploads.uploadStream({
- sessionId: SESSION,
- data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
- })).resolves.toMatchObject({ file: { bytes: 1 } })
- expect(resolveAgent).toHaveBeenCalledWith(SESSION)
- disposeResolver()
- const replacement = vi.fn(async () => agent)
- const disposeReplacement = uploads.registerAgentResolver(replacement)
- disposeResolver()
- expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered')
- expect(disposeReplacement).toBeTypeOf('function')
- disposeReplacement()
- })
- it('rejects a cold upload when no Agent resolver is registered', async () => {
- const { uploads, disposeAgent } = await uploadHarness()
- disposeAgent()
- await expect(uploads.uploadStream({
- sessionId: SESSION,
- data: (async function* (): AsyncIterable<Uint8Array> {})(),
- })).rejects.toMatchObject({ code: 'session/not-found' })
- })
- it('rejects subagent uploads and access outside the receiving Agent scope', async () => {
- const child = await uploadHarness('subagent')
- await expect(child.uploads.upload(
- child.agent,
- { data: 'AAAA' },
- new AbortController().signal,
- )).rejects.toMatchObject({
- code: 'subagent/attachment-invalid',
- details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
- })
- expect(child.saveFile).not.toHaveBeenCalled()
- const ordinary = await uploadHarness()
- const receipt = await ordinary.uploads.upload(
- ordinary.agent,
- { data: 'AAAA' },
- new AbortController().signal,
- )
- const foreignScope = { ...ordinary.agent, ctx: ordinary.ctx } as Agent
- expect(() => ordinary.uploads.resolve(foreignScope, receipt.receiptId))
- .toThrow('operation requires the Agent\'s own scope')
- })
- it('retires accepted receipts after their rpcId becomes observable', async () => {
- const { controller, uploads, agent } = await uploadHarness()
- uploads.retirePrompt(agent, 'not-staged')
- const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
- await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
- expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
- uploads.retirePrompt(agent, 'other-request')
- expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
- uploads.retirePrompt(agent, 'req-1')
- expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
- })
- it('retires observed prompt receipts and drops staged state with the Session', async () => {
- const first = await uploadHarness()
- const observed = await first.uploads.upload(
- first.agent,
- { data: 'AAAA' },
- new AbortController().signal,
- )
- await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]))
- first.ctx.emit('session/event', first.agent.session, {
- type: 'user/message',
- data: createUserMessage({
- content: [{ type: 'text', text: 'extension event' }],
- source: { kind: 'user', rpcId: 1 } as never,
- }),
- } as never)
- expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file)
- first.agent.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'observed' }],
- source: { kind: 'user', rpcId: 'req-1' as SessionRequestId },
- }), { surfaceOp: 'append' })
- expect(first.uploads.resolve(first.agent, observed.receiptId)).toBeUndefined()
- const second = await uploadHarness()
- const abandoned = await second.uploads.upload(
- second.agent,
- { data: 'AAAA' },
- new AbortController().signal,
- )
- second.ctx.emit('session/disposed', second.agent.session)
- expect(second.uploads.resolve(second.agent, abandoned.receiptId)).toBeUndefined()
- })
- it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
- const { controller, agent, followup } = await uploadHarness()
- const request = promptRequest([{ type: 'text', text: 'once' }])
- await controller.prompt(request)
- agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
- await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
- expect(followup).toHaveBeenCalledOnce()
- })
- it('deduplicates a retried rpcId already present in the durable log', async () => {
- const { controller, agent, followup } = await uploadHarness()
- const request = promptRequest([{ type: 'text', text: 'once' }])
- agent.session.append('turn/start', { turn: 1 })
- agent.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'unidentified' }],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- agent.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'accepted' }],
- source: { kind: 'user', rpcId: request.requestId },
- }), { surfaceOp: 'append' })
- await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
- expect(followup).not.toHaveBeenCalled()
- })
- it('rejects when the Agent disappears during prompt admission', async () => {
- const { controller, saveImages, disposeAgent } = await uploadHarness()
- const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
- saveImages.mockReturnValueOnce(admitted.promise)
- const prompting = controller.prompt(promptRequest([
- { type: 'image', mediaType: 'image/png', data: 'AAAA' },
- ]))
- await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
- disposeAgent()
- admitted.resolve([{
- attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
- }])
- await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' })
- })
- it('rejects when a previously bound receipt retires during image admission', async () => {
- const { controller, uploads, agent, saveImages } = await uploadHarness()
- const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
- await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
- const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
- saveImages.mockReturnValueOnce(admitted.promise)
- const prompting = controller.prompt({
- ...promptRequest([
- { type: 'file', receiptId: receipt.receiptId },
- { type: 'image', mediaType: 'image/png', data: 'AAAA' },
- ]),
- requestId: 'req-2' as SessionRequestId,
- })
- await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
- uploads.retirePrompt(agent, 'req-1')
- admitted.resolve([{
- attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
- }])
- await expect(prompting).rejects.toMatchObject({
- code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' },
- })
- })
- it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
- const { controller, uploads, agent, followup } = await uploadHarness()
- const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
- await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
- followup.mockImplementationOnce(() => { throw new Error('busy') })
- await expect(controller.prompt({
- ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
- requestId: 'req-2' as SessionRequestId,
- })).rejects.toMatchObject({ code: 'session/agent-busy' })
- uploads.retirePrompt(agent, 'req-1')
- expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
- })
- it('restores an unbound receipt after prompt delivery fails', async () => {
- const { controller, uploads, agent, followup } = await uploadHarness()
- const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
- followup.mockImplementationOnce(() => { throw new Error('busy') })
- await expect(controller.prompt(promptRequest([
- { type: 'file', receiptId: receipt.receiptId },
- ]))).rejects.toMatchObject({ code: 'session/agent-busy' })
- uploads.retirePrompt(agent, 'req-1')
- expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
- })
- it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
- const { controller, uploads, agent, followup } = await uploadHarness()
- const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
- await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
- const queued = followup.mock.calls[0]?.[0] as UserMessage
- agent.inbox.append('next-turn', queued)
- expect(controller.updateQueue({
- sessionId: SESSION,
- itemId: queued.id,
- action: { kind: 'remove' },
- })).toEqual({ accepted: true })
- expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
- })
- it('keeps separate names for identical bytes uploaded more than once', async () => {
- const { controller, uploads, agent, followup } = await uploadHarness()
- const first = await uploads.upload(agent, { data: 'AAAA', name: 'first.txt' }, new AbortController().signal)
- const second = await uploads.upload(agent, { data: 'AAAA', name: 'second.txt' }, new AbortController().signal)
- expect(first.file.attachmentId).toBe(second.file.attachmentId)
- expect(first.receiptId).not.toBe(second.receiptId)
- await controller.prompt(promptRequest([
- { type: 'file', receiptId: first.receiptId },
- { type: 'file', receiptId: second.receiptId },
- ]))
- const message = followup.mock.calls[0]?.[0] as UserMessage
- expect(message.content).toEqual([
- { type: 'file', attachment: first.file },
- { type: 'file', attachment: second.file },
- ])
- })
- it('maps a non-canonical payload to the wire attachment error', async () => {
- const { uploads, agent, saveFile } = await uploadHarness()
- await expect(uploads.upload(agent, { data: 'not base64!!' }, new AbortController().signal))
- .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } })
- expect(saveFile).not.toHaveBeenCalled()
- })
- it('maps an unexpected storage failure to the internal wire error', async () => {
- const { uploads, agent, saveFile } = await uploadHarness()
- saveFile.mockRejectedValueOnce(new Error('disk unavailable'))
- await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal))
- .rejects.toMatchObject({
- code: 'gateway/internal',
- message: 'failed to store file upload: Error: disk unavailable',
- })
- })
- })
|