fix(agents): show only explicitly delivered chat files
This commit is contained in:
1 parent
922b09bda0
commit
e78459b384
12 files changed
+429
-62
No files matched your search
@@ -67,6 +67,7 @@ const longWord = 'an-unbroken-story-notebook-'.repeat(24);
|
||||
const richReply = [
|
||||
'## 一起把小发现变成故事',
|
||||
'你可以先选一个最喜欢的发现,再让角色去试一试。',
|
||||
'[下载故事笔记](sandbox:/故事笔记.md)',
|
||||
`这个很长的名字也应该完整保留:${longWord}`,
|
||||
`[查看完整故事线索](https://example.invalid/story/${longWord})`,
|
||||
['| 今天的线索 | 可以接着想什么 |', '| --- | --- |',
|
||||
@@ -102,7 +103,7 @@ type FixtureSeed = { threads: CloudThread[]; histories: Record<string, CloudHist
|
||||
type FixtureStream = EventTarget & { readyState: number; url: string };
|
||||
type FixtureState = FixtureSeed & { calls: FixtureCall[]; attachments: CloudAttachment[]; streams: FixtureStream[] };
|
||||
type FixtureWindow = Window & { __cloudChatFixture: FixtureState };
|
||||
type FixtureKind = 'empty' | 'history' | 'running' | 'approval' | 'thinking';
|
||||
type FixtureKind = 'empty' | 'history' | 'running' | 'approval' | 'thinking' | 'delivery';
|
||||
|
||||
async function installFixture(page: Page, kind: FixtureKind) {
|
||||
const isRunning = kind === 'running' || kind === 'thinking';
|
||||
@@ -113,7 +114,14 @@ async function installFixture(page: Page, kind: FixtureKind) {
|
||||
const run = isRunning ? makeRun(first, 'running') : kind === 'approval' ? makeRun(first, 'interrupted', interrupt) : null;
|
||||
const seed: FixtureSeed = kind === 'empty' ? { threads: [], histories: {} } : {
|
||||
threads: [first, second], histories: {
|
||||
[first.thread_id]: kind === 'thinking' ? { ...makeHistory(first, false, run), messages: [] } : makeHistory(first, kind !== 'approval', run),
|
||||
[first.thread_id]: kind === 'thinking' ? { ...makeHistory(first, false, run), messages: [] }
|
||||
: kind === 'delivery' ? { ...makeHistory(first), messages: [
|
||||
{ id: 1, role: 'user', content: '[用户提到的文件](sandbox:/user.txt)', request_id: 'delivery-user', run_id: null, created_at: date },
|
||||
{ id: 2, role: 'assistant', request_id: 'delivery-answer', run_id: null, created_at: date,
|
||||
content: '[下载完成的小程序](sandbox:/deliverable.py)\n\n准备时用到了 /internal.py、/call.txt、/cache.json 和 /plain.txt。\n\n`[行内示例](sandbox:/inline.py)`\n\n```markdown\n[代码示例](sandbox:/example.py)\n```',
|
||||
reasoning_content: '[思考过程](sandbox:/thought.txt)',
|
||||
},
|
||||
] } : makeHistory(first, kind !== 'approval', run),
|
||||
[second.thread_id]: makeHistory(second),
|
||||
},
|
||||
};
|
||||
@@ -166,7 +174,8 @@ async function installFixture(page: Page, kind: FixtureKind) {
|
||||
if (!state.attachments.some(item => item.file_id === attachment.file_id)) state.attachments.push(attachment);
|
||||
return result({ attachments: [attachment] });
|
||||
}
|
||||
case 'files': return result({ files: [{ name: '故事笔记.md', is_dir: false, path: '/故事笔记.md', size: 1024 }] });
|
||||
case 'files': return result({ files: ['故事笔记.md', 'deliverable.py', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']
|
||||
.map(name => ({ name, is_dir: false, path: '/' + name, directory_path: '/', size: 1024 })) });
|
||||
case 'run': return result(Object.values(state.histories).find(item => item.run?.agent_run_id === args?.run_id)?.run);
|
||||
case 'cancelRun': return result({ status: 'cancel_requested' });
|
||||
case 'resume': {
|
||||
@@ -258,6 +267,28 @@ test('long Agent identity and attachment entry fit on a short narrow window', as
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('the file Sheet counts only uploads and files explicitly delivered in the assistant answer', async ({ page }) => {
|
||||
const errors = await openChat(page, 'delivery', 375, 640);
|
||||
const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true });
|
||||
await expect(entry).toBeVisible();
|
||||
await entry.click();
|
||||
const panel = page.getByRole('dialog', { name: '本次对话的附件' });
|
||||
const generated = panel.getByRole('region', { name: '助手生成的' });
|
||||
await expect(generated.getByRole('button', { name: /^保存 / })).toHaveCount(1);
|
||||
await expect(generated.getByRole('button', { name: '保存 deliverable.py', exact: true })).toBeVisible();
|
||||
for (const name of ['故事笔记.md', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']) {
|
||||
await expect(generated.getByText(name, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
await generated.getByRole('button', { name: '保存 deliverable.py', exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
|
||||
.filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/deliverable.py' }]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(entry).toBeFocused();
|
||||
await expectComposerBounds(page);
|
||||
expect((await operations(page)).some(item => ['submit', 'preview', 'resume', 'deleteAttachment'].includes(String(item?.operation)))).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const width of [375, 768, 1024, 1440]) {
|
||||
test(`friendly empty conversation keeps the composer reachable at ${width}px`, async ({ page }) => {
|
||||
const errors = await openChat(page, 'empty', width, width === 375 ? 640 : 760);
|
||||
|
||||
@@ -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()!;
|
||||
|
||||
@@ -589,19 +589,20 @@ it('discovers generated files after the authoritative completed reply without re
|
||||
let completed = false;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', client_thread_id: 'client', mode: 'published' }], next_offset: null };
|
||||
if (operation === 'history') return { ...history, messages: completed ? [{ id: 'answer-1', role: 'assistant', content: '故事文件已做好', run_id: 'run-1' }] : [], run: { ...run, status: completed ? 'completed' : 'running' } };
|
||||
if (operation === 'history') return { ...history, messages: completed ? [{ id: 'answer-1', role: 'assistant', content: '故事文件已做好。\n[下载故事](sandbox:/story.txt)', run_id: 'run-1' }] : [], run: { ...run, status: completed ? 'completed' : 'running' } };
|
||||
if (operation === 'files') return { files: completed ? [{ name: 'story.txt', path: '/story.txt', directory_path: '/', is_dir: false, size: 1024 }] : [] };
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-1'));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'thread-1', path: '/' }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('attachments', { thread_id: 'thread-1' }));
|
||||
expect(api.call.mock.calls.some(([operation]) => operation === 'files')).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
|
||||
completed = true;
|
||||
await act(async () => events.dispatchEvent(new MessageEvent('end', {
|
||||
data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', payload: { chunk: { status: 'completed' } } }),
|
||||
})));
|
||||
expect(await screen.findByText('故事文件已做好')).toBeVisible();
|
||||
expect(await screen.findByText('故事文件已做好。')).toBeVisible();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '附件,共 1 个文件' }));
|
||||
expect(await screen.findByRole('button', { name: '保存 story.txt' })).toBeEnabled();
|
||||
expect(api.call.mock.calls.filter(([operation]) => operation === 'history')).toHaveLength(2);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments';
|
||||
import { CloudPendingContext } from '@/pages/CloudAgents/CloudPending';
|
||||
import type { CloudAttachment, CloudUpload } from '../../shared/cloud-agents';
|
||||
import type { CloudAttachment, CloudHistory, CloudUpload } 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 }));
|
||||
@@ -12,6 +12,11 @@ const photo: CloudAttachment = { file_id: 'photo-id', file_name: '我的画.png'
|
||||
const document: CloudAttachment = { file_id: 'document-id', file_name: '故事.docx', file_size: 1200, path: '/attachments/story', original_path: '/original/story' };
|
||||
const upload: CloudUpload = { object_name: 'temporary/photo-object', file_name: photo.file_name, file_type: 'image/png', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] };
|
||||
|
||||
function delivered(threadId: string, path: string): CloudHistory {
|
||||
return { thread_id: threadId, next_offset: null, run: null, messages: [{ id: 1, role: 'assistant',
|
||||
content: `[下载文件](sandbox:${path})`, request_id: 'delivery-request', run_id: null, created_at: '',
|
||||
}] };
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
@@ -23,8 +28,8 @@ function operationCalls(operation: string) {
|
||||
return api.call.mock.calls.filter(([name]) => name === operation);
|
||||
}
|
||||
|
||||
function open({ initialSelected = [], threadId, disabled = false, ensureThread = vi.fn().mockResolvedValue('thread-created') }: {
|
||||
initialSelected?: string[]; threadId?: string; disabled?: boolean; ensureThread?: () => Promise<string>;
|
||||
function open({ initialSelected = [], threadId, disabled = false, deliveryHistory, ensureThread = vi.fn().mockResolvedValue('thread-created') }: {
|
||||
initialSelected?: string[]; threadId?: string; disabled?: boolean; deliveryHistory?: CloudHistory; ensureThread?: () => Promise<string>;
|
||||
} = {}) {
|
||||
const onBusy = vi.fn();
|
||||
const onSelected = vi.fn();
|
||||
@@ -34,7 +39,7 @@ function open({ initialSelected = [], threadId, disabled = false, ensureThread =
|
||||
const [selected, setSelected] = useState(initialSelected);
|
||||
const [text, setText] = useState('保留这些文字');
|
||||
return <CloudPendingContext.Provider value={pending}><form onSubmit={event => { event.preventDefault(); submit(); }}>
|
||||
<CloudComposerAttachments threadId={threadId} disabled={disabled} ensureThread={ensureThread} selected={selected} onBusy={onBusy}
|
||||
<CloudComposerAttachments threadId={threadId} deliveryHistory={deliveryHistory} disabled={disabled} ensureThread={ensureThread} selected={selected} onBusy={onBusy}
|
||||
onSelected={value => { onSelected(value); setSelected(value); }} footer={<span data-testid="composer-footer">发送区</span>}>
|
||||
<textarea aria-label="消息" value={text} onChange={event => setText(event.target.value)} />
|
||||
</CloudComposerAttachments>
|
||||
@@ -106,7 +111,7 @@ describe('CloudComposerAttachments', () => {
|
||||
expect(operationCalls('confirmAttachment')).toHaveLength(0);
|
||||
expect(view.onSelected).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('消息')).toHaveValue('保留这些文字');
|
||||
expect(view.hasPending()).toBe(false);
|
||||
await waitFor(() => expect(view.hasPending()).toBe(false));
|
||||
});
|
||||
|
||||
it('retains the uploaded object and resolved conversation for an explicit confirmation retry', async () => {
|
||||
@@ -263,7 +268,7 @@ describe('CloudComposerAttachments', () => {
|
||||
if (operation === 'files' && ++filesReads === 1) throw new Error('文件列表首次读取中断');
|
||||
return { files: [] };
|
||||
});
|
||||
const view = open({ threadId: 'thread-existing', initialSelected: [document.file_id] });
|
||||
const view = open({ threadId: 'thread-existing', initialSelected: [document.file_id], deliveryHistory: delivered('thread-existing', '/pending-only.txt') });
|
||||
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
|
||||
// A failed companion list opens the library while the upload list is still loading.
|
||||
fireEvent.click(await screen.findByRole('button', { name: '附件', exact: true }));
|
||||
@@ -332,7 +337,7 @@ describe('CloudComposerAttachments', () => {
|
||||
if (input.path === '/') return { files: [{ name: '作品', path: '/作品', directory_path: '/作品', is_dir: true, size: 0 }] };
|
||||
return { files: [{ name: '故事.txt', path: '/作品/故事.txt', directory_path: '/作品', is_dir: false, size: 500 }] };
|
||||
});
|
||||
const view = open({ threadId: 'thread-existing' });
|
||||
const view = open({ threadId: 'thread-existing', deliveryHistory: delivered('thread-existing', '/作品/故事.txt') });
|
||||
await openLibrary(1);
|
||||
expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'thread-existing', path: '/作品' });
|
||||
fireEvent.click(await screen.findByRole('button', { name: '保存 故事.txt' }));
|
||||
@@ -342,4 +347,34 @@ describe('CloudComposerAttachments', () => {
|
||||
expect(view.ensureThread).not.toHaveBeenCalled();
|
||||
expect(view.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps file reads stable for equivalent delivery history and rechecks on explicit refresh or revision changes', async () => {
|
||||
const history = delivered('stable-thread', '/stories/story.txt');
|
||||
history.messages[0].content += '\n[下载笔记](sandbox:/stories/notes.txt)';
|
||||
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [] } : { files: [
|
||||
{ name: 'story.txt', path: '/stories/story.txt', directory_path: '/stories', is_dir: false, size: 500 },
|
||||
{ name: 'notes.txt', path: '/stories/notes.txt', directory_path: '/stories', is_dir: false, size: 200 },
|
||||
] });
|
||||
const props = { threadId: 'stable-thread', ensureThread: vi.fn(), selected: [], onSelected: vi.fn(), onBusy: vi.fn(), disabled: false };
|
||||
const view = render(<CloudComposerAttachments {...props} deliveryHistory={history} filesRevision="revision-1" />);
|
||||
await screen.findByRole('button', { name: '附件,共 2 个文件' });
|
||||
expect(operationCalls('files')).toHaveLength(1);
|
||||
const equivalent = { ...history, messages: history.messages.map(item => ({ ...item,
|
||||
content: '文件仍然是这两个。\n[笔记](sandbox:/stories/notes.txt)\n[故事](sandbox:/stories/story.txt)\n[再看故事](sandbox:/stories/story.txt)',
|
||||
})) };
|
||||
view.rerender(<CloudComposerAttachments {...props} deliveryHistory={equivalent} filesRevision="revision-1" />);
|
||||
await act(async () => undefined);
|
||||
expect(operationCalls('files')).toHaveLength(1);
|
||||
expect(screen.getByRole('button', { name: '附件,共 2 个文件' })).toBeVisible();
|
||||
const dialog = await openLibrary(2);
|
||||
await waitFor(() => expect(operationCalls('files')).toHaveLength(2));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
|
||||
await waitFor(() => expect(operationCalls('files')).toHaveLength(3));
|
||||
view.rerender(<CloudComposerAttachments {...props} deliveryHistory={equivalent} filesRevision="revision-2" />);
|
||||
await waitFor(() => expect(operationCalls('files')).toHaveLength(4));
|
||||
expect(operationCalls('files')).toEqual(Array.from({ length: 4 }, () => ['files', { thread_id: 'stable-thread', path: '/stories' }]));
|
||||
expect(within(dialog).getByRole('button', { name: '保存 story.txt' })).toBeVisible();
|
||||
expect(within(dialog).getByRole('button', { name: '保存 notes.txt' })).toBeVisible();
|
||||
expect(props.ensureThread).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { deliveredFilePaths } from '@/pages/CloudAgents/cloud-delivered-files';
|
||||
import type { CloudHistory, CloudMessage } from '../../shared/cloud-agents';
|
||||
|
||||
const threadId = 'thread-1';
|
||||
function message(content: string, role: CloudMessage['role'] = 'assistant', extra: Partial<CloudMessage> = {}): CloudMessage {
|
||||
return { id: 1, role, content, request_id: 'request-1', run_id: 'run-1', created_at: '2026-10-12T00:00:00Z', ...extra };
|
||||
}
|
||||
function history(...messages: CloudMessage[]): CloudHistory {
|
||||
return { thread_id: threadId, messages, run: null, next_offset: null };
|
||||
}
|
||||
function paths(content: string): string[] {
|
||||
return [...deliveredFilePaths(history(message(content)), threadId)];
|
||||
}
|
||||
|
||||
describe('saved assistant file delivery evidence', () => {
|
||||
it.each([
|
||||
['sandbox:/mnt/data/report.xlsx', '/mnt/data/report.xlsx'],
|
||||
['outputs/story.txt', '/outputs/story.txt'],
|
||||
['/report.pdf', '/report.pdf'],
|
||||
['/api/chat/thread/thread-1/artifacts/outputs/report.pdf', '/outputs/report.pdf'],
|
||||
['/api/makelore/threads/thread-1/artifacts/outputs/report.pdf', '/outputs/report.pdf'],
|
||||
['sandbox:/outputs/%E6%95%85%E4%BA%8B%20%E7%BB%88%E7%A8%BF.txt', '/outputs/故事 终稿.txt'],
|
||||
['/api/makelore/threads/thread-1/artifacts/%E8%A1%A8%E6%A0%BC%20%E7%BB%88%E7%A8%BF.xlsx', '/表格 终稿.xlsx'],
|
||||
])('normalizes an explicit local deliverable %s', (target, expected) => {
|
||||
expect(paths(`[保存成果](${target})`)).toEqual([expected]);
|
||||
});
|
||||
|
||||
it('accepts actual link and image references, resolves case and whitespace, and ignores unused definitions', () => {
|
||||
expect(paths([
|
||||
'[故事][My File] 和 ![封面][cover],还有 [快捷成果][]。',
|
||||
'',
|
||||
'[my file]: sandbox:/outputs/story.txt',
|
||||
'[COVER]: /outputs/cover.png',
|
||||
'[快捷成果]: /outputs/quick.pdf',
|
||||
'[unused]: /internal/unmentioned.json',
|
||||
].join('\n'))).toEqual(['/outputs/story.txt', '/outputs/cover.png', '/outputs/quick.pdf']);
|
||||
});
|
||||
|
||||
it('accepts inline images and follows the first reference definition', () => {
|
||||
expect(paths([
|
||||
' [文件][file]',
|
||||
'',
|
||||
'[file]: /outputs/final.txt',
|
||||
'[file]: /internal/duplicate.txt',
|
||||
].join('\n'))).toEqual(['/outputs/drawing.png', '/outputs/final.txt']);
|
||||
});
|
||||
|
||||
it('keeps explicitly delivered code and data files regardless of their name or extension and deduplicates paths', () => {
|
||||
expect(paths([
|
||||
'[脚本](/batch.py) [数据](/result.json) [运行文件](/output.pyc)',
|
||||
'[再次保存](sandbox:/batch.py) [重复](batch.py)',
|
||||
].join('\n'))).toEqual(['/batch.py', '/result.json', '/output.pyc']);
|
||||
});
|
||||
|
||||
it('uses only assistant replies and never user text, system text, or reasoning', () => {
|
||||
const saved = history(
|
||||
message('[用户资料](/user-link.pdf)', 'user'),
|
||||
message('[系统文件](/system.json)', 'system'),
|
||||
message('[已交付](/final.pdf)', 'assistant', { reasoning_content: '[工作记录](/thinking.json)' }),
|
||||
);
|
||||
expect([...deliveredFilePaths(saved, threadId)]).toEqual(['/final.pdf']);
|
||||
});
|
||||
|
||||
it('does not resolve definitions from a different message', () => {
|
||||
expect([...deliveredFilePaths(history(
|
||||
message('[file]: /internal/only-defined.txt'),
|
||||
message('[没有本条定义的文件][file]', 'assistant', { id: 2 }),
|
||||
), threadId)]).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores raw paths, fenced and indented code, inline code, raw HTML and code examples', () => {
|
||||
expect(paths([
|
||||
'过程位置 /internal/log.json,outputs/script.py,sandbox:/outputs/raw.pdf。',
|
||||
'',
|
||||
'```markdown',
|
||||
'[示例](/internal/fenced.txt)',
|
||||
'[hidden]: /internal/fenced-reference.txt',
|
||||
'```',
|
||||
'',
|
||||
' [示例](/internal/indented.txt)',
|
||||
'',
|
||||
'`[示例](/internal/inline.txt)`',
|
||||
'',
|
||||
'<a href="/internal/html.txt">HTML 示例</a>',
|
||||
'<img src="/internal/html.png" />',
|
||||
'',
|
||||
'[不存在的引用][hidden]',
|
||||
'',
|
||||
'[真正的成果](/outputs/final.docx)',
|
||||
].join('\n'))).toEqual(['/outputs/final.docx']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://example.com/report.pdf',
|
||||
'//example.com/report.pdf',
|
||||
'mailto:person@example.com',
|
||||
'javascript:alert(1)',
|
||||
'data:text/plain,hello',
|
||||
'file:///private/file.txt',
|
||||
'C:/private/file.txt',
|
||||
'#section',
|
||||
'/report.pdf?download=true',
|
||||
'/report.pdf#section',
|
||||
'/report.pdf%3Fdownload=true',
|
||||
'/report.pdf%23section',
|
||||
'/../private.txt',
|
||||
'outputs/../private.txt',
|
||||
'/outputs/%2e%2e/private.txt',
|
||||
'/outputs/%252e%252e/private.txt',
|
||||
'/outputs%2f..%2fprivate.txt',
|
||||
'/outputs/./report.txt',
|
||||
'/outputs\\private.txt',
|
||||
'/outputs/%5Cprivate.txt',
|
||||
'/outputs/%255Cprivate.txt',
|
||||
'/outputs/%00private.txt',
|
||||
'/outputs/\u0000private.txt',
|
||||
'/outputs/%0Aprivate.txt',
|
||||
'/outputs/%E0%A4%A',
|
||||
'sandbox://example.com/report.txt',
|
||||
'/outputs/',
|
||||
'/api/chat/thread/other-thread/artifacts/final.pdf',
|
||||
'/api/makelore/threads/other-thread/artifacts/final.pdf',
|
||||
'/api/chat/thread/thread-1/artifacts/',
|
||||
'/api/makelore/threads/thread-1/attachments/final.pdf',
|
||||
])('rejects an unsafe, ambiguous, external, or cross-thread target %s', target => {
|
||||
expect(paths(`[文件](<${target}>)`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no evidence until matching conversation history exists', () => {
|
||||
expect(deliveredFilePaths(null, threadId).size).toBe(0);
|
||||
expect(deliveredFilePaths(undefined, threadId).size).toBe(0);
|
||||
expect(deliveredFilePaths(history(message('[文件](/final.pdf)')), 'other-thread').size).toBe(0);
|
||||
expect(deliveredFilePaths(history(message('[文件](/final.pdf)')), '').size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user