|
|
@@ -9,12 +9,14 @@ import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-cl
|
|
|
import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
|
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
|
|
import { InputHub } from '../src/client/input/hub.ts'
|
|
|
-import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
|
|
|
+import { ConversationController } from '../src/client/service.ts'
|
|
|
import { zh } from '../src/client/locales.ts'
|
|
|
|
|
|
-async function bench() {
|
|
|
+async function bench(maxConcurrentFileUploads = 2) {
|
|
|
const runtime = await SlotTestRuntime.create()
|
|
|
- const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
|
|
+ const prompt = vi.fn((
|
|
|
+ _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string,
|
|
|
+ ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
|
|
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
|
|
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
|
|
const loadOlder = vi.fn(() => Promise.resolve())
|
|
|
@@ -28,6 +30,7 @@ async function bench() {
|
|
|
const fiber = runtime.ctx.plugin(ConversationController, {
|
|
|
input: hub,
|
|
|
blocks: new ComposerBlockRegistry(),
|
|
|
+ maxConcurrentFileUploads,
|
|
|
})
|
|
|
await fiber.await()
|
|
|
const root = runtime.ctx.get('conversation') as ConversationController
|
|
|
@@ -87,13 +90,13 @@ describe('ConversationController', () => {
|
|
|
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
|
|
|
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
|
|
try {
|
|
|
- const [attachment] = b.root.createDraftImages([
|
|
|
+ const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
|
|
|
new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }),
|
|
|
])
|
|
|
if (attachment === undefined) throw new Error('draft attachment missing')
|
|
|
- b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id])
|
|
|
+ b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id])
|
|
|
await b.runtime.sessions.remove('s1')
|
|
|
- expect(b.root.draftImages([attachment.id])).toEqual([])
|
|
|
+ expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
|
|
|
expect(revoked).toHaveBeenCalledWith('blob:draft-1')
|
|
|
} finally {
|
|
|
created.mockRestore()
|
|
|
@@ -107,15 +110,15 @@ describe('ConversationController', () => {
|
|
|
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached')
|
|
|
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
|
|
try {
|
|
|
- const [attachment] = b.root.createDraftImages([
|
|
|
+ const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
|
|
|
new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }),
|
|
|
])
|
|
|
if (attachment === undefined) throw new Error('draft attachment missing')
|
|
|
- b.shell.addImages([attachment.id])
|
|
|
+ b.shell.addAttachments([attachment.id])
|
|
|
b.shell.submit()
|
|
|
- expect(b.shell.snapshot.imageIds).toEqual([])
|
|
|
+ expect(b.shell.snapshot.attachmentIds).toEqual([])
|
|
|
await b.runtime.sessions.remove('s1')
|
|
|
- expect(b.root.draftImages([attachment.id])).toEqual([])
|
|
|
+ expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
|
|
|
expect(revoked).toHaveBeenCalledWith('blob:detached')
|
|
|
} finally {
|
|
|
created.mockRestore()
|
|
|
@@ -124,18 +127,310 @@ describe('ConversationController', () => {
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
|
|
|
- it('validates every MIME type before allocating previews', async () => {
|
|
|
+ it('classifies image MIME drafts as images and every other file as an uploading file draft', async () => {
|
|
|
const b = await bench()
|
|
|
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
|
|
|
- expect(() => b.root.createDraftImages([
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ const uploadFile = vi.fn((_data: Blob | Uint8Array) => Promise.resolve({
|
|
|
+ ok: true as const,
|
|
|
+ value: {
|
|
|
+ receiptId: 'receipt-1' as never,
|
|
|
+ file: { attachmentId: 'sha256:1' as never, name: 'notes.pdf', bytes: 2 },
|
|
|
+ },
|
|
|
+ }))
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
|
|
|
+ const drafts = b.root.createDrafts(session, [
|
|
|
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
|
|
|
- new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
|
|
|
- ])).toThrow(UnsupportedImageMediaTypeError)
|
|
|
- expect(created).not.toHaveBeenCalled()
|
|
|
+ new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }),
|
|
|
+ ])
|
|
|
+ expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file'])
|
|
|
+ expect(created).toHaveBeenCalledTimes(1)
|
|
|
+ const fileDraft = drafts[1]!
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('uploading')
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('ready')
|
|
|
+ })
|
|
|
+ expect(uploadFile).toHaveBeenCalledOnce()
|
|
|
+ expect(uploadFile.mock.calls[0]?.[0]).toBe((fileDraft as { file: File }).file)
|
|
|
created.mockRestore()
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
|
|
|
+ it('serializes command files as staged receipts without reading their bytes again', async () => {
|
|
|
+ const b = await bench()
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) =>
|
|
|
+ Promise.resolve({
|
|
|
+ ok: true as const,
|
|
|
+ value: {
|
|
|
+ receiptId: `receipt-${name}` as never,
|
|
|
+ file: { attachmentId: `file-${name}` as never, name: name ?? 'file', bytes: 1 },
|
|
|
+ },
|
|
|
+ }))
|
|
|
+ const drafts = b.root.createDrafts(session, [
|
|
|
+ new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }),
|
|
|
+ new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }),
|
|
|
+ ])
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(drafts.every(draft => b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true)
|
|
|
+ })
|
|
|
+ function RejectingFileReader(): never {
|
|
|
+ throw new Error('generic file bytes were reread')
|
|
|
+ }
|
|
|
+ vi.stubGlobal('FileReader', RejectingFileReader)
|
|
|
+ try {
|
|
|
+ await expect(b.root.serializeDraftAttachments(drafts.map(draft => draft.id))).resolves.toEqual({
|
|
|
+ attachments: [
|
|
|
+ { type: 'file', receiptId: 'receipt-one.txt' },
|
|
|
+ { type: 'file', receiptId: 'receipt-two.txt' },
|
|
|
+ ],
|
|
|
+ })
|
|
|
+ } finally {
|
|
|
+ vi.unstubAllGlobals()
|
|
|
+ }
|
|
|
+ await b.runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('bounds upload Workers, advances on settlement, and skips a queued file removed by the user', async () => {
|
|
|
+ const b = await bench(2)
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ type UploadResult = {
|
|
|
+ ok: true
|
|
|
+ value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } }
|
|
|
+ }
|
|
|
+ const gates = new Map<string, { resolve: (value: UploadResult) => void }>()
|
|
|
+ let active = 0
|
|
|
+ let maxActive = 0
|
|
|
+ const uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) => {
|
|
|
+ const fileName = name ?? 'unnamed'
|
|
|
+ const gate = Promise.withResolvers<UploadResult>()
|
|
|
+ gates.set(fileName, gate)
|
|
|
+ active += 1
|
|
|
+ maxActive = Math.max(maxActive, active)
|
|
|
+ return gate.promise.finally(() => { active -= 1 })
|
|
|
+ })
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
|
|
|
+ const drafts = b.root.createDrafts(session, ['one', 'two', 'three', 'four', 'removed'].map(name =>
|
|
|
+ new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' })))
|
|
|
+
|
|
|
+ expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt'])
|
|
|
+ await expect(b.root.serializeDraftAttachments([drafts[2]!.id]))
|
|
|
+ .rejects.toThrow('one or more files have not finished uploading')
|
|
|
+ b.root.releaseDraftAttachment(drafts[4]!.id)
|
|
|
+
|
|
|
+ const complete = (name: string) => gates.get(name)?.resolve({
|
|
|
+ ok: true,
|
|
|
+ value: {
|
|
|
+ receiptId: `receipt-${name}` as never,
|
|
|
+ file: { attachmentId: `file-${name}` as never, name, bytes: 1 },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ complete('one.txt')
|
|
|
+ await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(3) })
|
|
|
+ complete('two.txt')
|
|
|
+ await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(4) })
|
|
|
+ complete('three.txt')
|
|
|
+ complete('four.txt')
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(drafts.slice(0, 4).every(draft =>
|
|
|
+ b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true)
|
|
|
+ })
|
|
|
+ expect(maxActive).toBe(2)
|
|
|
+ expect(uploadFile.mock.calls.map(call => call[1])).not.toContain('removed.txt')
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[drafts[4]!.id]).toBeUndefined()
|
|
|
+ await b.runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps one upload alive and observable while another Session is open', async () => {
|
|
|
+ const b = await bench()
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ const settled = Promise.withResolvers<{
|
|
|
+ ok: true
|
|
|
+ value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } }
|
|
|
+ }>()
|
|
|
+ let reportProgress: ((progress: { loaded: number; total?: number }) => void) | undefined
|
|
|
+ const uploadFile = vi.fn((
|
|
|
+ _file: Blob | Uint8Array,
|
|
|
+ _name?: string,
|
|
|
+ _signal?: AbortSignal,
|
|
|
+ onProgress?: (progress: { loaded: number; total?: number }) => void,
|
|
|
+ ) => {
|
|
|
+ reportProgress = onProgress
|
|
|
+ return settled.promise
|
|
|
+ })
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
|
|
|
+ const [attachment] = b.root.createDrafts(session, [
|
|
|
+ new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }),
|
|
|
+ ])
|
|
|
+ if (attachment === undefined) throw new Error('file draft missing')
|
|
|
+ b.shell.addAttachments([attachment.id])
|
|
|
+ await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() })
|
|
|
+
|
|
|
+ await b.runtime.sessions.add({
|
|
|
+ id: 's2',
|
|
|
+ session: {
|
|
|
+ prompt: b.prompt, updateQueue: b.updateQueue, cancel: b.cancel, loadOlder: b.loadOlder,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ b.runtime.sessions.open('s2' as never)
|
|
|
+ reportProgress?.({ loaded: 3, total: 8 })
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({
|
|
|
+ status: 'uploading', loaded: 3, total: 8,
|
|
|
+ })
|
|
|
+ expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id])
|
|
|
+
|
|
|
+ b.runtime.sessions.open('s1' as never)
|
|
|
+ settled.resolve({
|
|
|
+ ok: true,
|
|
|
+ value: {
|
|
|
+ receiptId: 'background-receipt' as never,
|
|
|
+ file: { attachmentId: 'background-file' as never, name: 'background.bin', bytes: 8 },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready')
|
|
|
+ })
|
|
|
+ expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id])
|
|
|
+ await b.runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('cancels a superseded upload and stages the carried draft on the target Session', async () => {
|
|
|
+ const b = await bench()
|
|
|
+ const source = b.runtime.sessions.binding('s1')!.session
|
|
|
+ let sourceSignal: AbortSignal | undefined
|
|
|
+ const sourceUpload = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => {
|
|
|
+ sourceSignal = signal
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ signal?.addEventListener('abort', () => {
|
|
|
+ resolve({ ok: false, error: { message: 'aborted' } })
|
|
|
+ }, { once: true })
|
|
|
+ })
|
|
|
+ })
|
|
|
+ ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload
|
|
|
+ const [attachment] = b.root.createDrafts(source, [
|
|
|
+ new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }),
|
|
|
+ ])
|
|
|
+ if (attachment === undefined) throw new Error('file draft missing')
|
|
|
+ await vi.waitFor(() => { expect(sourceUpload).toHaveBeenCalledOnce() })
|
|
|
+
|
|
|
+ const target = {
|
|
|
+ uploadFile: vi.fn(() => Promise.resolve({
|
|
|
+ ok: true as const,
|
|
|
+ value: {
|
|
|
+ receiptId: 'target-receipt' as never,
|
|
|
+ file: { attachmentId: 'target-file' as never, name: 'carry.pdf', bytes: 1 },
|
|
|
+ },
|
|
|
+ })),
|
|
|
+ }
|
|
|
+ b.root.rebindDraftFiles(target as never, [attachment.id])
|
|
|
+
|
|
|
+ expect(sourceSignal?.aborted).toBe(true)
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({
|
|
|
+ status: 'ready', receiptId: 'target-receipt',
|
|
|
+ file: { attachmentId: 'target-file', name: 'carry.pdf', bytes: 1 },
|
|
|
+ })
|
|
|
+ })
|
|
|
+ await b.runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('cancels a file upload when its draft is removed', async () => {
|
|
|
+ const b = await bench()
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ let uploadSignal: AbortSignal | undefined
|
|
|
+ const uploadFile = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => {
|
|
|
+ uploadSignal = signal
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ signal?.addEventListener('abort', () => {
|
|
|
+ resolve({ ok: false, error: { message: 'aborted' } })
|
|
|
+ }, { once: true })
|
|
|
+ })
|
|
|
+ })
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
|
|
|
+ const [attachment] = b.root.createDrafts(session, [
|
|
|
+ new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }),
|
|
|
+ ])
|
|
|
+ if (attachment === undefined) throw new Error('file draft missing')
|
|
|
+ await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() })
|
|
|
+
|
|
|
+ b.root.releaseDraftAttachment(attachment.id)
|
|
|
+
|
|
|
+ expect(uploadSignal?.aborted).toBe(true)
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[attachment.id]).toBeUndefined()
|
|
|
+ await b.runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps the accepted file draft until its rpcId appears in the Host queue', async () => {
|
|
|
+ const b = await bench()
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ let retire: ((retirement: unknown) => void) | undefined
|
|
|
+ ;(session as unknown as { beginSubmission: (input: { onRetire?: (retirement: unknown) => void }) => unknown })
|
|
|
+ .beginSubmission = (input) => {
|
|
|
+ retire = input.onRetire
|
|
|
+ return { requestId: 'file-rpc-id', abandon: vi.fn() }
|
|
|
+ }
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({
|
|
|
+ ok: true as const,
|
|
|
+ value: {
|
|
|
+ receiptId: 'send-receipt' as never,
|
|
|
+ file: { attachmentId: 'send-file' as never, name: 'sent.pdf', bytes: 1 },
|
|
|
+ },
|
|
|
+ }))
|
|
|
+ const [attachment] = b.root.createDrafts(session, [
|
|
|
+ new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }),
|
|
|
+ ])
|
|
|
+ if (attachment === undefined) throw new Error('file draft missing')
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready')
|
|
|
+ })
|
|
|
+
|
|
|
+ const sending = b.root.sendSession(session, 'read', [attachment.id], 'queue')
|
|
|
+ await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
|
|
|
+
|
|
|
+ expect(b.prompt).toHaveBeenCalledWith([
|
|
|
+ { type: 'file', receiptId: 'send-receipt' },
|
|
|
+ { type: 'text', text: 'read' },
|
|
|
+ ], 'queue', undefined, expect.any(String))
|
|
|
+ expect(b.root.resolveDraftAttachments([attachment.id])).toHaveLength(1)
|
|
|
+ expect(b.prompt.mock.calls[0]?.[3]).toBe('file-rpc-id')
|
|
|
+ retire?.({
|
|
|
+ reason: 'observed',
|
|
|
+ attachments: [{ attachmentId: 'send-file', name: 'sent.pdf', bytes: 1 }],
|
|
|
+ })
|
|
|
+ await expect(sending).resolves.toEqual({ kind: 'success' })
|
|
|
+ expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
|
|
|
+ await b.runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('awaits an aborted upload before disposing the service', async () => {
|
|
|
+ const b = await bench()
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ let uploadSignal: AbortSignal | undefined
|
|
|
+ let finishUpload: (() => void) | undefined
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(
|
|
|
+ (_data: Uint8Array, _name?: string, signal?: AbortSignal) => {
|
|
|
+ uploadSignal = signal
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ finishUpload = () => { resolve({ ok: false, error: { message: 'aborted' } }) }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ )
|
|
|
+ b.root.createDrafts(session, [
|
|
|
+ new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }),
|
|
|
+ ])
|
|
|
+ await vi.waitFor(() => { expect(uploadSignal).toBeDefined() })
|
|
|
+
|
|
|
+ let disposed = false
|
|
|
+ const disposal = b.fiber.dispose().then(() => { disposed = true })
|
|
|
+ await vi.waitFor(() => { expect(uploadSignal?.aborted).toBe(true) })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(disposed).toBe(false)
|
|
|
+ finishUpload?.()
|
|
|
+ await disposal
|
|
|
+ expect(disposed).toBe(true)
|
|
|
+ await b.runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => {
|
|
|
const b = await bench()
|
|
|
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
|
|
|
@@ -147,6 +442,7 @@ describe('ConversationController', () => {
|
|
|
await bare.plugin(ConversationController, {
|
|
|
input: new InputHub(bare, makeTranslate(zh, {})),
|
|
|
blocks: new ComposerBlockRegistry(),
|
|
|
+ maxConcurrentFileUploads: 2,
|
|
|
}).await()
|
|
|
const orphan = bare.get('conversation') as ConversationController
|
|
|
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
|
|
|
@@ -178,7 +474,7 @@ describe('sendSession submission echo', () => {
|
|
|
it('registers the echo before serialization and prompts with its identity', async () => {
|
|
|
const b = await echoBench()
|
|
|
try {
|
|
|
- const [attachment] = b.root.createDraftImages([
|
|
|
+ const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
|
|
|
new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }),
|
|
|
])
|
|
|
const session = b.runtime.sessions.binding('s1')!.session
|
|
|
@@ -187,7 +483,7 @@ describe('sendSession submission echo', () => {
|
|
|
expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
mode: 'queue',
|
|
|
text: '带图',
|
|
|
- images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })],
|
|
|
+ attachments: [expect.objectContaining({ type: 'image', previewUrl: 'blob:echo-1', name: 'a.png' })],
|
|
|
}))
|
|
|
expect(b.prompt).not.toHaveBeenCalled()
|
|
|
await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
|
|
|
@@ -201,10 +497,10 @@ describe('sendSession submission echo', () => {
|
|
|
'req-echo',
|
|
|
)
|
|
|
// The draft stays registered until the echo's observed retirement.
|
|
|
- expect(b.root.draftImages([attachment!.id])).toHaveLength(1)
|
|
|
+ expect(b.root.resolveDraftAttachments([attachment!.id])).toHaveLength(1)
|
|
|
b.retire.onRetire?.({ reason: 'observed', attachments: [] })
|
|
|
await expect(sending).resolves.toEqual({ kind: 'success' })
|
|
|
- expect(b.root.draftImages([attachment!.id])).toEqual([])
|
|
|
+ expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([])
|
|
|
expect(b.revoked).toHaveBeenCalledWith('blob:echo-1')
|
|
|
} finally {
|
|
|
b.restore()
|
|
|
@@ -212,6 +508,57 @@ describe('sendSession submission echo', () => {
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
|
|
|
+ it('preserves mixed image/file selection order through echo, prompt, and observed retirement', async () => {
|
|
|
+ const b = await echoBench()
|
|
|
+ try {
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({
|
|
|
+ ok: true as const,
|
|
|
+ value: {
|
|
|
+ receiptId: 'mixed-file-receipt' as never,
|
|
|
+ file: { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 },
|
|
|
+ },
|
|
|
+ }))
|
|
|
+ const drafts = b.root.createDrafts(session, [
|
|
|
+ new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
|
|
|
+ new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }),
|
|
|
+ new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }),
|
|
|
+ ])
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready')
|
|
|
+ })
|
|
|
+ const sending = b.root.sendSession(session, 'ordered', drafts.map(draft => draft.id), 'steer')
|
|
|
+ expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
+ mode: 'steer',
|
|
|
+ attachments: [
|
|
|
+ expect.objectContaining({ type: 'image', name: 'first.png' }),
|
|
|
+ { type: 'file', attachment: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } },
|
|
|
+ expect.objectContaining({ type: 'image', name: 'last.png' }),
|
|
|
+ ],
|
|
|
+ }))
|
|
|
+ await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
|
|
|
+ expect(b.prompt.mock.calls[0]?.[0]).toEqual([
|
|
|
+ { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'first.png' },
|
|
|
+ { type: 'file', receiptId: 'mixed-file-receipt' },
|
|
|
+ { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'last.png' },
|
|
|
+ { type: 'text', text: 'ordered' },
|
|
|
+ ])
|
|
|
+ b.retire.onRetire?.({
|
|
|
+ reason: 'observed',
|
|
|
+ attachments: [
|
|
|
+ { attachmentId: 'image-first', mediaType: 'image/png' },
|
|
|
+ { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 },
|
|
|
+ { attachmentId: 'image-last', mediaType: 'image/png' },
|
|
|
+ ],
|
|
|
+ })
|
|
|
+ await expect(sending).resolves.toEqual({ kind: 'success' })
|
|
|
+ expect(b.root.resolveDraftAttachments(drafts.map(draft => draft.id))).toEqual([])
|
|
|
+ } finally {
|
|
|
+ b.restore()
|
|
|
+ }
|
|
|
+ await b.runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
it('passes each delivery mode before image serialization', async () => {
|
|
|
const b = await echoBench()
|
|
|
try {
|
|
|
@@ -241,17 +588,19 @@ describe('sendSession submission echo', () => {
|
|
|
const seedImageUrl = vi.fn(() => true)
|
|
|
b.runtime.ctx.provide('uiConversation')
|
|
|
b.runtime.ctx.set('uiConversation', { seedImageUrl })
|
|
|
- const [attachment] = b.root.createDraftImages([
|
|
|
+ const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
|
|
|
new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }),
|
|
|
])
|
|
|
const session = b.runtime.sessions.binding('s1')!.session
|
|
|
const sending = b.root.sendSession(session, '', [attachment!.id], 'queue')
|
|
|
await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
|
|
|
- const ref = { attachmentId: 'att-1' }
|
|
|
+ const ref = {
|
|
|
+ attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1,
|
|
|
+ }
|
|
|
b.retire.onRetire?.({ reason: 'observed', attachments: [ref] })
|
|
|
await expect(sending).resolves.toEqual({ kind: 'success' })
|
|
|
expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1')
|
|
|
- expect(b.root.draftImages([attachment!.id])).toEqual([])
|
|
|
+ expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([])
|
|
|
expect(b.revoked).not.toHaveBeenCalled()
|
|
|
// Failed retirement keeps nothing to do; a second retire of released ids is a no-op.
|
|
|
b.retire.onRetire?.({ reason: 'observed', attachments: [ref] })
|
|
|
@@ -261,20 +610,31 @@ describe('sendSession submission echo', () => {
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
|
|
|
- it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => {
|
|
|
+ it('keeps mixed image and file drafts when the echo retires as failed', async () => {
|
|
|
const b = await echoBench()
|
|
|
try {
|
|
|
+ const session = b.runtime.sessions.binding('s1')!.session
|
|
|
+ ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({
|
|
|
+ ok: true as const,
|
|
|
+ value: {
|
|
|
+ receiptId: 'kept-file-receipt' as never,
|
|
|
+ file: { attachmentId: 'kept-file' as never, name: 'kept.txt', bytes: 1 },
|
|
|
+ },
|
|
|
+ }))
|
|
|
b.prompt.mockResolvedValueOnce({
|
|
|
ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }),
|
|
|
} as never)
|
|
|
- const [attachment] = b.root.createDraftImages([
|
|
|
+ const attachments = b.root.createDrafts(session, [
|
|
|
new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }),
|
|
|
+ new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }),
|
|
|
])
|
|
|
- const session = b.runtime.sessions.binding('s1')!.session
|
|
|
- await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue'))
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(b.root.fileUploads.getSnapshot()[attachments[1]!.id]?.status).toBe('ready')
|
|
|
+ })
|
|
|
+ await expect(b.root.sendSession(session, '失败', attachments.map(attachment => attachment.id), 'queue'))
|
|
|
.resolves.toEqual({ kind: 'error' })
|
|
|
b.retire.onRetire?.({ reason: 'failed' })
|
|
|
- expect(b.root.draftImages([attachment!.id])).toHaveLength(1)
|
|
|
+ expect(b.root.resolveDraftAttachments(attachments.map(attachment => attachment.id))).toHaveLength(2)
|
|
|
expect(b.revoked).not.toHaveBeenCalled()
|
|
|
} finally {
|
|
|
b.restore()
|
|
|
@@ -294,7 +654,7 @@ describe('sendSession submission echo', () => {
|
|
|
}
|
|
|
vi.stubGlobal('FileReader', FailingReader)
|
|
|
try {
|
|
|
- const [attachment] = b.root.createDraftImages([
|
|
|
+ const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
|
|
|
new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }),
|
|
|
])
|
|
|
const session = b.runtime.sessions.binding('s1')!.session
|
|
|
@@ -374,15 +734,17 @@ describe('draft image dimension probe', () => {
|
|
|
}
|
|
|
vi.stubGlobal('Image', InstantImage)
|
|
|
try {
|
|
|
- const [probed] = b.root.createDraftImages([
|
|
|
+ const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
|
|
|
new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }),
|
|
|
])
|
|
|
expect(probed).toMatchObject({ width: 640, height: 480 })
|
|
|
vi.stubGlobal('Image', undefined)
|
|
|
- const [unprobed] = b.root.createDraftImages([
|
|
|
+ const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
|
|
|
new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }),
|
|
|
])
|
|
|
- expect(unprobed?.width).toBeUndefined()
|
|
|
+ expect(unprobed?.kind).toBe('image')
|
|
|
+ if (unprobed?.kind !== 'image') throw new Error('image draft missing')
|
|
|
+ expect(unprobed.width).toBeUndefined()
|
|
|
} finally {
|
|
|
vi.unstubAllGlobals()
|
|
|
created.mockRestore()
|