// @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(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(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(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); }); });