Merge remote main preserving project chats and reply recovery
This commit is contained in:
commit
efff238a75
54 files changed
+3189
-1713
No files matched your search
@@ -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';
|
||||
@@ -83,6 +84,32 @@ describe('continuous conversation persistence', () => {
|
||||
await foreign.importGlobal(global);
|
||||
expect(await foreign.exists()).toBe(false);
|
||||
});
|
||||
it('imports reply-only global history by project without restoring components or changing its raw files', async () => {
|
||||
const dir = await root(), global = path.join(dir, 'global');
|
||||
const prose = '它早就不是"刚搭好架子"那种阶段了。完整的后半段。';
|
||||
const request = { ...turn(1), projectId: 'project-a', response: '它早就不是', replyParseError: '旧解析失败',
|
||||
unparsedResponse: '{"reply":"' + prose + '","quickReplies":[]}' };
|
||||
const unrelated = { ...turn(2), projectId: 'project-b', text: '其他项目的问题' };
|
||||
const manifestFile = path.join(global, 'conversation.json');
|
||||
await atomicWriteJson(manifestFile, { topic: {
|
||||
id: randomUUID(), accountId: 'account', definition, version: 1,
|
||||
conversation: { agentId: 'agent-a', segmentTurns: 1 },
|
||||
}, turns: [request, unrelated].map(({ id, createdAt }) => ({ id, createdAt, messages: [] })) });
|
||||
for (const item of [request, unrelated]) await atomicWriteJson(path.join(global, 'turns', item.id + '.json'), item);
|
||||
const rawFile = path.join(global, 'turns', request.id + '.json');
|
||||
const original = await readFile(rawFile, 'utf8');
|
||||
const manifest = await readFile(manifestFile, 'utf8');
|
||||
const store = new TeacherConversationStore(path.join(dir, 'project'), 'account', 'agent-a', 'project-a');
|
||||
await store.importGlobal(global);
|
||||
const topic = await store.read();
|
||||
expect(topic.requests).toHaveLength(1);
|
||||
expect(topic.requests[0]).toMatchObject({ id: request.id, projectId: 'project-a', response: prose,
|
||||
unparsedResponse: request.unparsedResponse });
|
||||
expect(topic.conversation).not.toHaveProperty('discussions');
|
||||
expect((await store.history()).messages.some(message => message.text === unrelated.text)).toBe(false);
|
||||
expect(await readFile(rawFile, 'utf8')).toBe(original);
|
||||
expect(await readFile(manifestFile, 'utf8')).toBe(manifest);
|
||||
});
|
||||
it('resumes a partial global split after an index write failure without duplicate messages', async () => {
|
||||
const dir = await root(), old = path.join(dir, 'global'), dest = path.join(dir, 'project');
|
||||
const request = { ...turn(1), projectId: 'project-a' };
|
||||
@@ -105,6 +132,55 @@ describe('continuous conversation persistence', () => {
|
||||
await new TeacherConversationStore(dest, 'account', 'agent-a', 'project-a').importGlobal(old);
|
||||
expect((await store.history()).messages).toHaveLength(2);
|
||||
});
|
||||
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', 'project-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', 'project-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', 'project-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', 'project-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', 'project-a');
|
||||
@@ -160,6 +236,68 @@ describe('continuous conversation persistence', () => {
|
||||
await other.importProject(project);
|
||||
expect(await other.exists()).toBe(false);
|
||||
});
|
||||
it('imports reply history and its execution identity without reviving archived components', async () => {
|
||||
const dir = await root(), project = { id: randomUUID(), path: path.join(dir, 'project'), name: '旧项目' };
|
||||
const sourceId = randomUUID();
|
||||
const archived = { obsoleteComponent: 'never-send-this-to-model', broken: ['not', 'a', 'component'] };
|
||||
const oldStore = new TeacherTopicStore(path.join(project.path, '.makelore', 'teacher-conversations', 'account', sourceId));
|
||||
const request: TeacherRequest = { ...turn(1), sourceConversationId: sourceId,
|
||||
presentation: 'discussion-v1', discussionContext: archived, discussionSnapshot: archived,
|
||||
discussionError: 'historical parser error', cloudRequestId: 'old-cloud-request',
|
||||
response: '保留的正文', suggestedQuestions: ['继续聊这个想法'] };
|
||||
const old: TeacherTopic = { schemaVersion: 1, revision: 1, accountId: 'account', id: randomUUID(),
|
||||
projectId: project.id, sourceConversationId: sourceId, definition, version: 7,
|
||||
createdAt: 'now', updatedAt: 'now', requests: [request], discussion: archived };
|
||||
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', project.id);
|
||||
await store.importProject(project);
|
||||
const resumed = new TeacherConversationStore(target, 'account', 'agent-a', project.id);
|
||||
await resumed.importProject(project);
|
||||
const topic = await resumed.read();
|
||||
expect(topic.requests).toHaveLength(1);
|
||||
expect(topic.requests[0]).toMatchObject({
|
||||
origin: { projectId: project.id, topicId: old.id, requestId: request.id },
|
||||
projectId: project.id, projectName: project.name, sourceConversationId: sourceId,
|
||||
teacherVersion: 7, runtimeThreadId: old.id, cloudRequestId: 'old-cloud-request',
|
||||
response: '保留的正文', suggestedQuestions: ['继续聊这个想法'],
|
||||
discussionContext: archived, discussionSnapshot: archived, discussionError: 'historical parser error',
|
||||
});
|
||||
expect(topic.conversation).not.toHaveProperty('discussions');
|
||||
expect(topic).not.toHaveProperty('discussion');
|
||||
const manifest = JSON.parse(await readFile(path.join(target, 'conversation.json'), 'utf8'));
|
||||
expect(manifest).not.toHaveProperty('importedDiscussions');
|
||||
expect(JSON.stringify((await resumed.history()).messages)).not.toContain(archived.obsoleteComponent);
|
||||
const history = await resumed.history();
|
||||
expect((await history.read(`teacher:${topic.requests[0].id}:assistant`))?.text)
|
||||
.toBe('保留的正文\n\n可以接着聊的问题:\n- 继续聊这个想法');
|
||||
expect(await readFile(file, 'utf8')).toBe(before);
|
||||
});
|
||||
it('preserves existing continuous-chat component archives opaquely across recovery and writes', async () => {
|
||||
const dir = await root();
|
||||
const store = new TeacherConversationStore(dir, 'account', 'agent-a', 'project-a');
|
||||
const topic = await store.ensure(definition, 1);
|
||||
expect(topic.conversation).not.toHaveProperty('discussions');
|
||||
const manifestPath = path.join(dir, 'conversation.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
const archives = { project: { malformedLegacyState: true, active: ['opaque', 'archive'] } };
|
||||
const imported = { project: { legacyMarker: true } };
|
||||
manifest.topic.conversation.discussions = archives;
|
||||
manifest.importedDiscussions = imported;
|
||||
await atomicWriteJson(manifestPath, manifest);
|
||||
const pending = { ...turn(1), status: 'running' as const, discussionSnapshot: { malformed: true } };
|
||||
await atomicWriteJson(path.join(dir, 'turns', pending.id + '.json'), pending);
|
||||
const resumed = new TeacherConversationStore(dir, 'account', 'agent-a', 'project-a');
|
||||
const restored = await resumed.read();
|
||||
expect(restored.requests[0]).toMatchObject({ status: 'interrupted', discussionSnapshot: { malformed: true } });
|
||||
restored.requests.push(turn(2));
|
||||
await resumed.save(restored, restored.requests[1].id);
|
||||
const saved = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
expect(saved.topic.conversation.discussions).toEqual(archives);
|
||||
expect(saved.importedDiscussions).toEqual(imported);
|
||||
expect((await new TeacherConversationStore(dir, 'account', 'agent-a', 'project-a').page()).requests).toHaveLength(2);
|
||||
});
|
||||
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'));
|
||||
@@ -189,7 +327,9 @@ it('serves paged account chats and compact SSE, preserves legacy source records
|
||||
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); });
|
||||
{codingProducts: {teacher: f.service}} as HostApiContext).then(handled => {
|
||||
if (!handled) { res.statusCode = 404; res.end(); }
|
||||
}); });
|
||||
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;
|
||||
@@ -219,7 +359,7 @@ it('serves paged account chats and compact SSE, preserves legacy source records
|
||||
expect((await (await post(otherBase + '/seen', {requestId: input.requestId})).json()).conversation.seenThrough).toBe(input.requestId);
|
||||
expect((await (await post(base + '/seen', {requestId: input.requestId})).json()).conversation.seenThrough).toBeUndefined();
|
||||
expect((await (await fetch(base)).json()).topic.requests).toHaveLength(50);
|
||||
expect((await post(otherBase + '/discussion', {projectId: f.a.project.id, action: 'archive'})).status).toBe(422);
|
||||
expect((await post(otherBase + '/discussion', {projectId: f.a.project.id, action: 'archive'})).status).toBe(404);
|
||||
expect((await fetch(base + '?before=missing')).status).toBe(400);
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); }
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user