fix(agents): show only explicitly delivered chat files

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 23:43:33 +08:00
1 parent 922b09bda0
commit e78459b384
12 files changed
+429 -62

No files matched your search

+81 -18
View File
@@ -2,7 +2,7 @@ import { useState, type ComponentProps } from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments';
import type { CloudAttachment, CloudFile } from '../../shared/cloud-agents';
import type { CloudAttachment, CloudFile, CloudHistory } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ call: vi.fn(), upload: vi.fn(), download: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
@@ -19,13 +19,19 @@ function file(path: string): CloudFile {
function folder(path: string): CloudFile {
return { name: path.split('/').at(-1)!, path, directory_path: path, is_dir: true, size: 0 };
}
function delivered(threadId: string, ...paths: string[]): CloudHistory {
return { thread_id: threadId, next_offset: null, run: null, messages: [{
id: 1, role: 'assistant', request_id: 'delivery-request', run_id: null, created_at: '',
content: paths.map(path => `[${path.split('/').at(-1)}](sandbox:${path})`).join('\n'),
}] };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
}
type HarnessProps = Pick<ComponentProps<typeof CloudComposerAttachments>, 'threadId' | 'headerTarget' | 'filesRevision' | 'activeView'> & { initialSelected?: string[] };
type HarnessProps = Pick<ComponentProps<typeof CloudComposerAttachments>, 'threadId' | 'headerTarget' | 'filesRevision' | 'activeView' | 'deliveryHistory'> & { initialSelected?: string[] };
function Harness({ initialSelected = [], ...props }: HarnessProps) {
const [selected, setSelected] = useState(initialSelected);
return <CloudComposerAttachments {...props} ensureThread={ensureThread} selected={selected} onSelected={setSelected} onBusy={onBusy} disabled={false}>
@@ -54,7 +60,7 @@ afterEach(cleanup);
it.each([undefined, 'empty-thread'])('hides the library for an empty conversation (%s) and keeps adding available', async threadId => {
render(<Harness threadId={threadId} />);
if (threadId) {
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: threadId, path: '/' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('attachments', { thread_id: threadId }));
await act(async () => undefined);
}
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
@@ -62,10 +68,44 @@ it.each([undefined, 'empty-thread'])('hides the library for an empty conversatio
expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled();
expect(api.upload).not.toHaveBeenCalled();
expect(ensureThread).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(([operation]) => operation === 'files')).toBe(false);
if (!threadId) expect(api.call).not.toHaveBeenCalled();
});
it('counts generated files in nested folders once and offers the library without uploaded files', async () => {
it.each([undefined, null, delivered('other-thread', '/answer.txt')])('does not expose generated files without authoritative history for this thread (%s)', async deliveryHistory => {
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [] } : { files: [file('/answer.txt')] });
render(<Harness threadId="current-thread" deliveryHistory={deliveryHistory} />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('attachments', { thread_id: 'current-thread' }));
await act(async () => undefined);
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '保存 answer.txt' })).not.toBeInTheDocument();
expect(api.call.mock.calls.some(([operation]) => operation === 'files')).toBe(false);
});
it('shows an explicitly delivered Python file while hiding process files, code examples and links from users or reasoning', async () => {
const history = delivered('delivery-thread', '/deliverable.py');
history.messages[0].content += '\n过程里可能使用 /internal.py、/call.txt、/cache.json 和 /plain.txt。\n`[行内示例](sandbox:/inline.py)`\n\n```markdown\n[代码示例](sandbox:/example.py)\n```';
history.messages[0].reasoning_content = '[思考过程](sandbox:/thought.txt) [准备脚本](sandbox:/internal.py)';
history.messages.push({ ...history.messages[0], id: 2, role: 'user', content: '[用户提到的文件](sandbox:/user.txt)' },
{ ...history.messages[0], id: 3, role: 'system', content: '[内部信息](sandbox:/system.txt)' });
const hidden = ['internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt', 'system.txt'];
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [] } : {
files: [file('/deliverable.py'), ...hidden.map(name => file('/' + name)), folder('/scripts'), folder('/cache')],
});
render(<Harness threadId="delivery-thread" deliveryHistory={history} />);
const dialog = await openLibrary(1);
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
expect(within(generated).getAllByRole('button', { name: /^保存 / })).toHaveLength(1);
expect(within(generated).getByRole('button', { name: '保存 deliverable.py' })).toBeEnabled();
for (const name of hidden) expect(within(generated).queryByText(name)).not.toBeInTheDocument();
expect(api.call.mock.calls.filter(([operation]) => operation === 'files').every(([, input]) => input.path === '/')).toBe(true);
fireEvent.click(within(generated).getByRole('button', { name: '保存 deliverable.py' }));
await waitFor(() => expect(api.download).toHaveBeenCalledWith('delivery-thread', '/deliverable.py'));
fireEvent.click(within(dialog).getByRole('button', { name: '返回聊天' }));
expect(await screen.findByRole('button', { name: '附件,共 1 个文件' })).toBeVisible();
});
it('counts explicitly delivered nested files once and reads only their parent directories', async () => {
api.call.mockImplementation(async (operation, input) => {
if (operation === 'attachments') return { attachments: [] };
if (operation === 'files') return { files: input.path === '/'
@@ -75,7 +115,7 @@ it('counts generated files in nested folders once and offers the library without
: [file('/outputs/audio/story.mp3')] };
throw new Error(operation);
});
render(<Harness threadId="generated-thread" />);
render(<Harness threadId="generated-thread" deliveryHistory={delivered("generated-thread", "/poster.png", "/outputs/story.txt", "/outputs/audio/story.mp3")} />);
const dialog = await openLibrary(3);
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
expect(within(generated).getAllByRole('button', { name: /^保存 / })).toHaveLength(3);
@@ -83,6 +123,7 @@ it('counts generated files in nested folders once and offers the library without
expect(within(dialog).getByText('还没有上传文件。')).toBeVisible();
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'generated-thread', path: '/outputs/audio' });
expect(new Set(api.call.mock.calls.filter(([operation]) => operation === 'files').map(([, input]) => input.path))).toEqual(new Set(['/', '/outputs', '/outputs/audio']));
expect(ensureThread).not.toHaveBeenCalled();
});
@@ -94,7 +135,7 @@ it('excludes uploaded and parsed copies from generated files and saves each orig
: [file('/outputs/story.txt')] };
throw new Error(operation);
});
render(<Harness threadId="download-thread" />);
render(<Harness threadId="download-thread" deliveryHistory={delivered("download-thread", "/notes.txt", "/notes.md", "/outputs/story.txt")} />);
const dialog = await openLibrary(2);
const uploaded = within(dialog).getByRole('region', { name: '你上传的' });
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
@@ -142,7 +183,7 @@ it('keeps known uploads, generated files and message selections when refresh fai
if (fail) throw new Error(operation === 'attachments' ? '上传列表暂不可用' : '生成列表暂不可用');
return operation === 'attachments' ? { attachments: [upload] } : { files: [file('/poster.png')] };
});
render(<Harness threadId="refresh-thread" initialSelected={[upload.file_id]} />);
render(<Harness threadId="refresh-thread" initialSelected={[upload.file_id]} deliveryHistory={delivered("refresh-thread", "/poster.png")} />);
const dialog = await openLibrary(2);
await screen.findByRole('checkbox', { name: '选择附件 notes.txt' });
fail = true;
@@ -166,7 +207,7 @@ it.each(['attachments', 'files'] as const)('does not claim a complete count or u
if (fail && operation === failedOperation) throw new Error(error);
return operation === 'attachments' ? { attachments: [upload] } : { files: [file('/notes.txt'), file('/notes.md'), file('/poster.png')] };
});
render(<Harness threadId="first-read-thread" />);
render(<Harness threadId="first-read-thread" deliveryHistory={delivered("first-read-thread", "/poster.png")} />);
const trigger = await screen.findByRole('button', { name: '附件', exact: true });
await act(async () => undefined);
expect(trigger).toHaveTextContent(/^附件$/);
@@ -198,26 +239,27 @@ it.each(['attachments', 'files'] as const)('does not claim a complete count or u
expect(await screen.findByRole('button', { name: '附件,共 2 个文件' })).toHaveTextContent('2');
});
it('discards late files and uploads after switching conversations and stops reading old subfolders', async () => {
it('discards late files and uploads after switching conversations and stops further old directory reads', async () => {
const oldUploads = deferred<{ attachments: CloudAttachment[] }>();
const oldFiles = deferred<{ files: CloudFile[] }>();
api.call.mockImplementation(async (operation, input) => {
if (input.thread_id === 'old-thread') return operation === 'attachments' ? oldUploads.promise : oldFiles.promise;
return operation === 'attachments' ? { attachments: [{ ...upload, file_id: 'new-upload', file_name: 'new-notes.txt' }] } : { files: [file('/new-poster.png')] };
});
const view = render(<Harness threadId="old-thread" />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/' }));
view.rerender(<Harness threadId="new-thread" />);
const view = render(<Harness threadId="old-thread" deliveryHistory={delivered("old-thread", "/old-output/late.txt", "/unrelated/never.txt")} />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/old-output' }));
view.rerender(<Harness threadId="new-thread" deliveryHistory={delivered("new-thread", "/new-poster.png")} />);
const dialog = await openLibrary(2);
expect(within(dialog).getByText('new-notes.txt')).toBeVisible();
await act(async () => {
oldUploads.resolve({ attachments: [upload] });
oldFiles.resolve({ files: [file('/old-poster.png'), folder('/old-output')] });
oldFiles.resolve({ files: [file('/old-output/late.txt'), folder('/old-secret')] });
});
expect(within(dialog).queryByText('notes.txt')).not.toBeInTheDocument();
expect(within(dialog).queryByText('old-poster.png')).not.toBeInTheDocument();
expect(within(dialog).queryByText('late.txt')).not.toBeInTheDocument();
expect(within(dialog).getByText('new-poster.png')).toBeVisible();
expect(api.call).not.toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/old-output' });
expect(api.call).not.toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/unrelated' });
expect(api.call).not.toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/old-secret' });
fireEvent.click(within(dialog).getByRole('button', { name: '保存 new-poster.png' }));
await waitFor(() => expect(api.download).toHaveBeenCalledWith('new-thread', '/new-poster.png'));
});
@@ -225,18 +267,39 @@ it('discards late files and uploads after switching conversations and stops read
it('refreshes generated files when authoritative history revision changes while retaining the current conversation', async () => {
let generated = false;
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [] } : { files: generated ? [file('/answer.txt')] : [] });
const view = render(<Harness threadId="revision-thread" filesRevision="message-1:run-1:running" />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'revision-thread', path: '/' }));
const view = render(<Harness threadId="revision-thread" filesRevision="message-1:run-1:running" deliveryHistory={delivered("revision-thread")} />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('attachments', { thread_id: 'revision-thread' }));
await act(async () => undefined);
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
generated = true;
view.rerender(<Harness threadId="revision-thread" filesRevision="message-2:run-1:completed" />);
view.rerender(<Harness threadId="revision-thread" filesRevision="message-2:run-1:completed" deliveryHistory={delivered("revision-thread", "/answer.txt")} />);
const dialog = await openLibrary(1);
expect(within(dialog).getByText('answer.txt')).toBeVisible();
expect(api.call.mock.calls.filter(([operation, input]) => operation === 'files' && input.thread_id === 'revision-thread').length).toBeGreaterThanOrEqual(2);
expect(ensureThread).not.toHaveBeenCalled();
});
it('updates delivered visibility from changed history and waits for matching history after a conversation switch', async () => {
api.call.mockImplementation(async (operation, input) => operation === 'attachments' ? { attachments: [] } : {
files: input.thread_id === 'history-thread' ? [file('/first.txt'), file('/second.txt'), file('/internal.py')] : [file('/new-thread.txt')],
});
const view = render(<Harness threadId="history-thread" deliveryHistory={delivered('history-thread', '/first.txt')} />);
const dialog = await openLibrary(1);
expect(within(dialog).getByRole('button', { name: '保存 first.txt' })).toBeVisible();
view.rerender(<Harness threadId="history-thread" deliveryHistory={delivered('history-thread', '/second.txt')} />);
expect(await within(dialog).findByRole('button', { name: '保存 second.txt' })).toBeVisible();
expect(within(dialog).queryByRole('button', { name: '保存 first.txt' })).not.toBeInTheDocument();
expect(within(dialog).queryByText('internal.py')).not.toBeInTheDocument();
view.rerender(<Harness threadId="next-thread" deliveryHistory={delivered('history-thread', '/second.txt')} />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('attachments', { thread_id: 'next-thread' }));
expect(within(dialog).queryByRole('button', { name: /^保存 / })).not.toBeInTheDocument();
expect(api.call.mock.calls.some(([operation, input]) => operation === 'files' && input.thread_id === 'next-thread')).toBe(false);
view.rerender(<Harness threadId="next-thread" deliveryHistory={delivered('next-thread', '/new-thread.txt')} />);
expect(await within(dialog).findByRole('button', { name: '保存 new-thread.txt' })).toBeVisible();
expect(within(dialog).queryByText('second.txt')).not.toBeInTheDocument();
expect(dialog).toHaveAccessibleDescription('1 个文件,随时找回来。');
});
it('offers reading choices from the first upload and confirms the chosen method on the same created thread', async () => {
api.upload.mockResolvedValue({ object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] });
const base = api.call.getMockImplementation()!;