fix: await concurrent attachment uploads

This commit is contained in:
brother7 committed 2026-08-24 01:54:25 +08:00
1 parent 1ca0249e69
commit 1fe8863e69
3 files changed
+20 -9

No files matched your search

@@ -156,7 +156,8 @@
acceptance settle, so removal or a next-message paste cannot mutate the acceptance settle, so removal or a next-message paste cannot mutate the
captured image set. Attachment preparation failures now remain local to the captured image set. Attachment preparation failures now remain local to the
Composer, restore the draft, permit direct retry without runtime recovery, Composer, restore the draft, permit direct retry without runtime recovery,
and retain already uploaded variants for reuse. Attachment route failures use retain already uploaded variants for reuse, and wait for every concurrent
upload flight to settle before releasing the edit lock. Attachment route failures use
only registered `CODING_ATTACHMENT_INVALID`, `CODING_ATTACHMENT_NOT_FOUND`, only registered `CODING_ATTACHMENT_INVALID`, `CODING_ATTACHMENT_NOT_FOUND`,
and `CODING_STORAGE_WRITE_FAILED` codes while preserving 413/404 status. and `CODING_STORAGE_WRITE_FAILED` codes while preserving 413/404 status.
- Synchronized `README.md` with the current Pi core Chat, first-Conversation, - Synchronized `README.md` with the current Pi core Chat, first-Conversation,
+4 -1
View File
@@ -392,7 +392,10 @@ export function CodingChatPanel({
}; };
} }
}); });
await Promise.all(workers); const results = await Promise.allSettled(workers);
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
return prepared; return prepared;
}, []); }, []);
+14 -7
View File
@@ -365,7 +365,7 @@ describe('CodingChatPanel first Conversation', () => {
expect(maxActiveUploads).toBeLessThanOrEqual(4); expect(maxActiveUploads).toBeLessThanOrEqual(4);
}); });
it('locks attachment removal while an upload is still pending', async () => { it('keeps attachment removal locked until every upload settles after one fails', async () => {
const upload = deferred<{ attachmentId: string; mime: string; byteLength: number }>(); const upload = deferred<{ attachmentId: string; mime: string; byteLength: number }>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config }); projectApi.config.mockResolvedValue({ project, config });
@@ -383,7 +383,11 @@ describe('CodingChatPanel first Conversation', () => {
runId: 'run-1', runId: 'run-1',
mode: input.mode, mode: input.mode,
})); }));
attachmentApi.upload.mockReturnValue(upload.promise); attachmentApi.upload.mockImplementation((file: File) => (
file.name === 'failed.png'
? Promise.reject(new Error('图片附件无效。'))
: upload.promise
));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations'); const { codingConversationStore } = await import('@/stores/coding-conversations');
@@ -393,21 +397,24 @@ describe('CodingChatPanel first Conversation', () => {
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId) await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id)); .toBe(conversation.id));
const file = new File(['image'], 'held.png', { type: 'image/png' }); const failed = new File(['failed'], 'failed.png', { type: 'image/png' });
const held = new File(['held'], 'held.png', { type: 'image/png' });
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), { fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
target: { files: [file] }, target: { files: [failed, held] },
}); });
fireEvent.click(await screen.findByRole('button', { name: '发送' })); fireEvent.click(await screen.findByRole('button', { name: '发送' }));
await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledOnce()); await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledTimes(2));
const remove = screen.getByRole('button', { name: '移除图片 held.png' }); const remove = screen.getByRole('button', { name: '移除图片 held.png' });
expect(remove).toBeDisabled(); expect(remove).toBeDisabled();
fireEvent.click(remove); fireEvent.click(remove);
expect(screen.getByAltText('held.png')).toBeInTheDocument(); expect(screen.getByAltText('held.png')).toBeInTheDocument();
expect(screen.queryByText('图片附件无效。')).not.toBeInTheDocument();
upload.resolve({ attachmentId: 'attachment-held', mime: 'image/png', byteLength: 5 }); upload.resolve({ attachmentId: 'attachment-held', mime: 'image/png', byteLength: 5 });
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce()); expect(await screen.findByText('图片附件无效。')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByAltText('held.png')).not.toBeInTheDocument()); expect(remove).toBeEnabled();
expect(conversationApi.submit).not.toHaveBeenCalled();
}); });
it('keeps next-message images out of a submission while its 202 response is pending', async () => { it('keeps next-message images out of a submission while its 202 response is pending', async () => {