fix(chat): reconcile reply recovery with continuous agent conversations
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
鲨鱼辣椒 committed 2026-09-28 18:14:12 +08:00
1 parent 44b16f8fbd
commit cafebca057
13 files changed
+159 -34

No files matched your search

+2 -2
View File
@@ -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();
+50
View File
@@ -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');