Files
makelore/tests/unit/teacher-conversation.test.ts

352 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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 { 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';
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('preserves individual completion times and reads older replies without backfilling or rewriting them', async () => {
const dir = await root();
const store = new TeacherConversationStore(dir, 'account', 'agent-a');
const topic = await store.ensure(definition, 1);
const old = turn(1), recent = { ...turn(2), finishedAt: '2026-09-29T08:01:42.000Z' };
topic.requests = [old, recent];
await store.save(topic);
const first = await new TeacherConversationStore(dir, 'account', 'agent-a').read();
expect(first.requests[0]).not.toHaveProperty('finishedAt');
expect(first.requests[1].finishedAt).toBe(recent.finishedAt);
const second = await new TeacherConversationStore(dir, 'account', 'agent-a').read();
expect(second.requests).toEqual(first.requests);
expect(second.requests[0].createdAt).toBe(old.createdAt);
});
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');
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('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');
await store.importProject(project);
const resumed = new TeacherConversationStore(target, 'account', 'agent-a');
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');
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');
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').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'));
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);
});
});