feat(coding): keep one ongoing conversation per delivered agent

This commit is contained in:
2026-09-28 14:19:02 +08:00
parent e54f3d234d
commit 7aa81da616
23 changed files with 1205 additions and 160 deletions

View File

@@ -232,6 +232,17 @@ async function submitCompiledContext(
return { body, compiled };
}
it('uses the accepted turn scope and runtime segment instead of mutable chat metadata', async () => {
const f = await fixture();
f.topic.requests.push({ id: requestId, projectId: 'accepted-project', sourceConversationId: 'accepted-pi',
teacherVersion: 12, runtimeThreadId: 'accepted-segment', text: '查看项目', references: [], status: 'running',
createdAt: 'now', sourceCapturedAt: 'now', sourceCursor: f.access.source.cursor,
includedSourceMessageIds: [], omittedMessages: 0, response: '' });
const { body } = await submitCompiledContext(f);
expect(body).toMatchObject({ teacher_version: 12, thread_id: 'accepted-segment',
local_context: { scope: { project_id: 'accepted-project', source_session_id: 'accepted-pi' } } });
});
it('submits only the published persona, source evidence and current question in ordered roles', async () => {
const f = await fixture();
f.topic.definition.system_prompt = '补充:解释代码时先说明现象。';

View File

@@ -14,6 +14,9 @@ const api = vi.hoisted(() => ({
cancel: vi.fn(),
save: vi.fn(),
events: vi.fn(),
conversation: vi.fn(),
sendConversation: vi.fn(),
seenConversation: vi.fn(),
}));
vi.mock('@/lib/coding-teacher', () => ({
teacherApi: api,
@@ -676,8 +679,11 @@ describe('merged cloud teacher classroom', () => {
{ teacher_id: 'algorithm', version: 9, definition: cloudDefinition, is_default: false },
] });
const selected = { ...topic('selected'), sourceConversationId: 'project', role: 'teacher' as const, definition: cloudDefinition, version: 9 };
api.conversation.mockResolvedValue({ topic: null, before: null });
api.create.mockResolvedValue(selected);
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...selected, revision: 2, requests: [request({ id: input.requestId, text: input.text, response: '先看看玩家会做什么。' })] }));
api.sendConversation.mockImplementation(async (_agent, input: TeacherSend) => ({ ...selected, revision: 2,
conversation: {agentId: 'algorithm', segmentTurns: 1, discussions: {}, seenThrough: input.requestId},
requests: [request({ id: input.requestId, text: input.text, response: '先看看玩家会做什么。' })] }));
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'algorithm', version: 9, definition: cloudDefinition, is_default: false }} />);
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
@@ -685,8 +691,8 @@ describe('merged cloud teacher classroom', () => {
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我想做一个小游戏' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('先看看玩家会做什么。');
expect(api.create).toHaveBeenCalledWith('p/agent-topics', undefined, undefined, 9);
expect(api.send.mock.calls[0][2]).toMatchObject({ sourceConversationId: 'c', presentation: 'discussion-v1' });
expect(api.create).not.toHaveBeenCalled();
expect(api.sendConversation).toHaveBeenCalledWith('algorithm', expect.objectContaining({ projectId: 'p', sourceConversationId: 'c', presentation: 'discussion-v1' }));
expect(screen.getByRole('heading', { name: '算法智能体' })).toBeVisible();
});

View File

@@ -198,7 +198,7 @@ describe('cloud coding teacher', () => {
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed'));
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
expect(saved.response).toBe('计数器从 42 开始。');
expect(saved.toolActivity).toEqual([{ id: 'parent:read-counter', name: 'read_project_file', status: 'completed' }]);
expect(saved.toolActivity).toEqual([{ id: id + ':read-counter', name: 'read_project_file', status: 'completed' }]);
const restarted = await f.restart();
expect((await restarted.read(f.scope, topic.id)).requests[0].toolActivity).toEqual(saved.toolActivity);
expect(saved.cloudRequestId).toBe('cloud-question');

View File

@@ -6,7 +6,7 @@ import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn() }));
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(), seenConversation: vi.fn() }));
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) }));
const definition: TeacherDefinition = {
@@ -21,7 +21,7 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
const [teacherComposing, setTeacherComposing] = useState(false);
const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing });
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }} />
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
{open && <TeacherChatPanel key={companion.selectedAgent?.teacher_id ?? projectId + ':history'} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
}
async function mount(props: Parameters<typeof Harness>[0] = {}) {
let view!: ReturnType<typeof render>;
@@ -36,6 +36,7 @@ beforeEach(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
api.config.mockResolvedValue({ enabled: true, definition });
api.catalog.mockResolvedValue({ items: [] });
api.conversation.mockResolvedValue({ topic: null, before: null });
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() }));
api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) }));
@@ -43,6 +44,39 @@ beforeEach(() => {
afterEach(() => { cleanup(); vi.useRealTimers(); });
describe('teacher presence and periodic guidance', () => {
it('restores the last selected disabled agent as read-only history after a restart', async () => {
const saved = { ...checkInTopic(), definition: {...definition, config_id: 'disabled', name: '已停用的老师'},
conversation: {agentId: 'disabled', segmentTurns: 1, discussions: {}, seenThrough: 'check-1'} };
localStorage.setItem('makelore-teacher-presence:kid:selected-agent', JSON.stringify('disabled'));
api.conversation.mockResolvedValue({topic: saved, before: null});
api.seenConversation.mockResolvedValue(saved);
await mount();
expect(api.conversation).toHaveBeenCalledWith('disabled');
await act(async () => fireEvent.click(screen.getByRole('button', {name: '智能体', exact: true})));
expect(screen.getByRole('heading', {name: '已停用的老师'})).toBeVisible();
expect(screen.getByRole('button', {name: '提问', exact: true})).toBeDisabled();
expect(api.sendConversation).not.toHaveBeenCalled();
});
it('tracks unread replies per agent without creating invitation bubbles or losing the selected chat on a project switch', async () => {
const agents = ['a', 'b'].map(id => ({teacher_id: id, version: 1, is_default: id === 'a', definition: {...definition, config_id: id, name: id}}));
const chats = agents.map(agent => ({...checkInTopic(), id: agent.teacher_id + '-chat', definition: agent.definition,
conversation: {agentId: agent.teacher_id, segmentTurns: 1, discussions: {}}}));
api.catalog.mockResolvedValue({items: agents});
api.conversation.mockImplementation(async id => ({topic: chats.find(chat => chat.definition.config_id === id), before: null}));
api.seenConversation.mockImplementation(async (id, requestId) => {
const chat = chats.find(chat => chat.definition.config_id === id)!;
return {...chat, revision: 3, conversation: {...chat.conversation, seenThrough: requestId}};
});
const view = await mount();
expect(view.container.querySelectorAll('.teacher-companion-unread')).toHaveLength(2);
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', {name: '与b聊天'})));
expect(api.seenConversation).toHaveBeenCalledWith('b', 'check-1');
expect(screen.getByRole('button', {name: '与b聊天'}).querySelector('.teacher-companion-unread')).toBeNull();
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByRole('button', {name: '与b聊天'})).toHaveAttribute('aria-pressed', 'true');
expect(api.create).not.toHaveBeenCalled();
});
it('uses each published icon in the topbar, welcome and panel, and falls back after an image error', async () => {
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, icon_url: 'https://yuxi.example.test/api/storage/public/images/a.png' } };
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, name: '另一个智能体', icon_url: 'https://yuxi.example.test/api/storage/public/images/b.webp' } };
@@ -66,7 +100,7 @@ describe('teacher presence and periodic guidance', () => {
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', avatar_id: 'avatar-09' } };
api.catalog.mockResolvedValue({ items: [a, b] });
api.create.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
api.send.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
api.sendConversation.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
await mount();
expect(screen.getByRole('button', { name: '与方法老师聊天' })).toBeVisible();
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
@@ -85,33 +119,33 @@ describe('teacher presence and periodic guidance', () => {
await advance();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, 9);
expect(api.send.mock.calls[0][2].text).toBe('体验的草稿');
expect(api.create).not.toHaveBeenCalled();
expect(api.sendConversation).toHaveBeenCalledWith('experience', expect.objectContaining({projectId: 'project', text: '体验的草稿'}));
});
it('restores the selected agent history at its pinned version instead of the globally last topic', async () => {
it('restores each selected agent conversation and uses its stable identity when sending', async () => {
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
const oldA = { ...checkInTopic(), id: 'a-topic', version: 1, definition: a.definition, requests: [] };
const oldB = { ...checkInTopic(), id: 'b-topic', version: 8, definition: b.definition, requests: [] };
const oldA = { ...checkInTopic(), id: 'a-topic', conversation: { agentId: 'method', segmentTurns: 0, discussions: {} }, version: 1, definition: a.definition, requests: [] };
const oldB = { ...checkInTopic(), id: 'b-topic', conversation: { agentId: 'experience', segmentTurns: 0, discussions: {} }, version: 8, definition: b.definition, requests: [] };
api.catalog.mockResolvedValue({ items: [a, b] });
api.list.mockResolvedValue({ items: [
{ id: oldB.id, teacherId: b.teacher_id, title: '体验历史' },
{ id: oldA.id, teacherId: a.teacher_id, title: '方法历史' },
], lastSelectedTopicId: oldB.id });
api.read.mockImplementation(async (_base, id) => id === oldA.id ? oldA : oldB);
api.send.mockResolvedValue({ ...oldA, revision: 3 });
api.conversation.mockImplementation(async id => ({topic: id === 'method' ? oldA : oldB, before: null}));
api.sendConversation.mockResolvedValue({ ...oldA, revision: 3 });
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(api.read).toHaveBeenCalledWith('project', oldA.id);
expect(api.conversation).toHaveBeenCalledWith('method');
expect(screen.getByRole('heading', { name: '方法老师' })).toBeVisible();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.send.mock.calls[0][1]).toBe(oldA.id);
expect(api.sendConversation.mock.calls[0][0]).toBe('method');
expect(api.create).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
expect(api.read).toHaveBeenCalledWith('project', oldB.id);
expect(api.conversation).toHaveBeenCalledWith('experience');
});
it('ignores a late initial history read after the student selects a different agent', async () => {
@@ -120,14 +154,15 @@ describe('teacher presence and periodic guidance', () => {
const old = { ...checkInTopic(), definition: a.definition };
api.catalog.mockResolvedValue({ items: [a, b] });
api.list.mockResolvedValue({ items: [{ id: old.id, teacherId: 'a' }], lastSelectedTopicId: old.id });
let resolve!: (topic: TeacherTopic) => void;
api.read.mockReturnValue(new Promise<TeacherTopic>((done) => { resolve = done; }));
let resolve!: (view: {topic: TeacherTopic; before: null}) => void;
const pending = new Promise<{topic: TeacherTopic; before: null}>((done) => { resolve = done; });
api.conversation.mockImplementation(id => id === 'a' ? pending : Promise.resolve({topic: null, before: null}));
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
await act(async () => resolve(old));
await act(async () => resolve({topic: old, before: null}));
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(api.read).toHaveBeenCalledWith('project', old.id, false);
expect(api.conversation).toHaveBeenCalledWith('a');
expect(api.send).not.toHaveBeenCalled();
});
@@ -154,7 +189,7 @@ describe('teacher presence and periodic guidance', () => {
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
});
it('shows the published greeting immediately without a model request and remembers dismissal per project', async () => {
it('shows the published greeting immediately without a model request and remembers dismissal across projects', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '有什么编程问题想一起讨论?' } });
const view = await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome');
@@ -166,7 +201,7 @@ describe('teacher presence and periodic guidance', () => {
const next = await mount();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => next.rerender(<Harness projectId="other" />));
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome');
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
});
it('gives a real unread reply priority over the greeting and does not show the greeting after acknowledgment', async () => {

View File

@@ -0,0 +1,119 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import type { TeacherDefinition, TeacherTopic, TeacherRequest } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(),
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,
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',
name: '设计老师', description: '', avatar_id: 'avatar-01', welcome_message: '聊聊吧', suggested_questions: [],
system_prompt: '', skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1000 } };
const agent = { teacher_id: 'agent-a', version: 1, definition, is_default: true };
function request(id: string, text = '旧的问题'): TeacherRequest {
return { id, text, references: [], createdAt: id, sourceCursor: { seq: 0, workerGeneration: 0 },
sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '原来的回答',
projectId: 'project-a', projectName: '天气', sourceConversationId: 'source-a', teacherVersion: 1 };
}
function topic(requests = [request('1')]): TeacherTopic {
return { schemaVersion: 1, revision: 1, id: 'chat', accountId: 'kid', projectId: 'project-a', sourceConversationId: 'project',
definition, version: 1, createdAt: 'now', updatedAt: 'now', requests,
conversation: { agentId: 'agent-a', segmentTurns: 1, discussions: {} } };
}
let stream: EventTarget;
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.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,
requests: [...topic().requests, { ...request(input.requestId, input.text), projectId: input.projectId, sourceConversationId: input.sourceConversationId }] }));
});
afterEach(cleanup);
const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source-a', selectedAgent: agent };
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 () => {
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.click(screen.getByRole('button', { name: '提问' }));
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledWith('agent-a', expect.objectContaining({
projectId: 'project-b', sourceConversationId: 'source-b', text: '跨项目草稿' })));
});
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' }));
await mount();
fireEvent.click(screen.getByText('查看更早消息'));
await screen.findByText('更早的问题');
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({
...topic([request('2', '新的问题')]), revision: 2,
}) })));
expect(screen.getByText('更早的问题')).toBeInTheDocument();
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 () => {
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} />);
fireEvent.click(screen.getByRole('button', { name: '提问' }));
expect(api.sendConversation).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('引用来自另一个项目');
expect(screen.getByText('请打开来源项目和操作对话后带回')).toBeDisabled();
});
it('opens an empty chat without a model call and creates it only through the first send', async () => {
api.conversation.mockResolvedValue({ topic: null, before: null });
await mount();
expect(api.sendConversation).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole('textbox'), { target: { value: '你好' } });
fireEvent.click(screen.getByRole('button', { name: '提问' }));
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(1));
expect(api.create).not.toHaveBeenCalled();
});
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' }));
await mount();
fireEvent.click(screen.getByText('查看更早消息'));
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', {data: JSON.stringify({
...topic([{...request('1'), response: '更新后的回答'}]), revision: 3,
})})));
await act(async () => resolve({topic: topic([request('0', '分页里较早的问题'), request('1')]), before: null}));
expect(screen.getByText('分页里较早的问题')).toBeVisible();
expect(screen.getByText('更新后的回答')).toBeVisible();
});
it('keeps legacy records read only when a live update arrives and returns to the same chat', async () => {
const old = {...topic([request('old', '旧来源会话')]), id: 'legacy', conversation: undefined};
api.legacyHistory.mockResolvedValue({items: [{id: 'legacy', title: '来源记录', legacySourceId: 'removed-source'}], lastSelectedTopicId: null});
api.legacyRead.mockResolvedValue(old);
const view = await mount();
fireEvent.click(screen.getByText('旧记录'));
fireEvent.click(await screen.findByText('来源记录(只读)'));
await screen.findByText('旧来源会话');
view.rerender(<TeacherChatPanel {...props} externalTopic={{...topic(), revision: 4}} />);
expect(screen.getByText('旧来源会话')).toBeVisible();
expect(screen.getByRole('button', {name: '提问'})).toBeDisabled();
fireEvent.click(screen.getByText('返回与智能体的聊天'));
await screen.findByText('旧的问题');
expect(api.sendConversation).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,224 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TeacherConversationStore } from '../../electron/coding-teacher/conversation-store';
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
import { CodingTeacherService } from '../../electron/coding-teacher/service';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import { createCodingProjectStore, createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
import * as atomic from '../../electron/coding-projects/atomic-json';
import { createTeacherReadTools } from '../../electron/coding-teacher/read-tools';
import { createServer } from 'node:http';
import { handleCodingTeacherRoutes } from '../../electron/api/routes/coding-teacher';
import type { HostApiContext } from '../../electron/api/context';
import type { TeacherDefinition, TeacherRequest, TeacherTopic } from '../../shared/coding-teacher';
const definition: TeacherDefinition = { schema_version: 1, config_id: 'agent-a', teacher_id: 'teacher',
runtime: 'yuxi', name: '设计老师', description: '', avatar_id: 'avatar-01', system_prompt: '', skills: [],
welcome_message: '', suggested_questions: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1000 } };
const roots: string[] = [];
const services: CodingTeacherService[] = [];
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(services.splice(0).map(service => service.dispose()));
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
});
async function root() { const dir = await mkdtemp(path.join(tmpdir(), 'single-agent-chat-')); roots.push(dir); return dir; }
function turn(index: number): TeacherRequest {
return { id: randomUUID(), text: '重复的问题', references: [], createdAt: new Date(1000 * index).toISOString(),
sourceCursor: { seq: 0, workerGeneration: 0 }, sourceCapturedAt: 'now', includedSourceMessageIds: [],
omittedMessages: 0, status: 'completed', response: '回答 ' + index };
}
describe('continuous conversation persistence', () => {
it('pages thousands of turns, restores the latest page and preserves repeated questions', async () => {
const dir = await root();
const store = new TeacherConversationStore(dir, 'account', 'agent-a');
const topic = await store.ensure(definition, 1);
topic.requests = Array.from({ length: 1001 }, (_, i) => turn(i));
await store.save(topic);
const resumed = new TeacherConversationStore(dir, 'account', 'agent-a');
expect((await resumed.read()).requests).toHaveLength(50);
const latest = await resumed.page();
expect(latest.requests.at(-1)?.response).toBe('回答 1000');
const older = await resumed.page(latest.before!);
expect(older.requests.at(-1)?.response).toBe('回答 950');
expect(new Set([...older.requests, ...latest.requests].map(turn => turn.id)).size).toBe(100);
expect((await resumed.findRequest(topic.requests[0].id))?.response).toBe('回答 0');
const archive = await resumed.history();
topic.requests.push(turn(1002)); await store.save(topic, topic.requests.at(-1)!.id);
const tools = createTeacherReadTools({ projectPath: dir, history: (await resumed.read()).requests, archive,
source: { messages: [], cursor: { seq: 0, workerGeneration: 0 }, capturedAt: 'now' }, assertCurrent() {} });
const signal = new AbortController().signal;
expect(await tools.execute('read_conversation', JSON.stringify({message_id: `teacher:${topic.requests[0].id}:assistant`}), signal)).toContain('回答 0');
expect((await tools.executeResult('read_conversation', JSON.stringify({message_id: `teacher:${topic.requests.at(-1)!.id}:assistant`}), signal)).status).toBe('error');
await resumed.markSeen(latest.requests.at(-1)!.id);
await resumed.markSeen(older.requests[0].id);
expect((await new TeacherConversationStore(dir, 'account', 'agent-a').read()).conversation?.seenThrough).toBe(latest.requests.at(-1)!.id);
});
it('recovers a turn written before its index and marks only unfinished work interrupted', async () => {
const dir = await root();
const store = new TeacherConversationStore(dir, 'account', 'agent-a');
await store.ensure(definition, 1);
const pending = { ...turn(1), status: 'running' as const };
await atomicWriteJson(path.join(dir, 'turns', pending.id + '.json'), pending);
const resumed = new TeacherConversationStore(dir, 'account', 'agent-a');
expect((await resumed.read()).requests[0]).toMatchObject({ id: pending.id, status: 'interrupted' });
expect((await resumed.page()).requests).toHaveLength(1);
});
it('imports separate topics idempotently without changing old files or merging repeated text', async () => {
const dir = await root(), project = { id: randomUUID(), path: path.join(dir, 'project'), name: '旧项目' };
const oldStore = new TeacherTopicStore(path.join(project.path, '.makelore', 'teacher-conversations', 'account', 'project'));
const old: TeacherTopic = { schemaVersion: 1, revision: 1, accountId: 'account', id: randomUUID(),
projectId: project.id, sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now',
requests: [turn(1), turn(2)] };
await oldStore.save(old);
const file = path.join(oldStore.directory, old.id + '.json'), before = await readFile(file, 'utf8');
const target = path.join(dir, 'new');
const store = new TeacherConversationStore(target, 'account', 'agent-a');
await store.importProject(project);
const resumed = new TeacherConversationStore(target, 'account', 'agent-a');
await resumed.importProject(project);
expect((await resumed.read()).requests).toHaveLength(2);
expect((await resumed.read()).requests[0].origin?.topicId).toBe(old.id);
expect(await readFile(file, 'utf8')).toBe(before);
const other = new TeacherConversationStore(path.join(dir, 'other'), 'another-account', 'agent-a');
await other.importProject(project);
expect(await other.exists()).toBe(false);
});
it('recovers an import index failure before retrying in the same process', async () => {
const dir = await root(), project = { id: randomUUID(), path: path.join(dir, 'project'), name: '旧项目' };
const oldStore = new TeacherTopicStore(path.join(project.path, '.makelore', 'teacher-conversations', 'account', 'project'));
const old: TeacherTopic = { schemaVersion: 1, revision: 1, accountId: 'account', id: randomUUID(),
projectId: project.id, sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: [turn(1)] };
await oldStore.save(old);
const store = new TeacherConversationStore(path.join(dir, 'chat'), 'account', 'agent-a');
await store.ensure(definition, 1);
const write = atomic.atomicWriteJson;
let fail = true;
vi.spyOn(atomic, 'atomicWriteJson').mockImplementation(async (file, value) => {
if (fail && file.endsWith('conversation.json')) { fail = false; throw new Error('disk unavailable'); }
return write(file, value);
});
await expect(store.importProject(project)).rejects.toThrow('disk unavailable');
await store.importProject(project);
expect((await store.page()).requests).toHaveLength(1);
expect((await store.page()).requests[0].origin?.requestId).toBe(old.requests[0].id);
});
});
it('serves paged account chats and compact SSE, preserves legacy source records and never dispatches on reads', async () => {
const f = await fixture();
const oldStore = new TeacherTopicStore(path.join(f.a.project.path, '.makelore', 'teacher-conversations', 'account', f.aSource.id));
const old: TeacherTopic = { schemaVersion: 1, revision: 1, accountId: 'account', id: randomUUID(),
projectId: f.a.project.id, sourceConversationId: f.aSource.id, definition, version: 1,
createdAt: 'now', updatedAt: 'now', requests: Array.from({length: 65}, (_, i) => turn(i)) };
await oldStore.save(old);
const server = createServer((req, res) => { void handleCodingTeacherRoutes(req, res, new URL(req.url!, 'http://localhost'),
{codingProducts: {teacher: f.service}} as HostApiContext); });
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address(); if (!address || typeof address === 'string') throw new Error('no address');
const origin = 'http://127.0.0.1:' + address.port, base = origin + '/api/coding/agent-conversations/agent-a';
const post = (url: string, input: unknown) => fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(input)});
try {
const page = await (await fetch(base)).json();
expect(page.topic.requests).toHaveLength(50);
expect((await (await fetch(base + '?before=' + page.before)).json()).topic.requests).toHaveLength(15);
const archive = origin + '/api/coding/projects/' + f.a.project.id + '/agent-history';
expect((await (await fetch(archive)).json()).items[0]).toMatchObject({id: old.id, legacySourceId: f.aSource.id});
expect((await (await fetch(archive + '/' + old.id + '?source=' + f.aSource.id)).json()).requests).toHaveLength(65);
expect((await post(archive, {})).status).toBe(405);
expect(f.run).not.toHaveBeenCalled();
const input = {projectId: f.b.project.id, sourceConversationId: f.bSource.id, requestId: randomUUID(), text: '继续'};
expect((await post(base + '/messages', input)).status).toBe(202);
expect((await post(base + '/messages', input)).status).toBe(202);
await f.settled(); expect(f.run).toHaveBeenCalledTimes(1);
const reader = (await fetch(base + '/events')).body!.getReader();
const chunk = new TextDecoder().decode((await reader.read()).value);
const snapshot = JSON.parse(chunk.split('data: ')[1].split('\n')[0]);
expect(snapshot.requests).toHaveLength(1); expect(snapshot.requests[0].id).toBe(input.requestId);
await reader.cancel();
expect((await (await post(base + '/seen', {requestId: input.requestId})).json()).conversation.seenThrough).toBe(input.requestId);
expect((await fetch(base + '?before=missing')).status).toBe(400);
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); }
});
async function fixture() {
const dir = await root();
const projects = new CodingProjectService(createCodingProjectStore(createMemoryCodingProjectStorage()));
const a = await projects.createProject({ projectPath: path.join(dir, 'weather'), identity: { kind: 'create' } });
const b = await projects.createProject({ projectPath: path.join(dir, 'game'), identity: { kind: 'create' } });
const source = async (project: typeof a) => projects.conversationStore(project.project.path)
.create({ agentId: project.config.defaultAgentId!, title: '操作对话', model: null, modelResolution: 'required' });
const aSource = await source(a), aOther = await source(a), bSource = await source(b);
let release: (() => void) | undefined;
let wait = false, version = 1, enabled = true, accountId = 'account';
const run = vi.fn(async (messages, _signal: AbortSignal, onText: (text: string) => void) => {
onText('已查看');
if (wait) await new Promise<void>(resolve => { release = resolve; });
return { inputTokens: 10, outputTokens: 5 };
});
const prepareCloud = vi.fn((_account, _topic, _id, _access) => ({ inputLimit: 8000, run }));
const service = new CodingTeacherService({ projects, runtime: new InMemoryConversationRuntime(), userDataDir: dir,
account: async () => ({ id: accountId, binding: { accountKey: accountId, epoch: 1 } }), assertAccount: () => undefined,
catalog: async () => ({ items: enabled ? [{ teacher_id: 'agent-a', version, definition, is_default: true }] : [] }),
prepareCloud,
readSource: async () => ({ messages: [{ id: 'pi', role: 'user', text: '项目上下文' }],
cursor: { seq: 0, workerGeneration: 0 }, capturedAt: 'now' }),
});
services.push(service);
const send = (project = a, session = aSource, requestId = randomUUID()) => service.sendConversation('agent-a', {
projectId: project.project.id, sourceConversationId: session.id, requestId, text: '帮我看看',
});
const settled = async () => {
await vi.waitFor(async () => expect((await service.conversation('agent-a')).topic?.requests.at(-1)?.status).toBe('completed'));
return (await service.conversation('agent-a')).topic!;
};
return { service, a, b, aSource, aOther, bSource, prepareCloud, run, send, settled,
wait: () => { wait = true; }, release: () => { wait = false; release?.(); },
version: (v: number) => { version = v; }, enabled: (v: boolean) => { enabled = v; },
account: (id: string) => { accountId = id; } };
}
describe('one account-agent conversation', () => {
it('keeps one visible chat across projects and versions while freezing each execution scope', async () => {
const f = await fixture();
await f.send(); const first = await f.settled();
await f.send(); const same = await f.settled();
expect(same.id).toBe(first.id);
expect(same.requests[1].runtimeThreadId).toBe(first.requests[0].runtimeThreadId);
// The same cloud checkpoint already owns the previous exchange.
expect(f.run.mock.calls[1][0].filter((m: { content: string }) => m.content.includes('已查看'))).toHaveLength(0);
await f.send(f.b, f.bSource); const second = await f.settled();
expect(second.id).toBe(first.id);
expect(second.requests[2].runtimeThreadId).not.toBe(first.requests[0].runtimeThreadId);
expect(f.prepareCloud.mock.calls[2][3].projectPath).toBe(f.b.project.path);
expect(JSON.stringify(f.run.mock.calls[2][0])).toContain('weather');
f.version(2); await f.send(f.b, f.bSource); const updated = await f.settled();
expect(updated.id).toBe(first.id);
expect(updated.requests.map(request => request.teacherVersion)).toEqual([1, 1, 1, 2]);
expect(updated.requests[3].runtimeThreadId).not.toBe(updated.requests[2].runtimeThreadId);
f.enabled(false);
await expect(f.send()).rejects.toMatchObject({ code: 'teacher_disabled' });
expect((await f.service.conversation('agent-a')).topic?.requests).toHaveLength(4);
});
it('deduplicates concurrent submissions, rejects a second active turn and rotates on Pi source change', async () => {
const f = await fixture(); f.wait();
const requestId = randomUUID();
const [a, b] = await Promise.all([f.send(f.a, f.aSource, requestId), f.send(f.a, f.aSource, requestId)]);
expect(a.id).toBe(b.id);
expect(f.run).toHaveBeenCalledTimes(1);
await expect(f.send(f.b, f.bSource)).rejects.toMatchObject({ code: 'teacher_topic_busy' });
expect(f.prepareCloud.mock.calls[0][3].projectPath).toBe(f.a.project.path);
f.release(); await f.settled();
await f.send(f.a, f.aOther); const next = await f.settled();
expect(next.requests[1].runtimeThreadId).not.toBe(next.requests[0].runtimeThreadId);
f.account('other');
expect((await f.service.conversation('agent-a')).topic).toBe(null);
await f.send(); expect((await f.settled()).id).not.toBe(a.id);
});
});