fix: isolate delivered agent chats by project
This commit is contained in:
1 parent
a21a1f077c
commit
4b41c23a3a
15 files changed
+518
-165
No files matched your search
@@ -7,6 +7,7 @@ const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), conversation:
|
||||
list: vi.fn(), read: vi.fn(), legacyHistory: vi.fn(), legacyRead: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), updateDiscussion: vi.fn(), cancel: vi.fn(), seenConversation: vi.fn() }));
|
||||
const identity = vi.hoisted(() => ({ id: 0 }));
|
||||
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base,
|
||||
teacherConversationsPath: (project: string) => '/api/coding/projects/' + project + '/agent-conversations',
|
||||
teacherTopicsPath: (project: string) => '/legacy/' + project }));
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid-' + identity.id } }) }));
|
||||
const definition: TeacherDefinition = { schema_version: 1, config_id: 'agent-a', teacher_id: 'teacher',
|
||||
@@ -29,11 +30,11 @@ beforeEach(() => {
|
||||
vi.resetAllMocks(); identity.id++; localStorage.clear(); stream = new EventTarget();
|
||||
api.config.mockResolvedValue({ enabled: true, definition });
|
||||
api.catalog.mockResolvedValue({ items: [agent] });
|
||||
api.conversation.mockResolvedValue({ topic: topic(), before: null });
|
||||
api.conversation.mockImplementation(async projectId => ({ topic: projectId === 'project-a' ? topic() : null, before: null }));
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
api.events.mockResolvedValue(Object.assign(stream, { close: vi.fn() }));
|
||||
api.seenConversation.mockImplementation(async (_agent, requestId) => ({ ...topic(), conversation: { ...topic().conversation!, seenThrough: requestId } }));
|
||||
api.sendConversation.mockImplementation(async (_agent, input) => ({ ...topic(), revision: 2,
|
||||
api.seenConversation.mockImplementation(async (projectId, _agent, requestId) => ({ ...topic(), projectId, conversation: { ...topic().conversation!, seenThrough: requestId } }));
|
||||
api.sendConversation.mockImplementation(async (projectId, _agent, input) => ({ ...topic(), projectId, revision: 2,
|
||||
requests: [...topic().requests, { ...request(input.requestId, input.text), projectId: input.projectId, sourceConversationId: input.sourceConversationId }] }));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
@@ -41,23 +42,53 @@ const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source
|
||||
async function mount() { let view!: ReturnType<typeof render>; await act(async () => { view = render(<TeacherChatPanel {...props} />); }); return view; }
|
||||
|
||||
describe('continuous agent panel', () => {
|
||||
it('opens one chat without topic controls or creating a topic, and preserves it across project switches', async () => {
|
||||
it('ignores a previous project fetch and external snapshot after a project switch', async () => {
|
||||
let resolve!: (value: { topic: TeacherTopic; before: null }) => void;
|
||||
const delayed = new Promise<{ topic: TeacherTopic; before: null }>(done => { resolve = done; });
|
||||
api.conversation.mockImplementation(project => project === 'project-a' ? delayed : Promise.resolve({ topic: null, before: null }));
|
||||
const view = await mount();
|
||||
await act(async () => view.rerender(<TeacherChatPanel {...props} projectId="project-b" externalTopic={topic()} />));
|
||||
await act(async () => resolve({ topic: topic(), before: null }));
|
||||
expect(screen.queryByText('旧的问题')).toBeNull();
|
||||
expect(api.seenConversation).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
it('adopts an old global draft only in its recorded project and preserves the original', async () => {
|
||||
const key = `makelore-consultation-draft:kid-${identity.id}:agent:agent-a`;
|
||||
const original = JSON.stringify({ text: '旧全局草稿', projectId: 'project-a', sourceId: 'source-a' });
|
||||
localStorage.setItem(key, original);
|
||||
const olderProjectKey = `makelore-consultation-draft:kid-${identity.id}:project-a:agent:agent-a`;
|
||||
localStorage.setItem(olderProjectKey, JSON.stringify({ text: '升级前更旧的草稿' }));
|
||||
const view = await mount();
|
||||
expect(screen.getByRole('textbox')).toHaveValue('旧全局草稿');
|
||||
await act(async () => view.rerender(<TeacherChatPanel {...props} projectId="project-b" />));
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
expect(localStorage.getItem(key)).toBe(original);
|
||||
expect(localStorage.getItem(olderProjectKey)).toContain('升级前更旧的草稿');
|
||||
await act(async () => view.rerender(<TeacherChatPanel {...props} />));
|
||||
expect(screen.getByRole('textbox')).toHaveValue('旧全局草稿');
|
||||
});
|
||||
it('keeps one chat per project and restores its own history and draft when switching back', async () => {
|
||||
const view = await mount();
|
||||
expect(screen.queryByRole('button', { name: '智能体新话题' })).toBeNull();
|
||||
expect(screen.queryByRole('combobox')).toBeNull();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('旧的问题')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '跨项目草稿' } });
|
||||
view.rerender(<TeacherChatPanel {...props} projectId="project-b" projectName="游戏" sourceId="source-b" />);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('跨项目草稿');
|
||||
expect(screen.getByText('旧的问题')).toBeInTheDocument();
|
||||
expect(screen.getByText('本轮项目:游戏 · 当前操作对话')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '天气项目草稿' } });
|
||||
await act(async () => view.rerender(<TeacherChatPanel {...props} projectId="project-b" projectName="游戏" sourceId="source-b" />));
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
expect(screen.queryByText('旧的问题')).toBeNull();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '游戏问题' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问' }));
|
||||
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledWith('agent-a', expect.objectContaining({
|
||||
projectId: 'project-b', sourceConversationId: 'source-b', text: '跨项目草稿' })));
|
||||
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledWith('project-b', 'agent-a', expect.objectContaining({
|
||||
projectId: 'project-b', sourceConversationId: 'source-b', text: '游戏问题' })));
|
||||
await act(async () => view.rerender(<TeacherChatPanel {...props} />));
|
||||
expect(screen.getByRole('textbox')).toHaveValue('天气项目草稿');
|
||||
expect(screen.getByText('旧的问题')).toBeInTheDocument();
|
||||
expect(screen.queryByText('游戏问题')).toBeNull();
|
||||
});
|
||||
it('merges older pages and streamed current turns without replacing earlier messages', async () => {
|
||||
api.conversation.mockImplementation(async (_id, before) => ({ topic: before ? topic([request('0', '更早的问题')]) : topic(), before: before ? null : '1' }));
|
||||
api.conversation.mockImplementation(async (_project, _id, before) => ({ topic: before ? topic([request('0', '更早的问题')]) : topic(), before: before ? null : '1' }));
|
||||
await mount();
|
||||
fireEvent.click(screen.getByText('查看更早消息'));
|
||||
await screen.findByText('更早的问题');
|
||||
@@ -68,12 +99,12 @@ describe('continuous agent panel', () => {
|
||||
expect(screen.getByText('旧的问题')).toBeInTheDocument();
|
||||
expect(screen.getByText('新的问题')).toBeInTheDocument();
|
||||
});
|
||||
it('rejects stale references and prevents carrying an old project reply into the new Pi draft', async () => {
|
||||
it('rejects stale references and prevents carrying an old Pi reply into another Pi draft', async () => {
|
||||
const bring = vi.fn();
|
||||
let view!: ReturnType<typeof render>;
|
||||
await act(async () => { view = render(<TeacherChatPanel {...props} quote={{ kind: 'code', text: '旧项目代码' }} onBringBack={bring} />); });
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '看看' } });
|
||||
view.rerender(<TeacherChatPanel {...props} projectId="project-b" sourceId="source-b" onBringBack={bring} />);
|
||||
view.rerender(<TeacherChatPanel {...props} sourceId="source-b" onBringBack={bring} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问' }));
|
||||
expect(api.sendConversation).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('引用来自另一个项目');
|
||||
@@ -91,7 +122,7 @@ describe('continuous agent panel', () => {
|
||||
it('merges a delayed older page without overwriting a newer streamed answer', async () => {
|
||||
let resolve!: (value: {topic: TeacherTopic; before: null}) => void;
|
||||
const page = new Promise<{topic: TeacherTopic; before: null}>(done => { resolve = done; });
|
||||
api.conversation.mockImplementation((_id, before) => before ? page : Promise.resolve({ topic: topic(), before: '1' }));
|
||||
api.conversation.mockImplementation((_project, _id, before) => before ? page : Promise.resolve({ topic: topic(), before: '1' }));
|
||||
await mount();
|
||||
fireEvent.click(screen.getByText('查看更早消息'));
|
||||
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', {data: JSON.stringify({
|
||||
|
||||
Reference in new issue
Block a user