fix(chat): reconcile reply recovery with continuous agent conversations
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Deterministic responses for layout/interaction tests only; never imported by the app.
|
||||
import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher';
|
||||
import { parseTeacherReply } from '../../../shared/teacher-reply';
|
||||
const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
|
||||
const definition: TeacherDefinition = { config_id: 'coding-teacher', schema_version: 1, teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
|
||||
const samples: Record<string, unknown> = {
|
||||
ideas: { kind: 'ideas', title: '我的小狗世界', items: [{ id: 'dog', text: '养一只自己的小狗', state: 'kept' }, { id: 'breed', parentId: 'dog', text: '是一只柯基', state: 'kept' }, { id: 'garden', text: '有一片可以种东西的花园', state: 'kept' }, { id: 'food', parentId: 'garden', text: '种出来的东西可以喂小狗', state: 'suggested' }] },
|
||||
structure: { kind: 'structure', title: '小狗和花园怎么连起来?', nodes: [{ id: 'world', label: '我的小狗世界' }, { id: 'dog', label: '照顾小狗', parentId: 'world' }, { id: 'breed', label: '一只柯基', parentId: 'dog', relation: '它的样子' }, { id: 'garden', label: '种植花园', parentId: 'world' }, { id: 'food', label: '收获食物,喂给小狗', parentId: 'garden', relation: '收获后' }] },
|
||||
@@ -9,14 +9,17 @@ const samples: Record<string, unknown> = {
|
||||
comparison: { kind: 'comparison', title: '哪一种更像你想要的小狗?', columns: [{ id: 'click', label: '点了才回应' }, { id: 'active', label: '会主动找我' }], rows: [{ id: 'home', label: '刚回到家的时候', cells: [{ columnId: 'click', text: '等我点一下,才跳起来。' }, { columnId: 'active', text: '看见我就跑过来,还带着玩具。' }] }, { id: 'busy', label: '我忙着种东西的时候', cells: [{ columnId: 'click', text: '安静等着,不打扰我。' }, { columnId: 'active', text: '有时来蹭一蹭,可能也会打断我。' }] }] },
|
||||
};
|
||||
const scenario = new URLSearchParams(location.search).get('kind') ?? 'ideas';
|
||||
export const fixtureAgent = { teacher_id: definition.teacher_id, version: 1, is_default: true, definition };
|
||||
const continuous = new URLSearchParams(location.search).get('chat') === 'continuous';
|
||||
let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]) } } : {}) };
|
||||
if (continuous) current.conversation = { agentId: definition.teacher_id, segmentTurns: 1, discussions: {} };
|
||||
const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null });
|
||||
if (scenario === 'recovered' || scenario === 'incomplete') {
|
||||
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。'
|
||||
+ (scenario === 'recovered' ? '","quickReplies":[],"tool":{"kind":"structure","nodes":[]}}' : '');
|
||||
const parsed = parseTeacherReply(raw);
|
||||
current.requests = [{
|
||||
...current.requests[0], text: '现在项目是什么阶段?', sourceConversationId: 'original-source',
|
||||
...current.requests[0], projectId: 'fixture', text: '现在项目是什么阶段?', sourceConversationId: 'original-source',
|
||||
references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
|
||||
response: parsed.reply, replyIncomplete: parsed.incomplete, replyParseError: parsed.parseError,
|
||||
suggestedQuestions: parsed.quickReplies, unparsedResponse: raw,
|
||||
@@ -42,6 +45,12 @@ const sentInputs: TeacherSend[] = [];
|
||||
Object.defineProperty(window, '__teacherReplyInputs', { value: sentInputs });
|
||||
const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); };
|
||||
export const teacherApi = {
|
||||
conversation: async () => ({ topic: structuredClone(current), before: null }),
|
||||
seenConversation: async (_agentId: string, requestId: string) => {
|
||||
if (current.conversation) current.conversation.seenThrough = requestId;
|
||||
return structuredClone(current);
|
||||
},
|
||||
sendConversation: async (_agentId: string, input: TeacherSend): Promise<TeacherTopic> => teacherApi.send('/fixture', current.id, input),
|
||||
catalog: async () => ({ items: [{ teacher_id: definition.teacher_id, version: 1, is_default: true, definition }] }),
|
||||
config: async () => ({ enabled: true, definition }),
|
||||
list: async () => ({ items: [{ id: current.id, title: '我的小狗世界' }], lastSelectedTopicId: current.id }),
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { fixtureAgent } from './teacher-reply-api';
|
||||
import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel';
|
||||
import '../../../src/styles/globals.css';
|
||||
import '../../../src/pages/Chat/classroom-workspace.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
const continuous = new URLSearchParams(location.search).get('chat') === 'continuous';
|
||||
const width = Number(new URLSearchParams(location.search).get('width')) || 508;
|
||||
createRoot(document.getElementById('root')!).render(<div className="classroom-workspace flex h-screen flex-col bg-white">
|
||||
<header className="flex h-10 shrink-0 items-center border-b px-5 text-xs">智能体回复交互测试 · 真实页面组件 / 模拟回复</header>
|
||||
<div className="flex min-h-0 flex-1 justify-end bg-[#f5f5f1]">
|
||||
<main className="min-w-0 flex-1 p-8"><h1 className="text-lg">我的小狗世界</h1><p className="mt-4 text-sm text-muted-foreground">作品区域</p></main>
|
||||
<div className="h-full shrink-0 border-l" style={{ width }}><TeacherChatPanel projectId="fixture" sourceId="source" /></div>
|
||||
<div className="h-full shrink-0 border-l" style={{ width }}><TeacherChatPanel projectId="fixture" sourceId={continuous ? 'original-source' : 'source'} selectedAgent={continuous ? fixtureAgent : undefined} /></div>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
@@ -47,10 +47,10 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation',
|
||||
});
|
||||
|
||||
for (const width of [319, 508]) {
|
||||
test(`quoted replies recover fully and incomplete replies retry without losing the draft at ${width}px`, async ({ page }) => {
|
||||
for (const chat of ['legacy', 'continuous']) test(`quoted ${chat} replies recover fully and incomplete replies retry without losing the draft at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 800 });
|
||||
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
|
||||
await page.goto(`${url}?kind=recovered&width=${width}`);
|
||||
await page.goto(`${url}?kind=recovered&width=${width}&chat=${chat}`);
|
||||
const panel = page.getByTestId('teacher-chat-panel');
|
||||
const body = page.getByTestId('teacher-reply');
|
||||
await expect(body).toContainText('它早就不是"刚搭好架子"那种阶段了');
|
||||
@@ -61,7 +61,7 @@ for (const width of [319, 508]) {
|
||||
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
await panel.screenshot({ path: test.info().outputPath(`recovered-${width}.png`) });
|
||||
|
||||
await page.goto(`${url}?kind=incomplete&width=${width}`);
|
||||
await page.goto(`${url}?kind=incomplete&width=${width}&chat=${chat}`);
|
||||
await expect(page.getByRole('status')).toHaveText('这次回复未能完整显示');
|
||||
await expect(page.getByTestId('teacher-reply')).toHaveCount(0);
|
||||
expect(await sent()).toEqual([]);
|
||||
@@ -76,7 +76,7 @@ for (const width of [319, 508]) {
|
||||
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
|
||||
await expect(input).toHaveValue('我还在写的新想法');
|
||||
expect(await sent()).toEqual([{
|
||||
requestId: expect.any(String), text: '现在项目是什么阶段?',
|
||||
requestId: expect.any(String), text: '现在项目是什么阶段?', ...(chat === 'continuous' ? { projectId: 'fixture' } : {}),
|
||||
sourceConversationId: 'original-source', references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -468,7 +468,7 @@ describe('teacher presence and periodic guidance', () => {
|
||||
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('consumes the static greeting when consultation opens through the avatar and remembers it for the project', async () => {
|
||||
it('consumes the static greeting when consultation opens through the avatar and remembers it across projects', async () => {
|
||||
const greeting = '有什么编程问题想一起讨论?';
|
||||
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: greeting } });
|
||||
const view = await mount();
|
||||
@@ -486,7 +486,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();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
|
||||
@@ -41,6 +41,37 @@ 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('retries incomplete prose only in its original project/source and keeps current drafts and one send identity', async () => {
|
||||
const original = { ...request('broken', '现在项目是什么阶段?'), response: '它早就不是', replyIncomplete: true,
|
||||
references: [{ kind: 'code' as const, text: '原项目代码' }] };
|
||||
api.conversation.mockResolvedValue({ topic: topic([original]), before: null });
|
||||
api.seenConversation.mockImplementation(async () => topic([original]));
|
||||
let finish!: (value: TeacherTopic) => void;
|
||||
api.sendConversation.mockImplementation(() => new Promise<TeacherTopic>(resolve => { finish = resolve; }));
|
||||
const view = await mount();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示');
|
||||
expect(screen.queryByTestId('teacher-reply')).toBeNull();
|
||||
expect(api.sendConversation).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '仍在编辑的草稿' } });
|
||||
view.rerender(<TeacherChatPanel {...props} projectId="project-b" sourceId="source-b" />);
|
||||
expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled();
|
||||
view.rerender(<TeacherChatPanel {...props} sourceId="another-source" />);
|
||||
expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled();
|
||||
view.rerender(<TeacherChatPanel {...props} />);
|
||||
const retry = screen.getByRole('button', { name: '重新回答' });
|
||||
fireEvent.click(retry); fireEvent.click(retry);
|
||||
expect(api.sendConversation).toHaveBeenCalledTimes(1);
|
||||
const sent = api.sendConversation.mock.calls[0][1];
|
||||
expect(sent).toEqual({ requestId: expect.any(String), projectId: 'project-a', sourceConversationId: 'source-a',
|
||||
text: original.text, references: original.references });
|
||||
expect(sent.requestId).not.toBe(original.id);
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '等待时补充的草稿' } });
|
||||
await act(async () => finish({ ...topic([original, { ...request(sent.requestId), response: '完整回答' }]), revision: 2 }));
|
||||
expect(screen.getByText('完整回答')).toBeVisible();
|
||||
expect(screen.getByRole('textbox')).toHaveValue('等待时补充的草稿');
|
||||
expect(JSON.parse(localStorage.getItem(`makelore-consultation-draft:kid-${identity.id}:agent:agent-a`)!)).toMatchObject({ text: '等待时补充的草稿' });
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { applyTeacherReply } from '../../electron/coding-teacher/reply';
|
||||
import { CodingTeacherService } from '../../electron/coding-teacher/service';
|
||||
import { CodingProjectService } from '../../electron/coding-projects/project-service';
|
||||
import { createCodingProjectStore, createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store';
|
||||
@@ -36,6 +37,55 @@ function turn(index: number): TeacherRequest {
|
||||
omittedMessages: 0, status: 'completed', response: '回答 ' + index };
|
||||
}
|
||||
describe('continuous conversation persistence', () => {
|
||||
it('repairs retained replies on every page without rewriting their files during restart recovery', async () => {
|
||||
const dir = await root();
|
||||
const store = new TeacherConversationStore(dir, 'account', 'agent-a');
|
||||
const topic = await store.ensure(definition, 1);
|
||||
const prose = '它早就不是"刚搭好架子"那种阶段了。\n完整的后半段。';
|
||||
const damaged = { ...turn(0), response: '它早就不是', discussionError: '旧解析失败',
|
||||
unparsedResponse: '{"reply":"' + prose + '","quickReplies":[]}' };
|
||||
const incomplete = { ...turn(1), response: '它早就不是', discussionError: '旧解析失败',
|
||||
unparsedResponse: '{"reply":"它早就不是"刚搭好架子' };
|
||||
const running = { ...turn(60), status: 'running' as const };
|
||||
topic.requests = [damaged, incomplete, ...Array.from({ length: 57 }, (_, i) => turn(i + 2)), running];
|
||||
await store.save(topic);
|
||||
const file = (id: string) => path.join(dir, 'turns', id + '.json');
|
||||
const rawBefore = await readFile(file(damaged.id), 'utf8');
|
||||
// Simulate a pre-repair index, including an untrusted partial assistant excerpt.
|
||||
const manifestFile = path.join(dir, 'conversation.json');
|
||||
const manifest = JSON.parse(await readFile(manifestFile, 'utf8'));
|
||||
for (const item of manifest.turns) delete item.replyProjectionVersion;
|
||||
manifest.turns[1].messages.push({ id: 'teacher:' + incomplete.id + ':assistant', role: 'assistant', text: '它早就不是' });
|
||||
await atomicWriteJson(manifestFile, manifest);
|
||||
const resumed = new TeacherConversationStore(dir, 'account', 'agent-a');
|
||||
const latest = await resumed.page();
|
||||
expect(latest.requests.at(-1)?.status).toBe('interrupted');
|
||||
const archive = await resumed.history();
|
||||
expect(archive.messages.some(message => message.text === '它早就不是')).toBe(false);
|
||||
expect(await archive.read('teacher:' + incomplete.id + ':assistant')).toBeUndefined();
|
||||
expect((await archive.read('teacher:' + damaged.id + ':assistant'))?.text).toBe(prose);
|
||||
const older = await resumed.page(latest.before!);
|
||||
expect(older.requests[0]).toMatchObject({ response: prose, unparsedResponse: damaged.unparsedResponse });
|
||||
expect(older.requests[1].replyIncomplete).toBe(true);
|
||||
expect(await readFile(file(damaged.id), 'utf8')).toBe(rawBefore);
|
||||
expect(JSON.parse(await readFile(file(incomplete.id), 'utf8'))).not.toHaveProperty('replyIncomplete');
|
||||
expect(JSON.parse(await readFile(file(running.id), 'utf8')).status).toBe('interrupted');
|
||||
});
|
||||
|
||||
it('keeps rejected new shortcuts hidden after reload while retaining historical cards', async () => {
|
||||
const dir = await root();
|
||||
const store = new TeacherConversationStore(dir, 'account', 'agent-a');
|
||||
const topic = await store.ensure(definition, 1);
|
||||
const current = turn(1);
|
||||
const old = { ...turn(0), suggestedQuestions: ['一', '二', '三', '四'] };
|
||||
applyTeacherReply(current, JSON.stringify({ reply: '完整回答', quickReplies: old.suggestedQuestions }));
|
||||
topic.requests = [old, current];
|
||||
await store.save(topic);
|
||||
const loaded = await new TeacherConversationStore(dir, 'account', 'agent-a').read();
|
||||
expect(loaded.requests[0].suggestedQuestions).toEqual(old.suggestedQuestions);
|
||||
expect(loaded.requests[1]).toMatchObject({ response: '完整回答', suggestedQuestions: [], replyQuickRepliesRejected: true, replyParseError: expect.any(String) });
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user