// @vitest-environment node import { afterEach, describe, expect, it, vi } from 'vitest'; import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { TeacherTopicStore } from '../../electron/coding-teacher/store'; import { applyTeacherReply } from '../../electron/coding-teacher/reply'; import { compileTeacherContext, teacherHistoryMessages } from '../../electron/coding-teacher/context'; import { createTeacherReadTools } from '../../electron/coding-teacher/read-tools'; import type { TeacherDefinition, TeacherRequest, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher'; const roots: string[] = []; afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); const definition: TeacherDefinition = { config_id: 'selected-agent', schema_version: 1, teacher_id: 'published-agent', name: '当前智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '使用已发布配置中的职责。', skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 32000, max_output_tokens: 1500 }, }; const source: TeacherSourceContext = { messages: [{ id: 'source-message', role: 'assistant', text: '玩家已经能走到门口。' }], cursor: { workerGeneration: 1, seq: 3 }, capturedAt: '2026-09-28T01:00:00Z', }; const marker = (field: string) => 'ARCHIVED_ONLY_' + field; const ideas = { kind: 'ideas', title: marker('ideas'), items: [{ id: 'door', text: marker('idea-text'), state: 'kept' }], firstItemId: 'door', }; const structure = { kind: 'structure', title: marker('structure'), nodes: [{ id: 'door', label: marker('structure-label') }], }; const flow = { kind: 'flow', title: marker('flow'), nodes: [{ id: 'door', label: marker('flow-label'), kind: 'event' }], edges: [], }; const comparison = { kind: 'comparison', title: marker('comparison'), columns: [{ id: 'a', label: marker('column-a') }, { id: 'b', label: marker('column-b') }], rows: [{ id: 'first', label: marker('row'), cells: [ { columnId: 'a', text: marker('cell-a') }, { columnId: 'b', text: marker('cell-b') }, ] }], }; const archivedCases = [ ...[ideas, structure, flow, comparison].map(content => ({ name: content.kind, discussion: { id: 'b8a278e5-a5ce-45fc-af92-d39d8fd48ebf', revision: 7, status: 'active', content, previousIdeas: ideas, previousStructure: structure, }, context: { toolId: 'b8a278e5-a5ce-45fc-af92-d39d8fd48ebf', revision: 7, focusId: 'door' }, snapshot: content, })), { name: 'invalid legacy fields', discussion: { id: marker('invalid-id'), revision: -4, status: marker('invalid-status'), content: { kind: marker('unknown-kind') }, previousIdeas: [marker('invalid-previousIdeas')], previousStructure: marker('invalid-previousStructure'), unknownArchiveField: { retained: marker('unknown-field') }, }, context: { toolId: marker('context-id'), revision: 'invalid', transition: marker('context-transition') }, snapshot: { kind: 'flow', nodes: marker('invalid-snapshot') }, }, { name: 'legacy scalar', discussion: marker('scalar'), context: marker('scalar-context'), snapshot: 42 }, { name: 'legacy array', discussion: [marker('array')], context: [marker('array-context')], snapshot: [marker('array-snapshot')] }, { name: 'legacy null', discussion: null, context: null, snapshot: null }, ]; function request(overrides: Partial = {}): TeacherRequest { return { id: 'completed-turn', text: '进门后要让玩家看见什么?', references: [], createdAt: source.capturedAt, sourceCursor: source.cursor, sourceCapturedAt: source.capturedAt, includedSourceMessageIds: ['source-message'], omittedMessages: 0, status: 'completed', response: '先看进门时的提示。\n\n```json\n{"kind":"flow","label":"普通代码示例"}\n```', suggestedQuestions: ['→ 看看提示出现的时机', '↪ 我想先说说玩家会看到什么'], presentation: 'discussion-v1', discussionError: marker('error'), unparsedResponse: marker('unparsed'), cloudRequestId: 'cloud-history-request', toolActivity: [{ id: 'read-1', name: 'read_project_file', status: 'completed' }], ...overrides, }; } function topic(archive = archivedCases[0]): TeacherTopic { return { schemaVersion: 1, revision: 12, id: 'e6ba57d5-9dc0-49fb-a8cf-e911f9ea366b', accountId: 'account', projectId: 'project', sourceConversationId: 'project', version: 31, definition, createdAt: source.capturedAt, updatedAt: source.capturedAt, discussion: structuredClone(archive.discussion), requests: [request({ discussionContext: structuredClone(archive.context), discussionSnapshot: structuredClone(archive.snapshot), })], }; } async function fixture(value: TeacherTopic) { const root = await mkdtemp(path.join(tmpdir(), 'teacher-reply-history-')); roots.push(root); const file = path.join(root, value.id + '.json'); // Different formatting also detects an unnecessary atomic JSON rewrite. const raw = JSON.stringify(value, null, 4) + '\n\n'; await writeFile(file, raw); const before = await stat(file, { bigint: true }); const unchanged = async () => { expect(await readFile(file, 'utf8')).toBe(raw); const after = await stat(file, { bigint: true }); expect({ ino: after.ino, mtimeNs: after.mtimeNs }).toEqual({ ino: before.ino, mtimeNs: before.mtimeNs }); }; return { root, file, unchanged, store: new TeacherTopicStore(root) }; } describe('passive teacher reply history compatibility', () => { it('retains all four historical shortcuts through read, list and a later save', async () => { const original = topic(archivedCases[4]); const historicalReplies = [ '看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头的作用。', '试试结果|怎样知道变化发生了?', '换个话题|我想先聊别的地方。', ]; original.requests[0].suggestedQuestions = historicalReplies; const f = await fixture(original); const loaded = await f.store.read(original.id); expect(loaded).toEqual(original); expect(loaded.requests[0].suggestedQuestions).toEqual(historicalReplies); expect((await f.store.list()).items).toEqual([{ id: original.id, title: original.requests[0].text, updatedAt: original.updatedAt, version: 31, teacherId: 'selected-agent', }]); expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(original); await f.unchanged(); loaded.revision++; loaded.requests.push(request({ id: 'new-turn', presentation: 'reply-v1', suggestedQuestions: [], discussionError: undefined, unparsedResponse: undefined })); await f.store.save(loaded); const expected = JSON.parse(JSON.stringify({ ...original, revision: original.revision + 1, requests: [...original.requests, loaded.requests[1]], })); const saved = JSON.parse(await readFile(f.file, 'utf8')); expect(saved).toEqual(expected); expect(saved.requests[0].suggestedQuestions).toEqual(historicalReplies); expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(expected); }); it.each(archivedCases)('keeps $name opaque through read, list and a later save', async archive => { const original = topic(archive); const f = await fixture(original); const loaded = await f.store.read(original.id); expect(loaded).toEqual(original); expect(await new TeacherTopicStore(f.root).list()).toEqual({ items: [{ id: original.id, title: original.requests[0].text, updatedAt: original.updatedAt, version: 31, teacherId: 'selected-agent' }], lastSelectedTopicId: original.id, }); await f.unchanged(); loaded.revision++; loaded.requests.push(request({ id: 'new-turn', presentation: 'reply-v1', discussionError: undefined, unparsedResponse: undefined })); await f.store.save(loaded); const saved = JSON.parse(await readFile(f.file, 'utf8')); expect(saved).toEqual(JSON.parse(JSON.stringify({ ...original, revision: original.revision + 1, requests: [...original.requests, loaded.requests[1]], }))); expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(saved); }); it.each(['preparing', 'running'] as const)('recovers %s without interpreting or discarding archived data', async status => { const original = topic(archivedCases[4]); original.requests.push(...(['failed', 'cancelled', 'interrupted', status] as const).map((value, index) => request({ ...original.requests[0], id: 'turn-' + index, status: value, error: 'existing error ' + index }))); const f = await fixture(original); const expected = structuredClone(original); expected.revision++; expected.requests.at(-1)!.status = 'interrupted'; expected.requests.at(-1)!.error = '应用已重启,本次回复中断。'; const loaded = await f.store.read(original.id); expect(loaded).toEqual({ ...expected, unsaved: false }); expect(JSON.parse(await readFile(f.file, 'utf8'))).toEqual(expected); const recovered = await stat(f.file, { bigint: true }); const reloadedStore = new TeacherTopicStore(f.root); expect(await reloadedStore.read(original.id)).toEqual(expected); await reloadedStore.list(); const reread = await stat(f.file, { bigint: true }); expect({ ino: reread.ino, mtimeNs: reread.mtimeNs }).toEqual({ ino: recovered.ino, mtimeNs: recovered.mtimeNs }); }); it('projects completed prose and unchanged quick replies without leaking archived fields into model context', () => { const original = topic(archivedCases[4]); original.requests.push(request({ id: 'check-in', intent: 'check-in', text: marker('synthetic-check-in-question'), response: '项目进展提醒正文。', suggestedQuestions: ['→ 继续这个角度'] })); original.requests.push(...(['preparing', 'running', 'failed', 'cancelled', 'interrupted'] as const).map(status => request({ id: status, status, text: marker(status), response: marker(status + '-response') }))); const before = structuredClone(original); const history = teacherHistoryMessages(original.requests); expect(history.map(message => [message.id, message.role])).toEqual([ ['teacher:completed-turn:user', 'user'], ['teacher:completed-turn:assistant', 'assistant'], ['teacher:check-in:assistant', 'assistant'], ]); expect(history[0].text).toBe(original.requests[0].text); expect(history[1].text).toContain(original.requests[0].response); for (const quickReply of original.requests[0].suggestedQuestions!) expect(history[1].text).toContain(quickReply); expect(history[2].text).toContain('项目进展提醒正文。'); expect(history[2].text).toContain('→ 继续这个角度'); expect(JSON.stringify(history)).not.toContain('ARCHIVED_ONLY_'); const compiled = compileTeacherContext(definition, source, original.requests, '接着看这个角度', []); const serialized = JSON.stringify(compiled.messages); expect(serialized).not.toContain('ARCHIVED_ONLY_'); for (const message of history) expect(compiled.messages.some(item => item.content.includes(message.text))).toBe(true); expect(original).toEqual(before); }); it('exposes only the same readable history through conversation index and message reads', async () => { const original = topic(archivedCases[4]); const f = await fixture(original); const loaded = await f.store.read(original.id); const assertCurrent = vi.fn(); const tools = createTeacherReadTools({ projectPath: f.root, source, history: loaded.requests, assertCurrent }); const signal = new AbortController().signal; const index = await tools.execute('read_conversation', '{}', signal); expect(index).toContain('teacher:completed-turn:assistant'); expect(index).not.toContain('ARCHIVED_ONLY_'); for (const message of teacherHistoryMessages(loaded.requests)) { const raw = await tools.execute('read_conversation', JSON.stringify({ message_id: message.id }), signal); expect(JSON.parse(raw).text).toBe(message.text); expect(raw).not.toContain('ARCHIVED_ONLY_'); } const inaccessible = await tools.executeResult('read_conversation', JSON.stringify({ message_id: 'discussion' }), signal); expect(inaccessible.status).toBe('error'); expect(inaccessible.content).not.toContain('ARCHIVED_ONLY_'); expect(assertCurrent).toHaveBeenCalled(); expect(loaded).toEqual(original); await f.unchanged(); }); }); describe('local repair of retained teacher replies', () => { const completeReply = '它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在更需要打磨细节。'; const quoteRaw = '{"reply":"' + completeReply + '","quickReplies":["看看接下来做什么"],"tool":{"kind":"structure","nodes":[{"id":"core","label":"完整结构"}]}}'; it('repairs the screenshot-style quoted prose in a cached read without rewriting or replaying history', async () => { const original = topic(archivedCases[4]); original.requests[0] = request({ ...original.requests[0], response: '它早就不是', unparsedResponse: quoteRaw, replyParseError: '旧解析错误', replyIncomplete: true, usage: { inputTokens: 20, outputTokens: 40, cacheReadTokens: 5, cacheWriteTokens: 3 } }); const f = await fixture(original); const loaded = await f.store.read(original.id); const repaired = loaded.requests[0]; expect(repaired).toEqual({ ...original.requests[0], response: completeReply, suggestedQuestions: ['看看接下来做什么'], discussionError: undefined, replyParseError: undefined, replyIncomplete: undefined }); expect(loaded).toEqual({ ...original, requests: [repaired] }); expect(repaired.unparsedResponse).toBe(quoteRaw); expect(repaired.status).toBe('completed'); expect(repaired.discussionContext).toEqual(original.requests[0].discussionContext); expect(repaired.discussionSnapshot).toEqual(original.requests[0].discussionSnapshot); expect(await f.store.read(original.id)).toBe(loaded); expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded); await f.unchanged(); // A later ordinary save can retain the repaired projection, with the exact // original response and all opaque data still available for diagnosis. await f.store.save(loaded); const persisted = JSON.parse(await readFile(f.file, 'utf8')); expect(persisted.requests[0].unparsedResponse).toBe(quoteRaw); expect(persisted.requests[0].response).toBe(completeReply); expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(persisted); }); it('keeps complete prose when auxiliary suggestions or legacy payloads remain invalid', async () => { const original = topic(); const raw = '{"reply":"这是一段完整回答。","quickReplies":[42],"tool":'; original.requests[0] = request({ response: '这是一段完整回答。', unparsedResponse: raw, replyParseError: '旧解析错误', replyIncomplete: true }); const f = await fixture(original); const loaded = await f.store.read(original.id); expect(loaded.requests[0]).toMatchObject({ status: 'completed', response: '这是一段完整回答。', suggestedQuestions: [], unparsedResponse: raw, replyParseError: expect.any(String) }); expect(loaded.requests[0].replyIncomplete).toBeUndefined(); expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded); await f.unchanged(); }); it('repairs display after saving restart recovery without rewriting a completed reply on disk', async () => { const original = topic(); original.requests[0] = request({ response: '它早就不是', unparsedResponse: quoteRaw }); original.requests.push(request({ id: 'running-turn', status: 'running', response: '' })); const f = await fixture(original); const loaded = await f.store.read(original.id); expect(loaded.requests[0].response).toBe(completeReply); expect(loaded.requests[1].status).toBe('interrupted'); const persisted = JSON.parse(await readFile(f.file, 'utf8')); expect(persisted.requests[0]).toEqual(original.requests[0]); expect(persisted.requests[1].status).toBe('interrupted'); expect(persisted.revision).toBe(original.revision + 1); expect((await new TeacherTopicStore(f.root).read(original.id)).requests[0].response).toBe(completeReply); }); it('keeps the question but excludes incomplete prose and suggestions from model context and read tools', async () => { const original = topic(); original.requests[0] = request({ response: '它早就不是', unparsedResponse: '{"reply":"它早就不是"刚搭好架子', suggestedQuestions: ['尚未确认的建议'] }); const f = await fixture(original); const loaded = await f.store.read(original.id); expect(loaded.requests[0].replyIncomplete).toBe(true); expect(teacherHistoryMessages(loaded.requests)).toEqual([ { id: 'teacher:completed-turn:user', role: 'user', text: original.requests[0].text }, ]); const context = JSON.stringify(compileTeacherContext(definition, source, loaded.requests, '重新看看', []).messages); expect(context).toContain(original.requests[0].text); expect(context).not.toMatch(/它早就不是|尚未确认的建议/); const tools = createTeacherReadTools({ projectPath: f.root, source, history: loaded.requests, assertCurrent: vi.fn() }); const signal = new AbortController().signal; const index = await tools.execute('read_conversation', '{}', signal); expect(index).toContain('teacher:completed-turn:user'); expect(index).not.toContain('teacher:completed-turn:assistant'); const assistant = await tools.executeResult('read_conversation', JSON.stringify({ message_id: 'teacher:completed-turn:assistant' }), signal); expect(assistant.status).toBe('error'); expect(assistant.content).not.toContain('它早就不是'); await f.unchanged(); }); it('marks truly truncated prose incomplete without replacing the saved text or raw evidence', async () => { const original = topic(); original.requests[0] = request({ response: '它早就不是', unparsedResponse: '{"reply":"它早就不是"刚搭好架子', replyParseError: '旧解析错误' }); const f = await fixture(original); const loaded = await f.store.read(original.id); expect(loaded.requests[0]).toEqual({ ...original.requests[0], replyIncomplete: true }); expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded); await f.unchanged(); }); it.each(['failed', 'cancelled', 'interrupted'] as const)('does not salvage a %s request as success', async status => { const original = topic(); original.requests[0] = request({ status, response: '它早就不是', unparsedResponse: quoteRaw, error: '该请求未完成', replyParseError: '旧解析错误' }); const f = await fixture(original); expect(await f.store.read(original.id)).toEqual(original); await f.unchanged(); }); it.each(['preparing', 'running'] as const)('keeps restart recovery for %s without accepting its received bytes', async status => { const original = topic(); original.requests[0] = request({ status, response: '它早就不是', unparsedResponse: quoteRaw, replyParseError: '旧解析错误' }); const f = await fixture(original); const loaded = await f.store.read(original.id); expect(loaded.requests[0]).toEqual({ ...original.requests[0], status: 'interrupted', error: '应用已重启,本次回复中断。' }); expect(loaded.revision).toBe(original.revision + 1); expect(await new TeacherTopicStore(f.root).read(original.id)).toMatchObject({ requests: loaded.requests }); }); it('does not replace useful prose with different content from a stale raw archive', async () => { const original = topic(); original.requests[0] = request({ response: '已经保存的完整回答。', unparsedResponse: quoteRaw }); const f = await fixture(original); expect(await f.store.read(original.id)).toEqual(original); await f.unchanged(); }); it('also repairs the legacy intro/questions envelope while leaving its tool snapshot opaque', async () => { const original = topic(); const raw = '{"intro":"它早就不是"刚搭好架子"了。","questions":["继续"],"tool":{"kind":"ideas"}}'; original.requests[0] = request({ response: '它早就不是', unparsedResponse: raw }); const f = await fixture(original); const loaded = await f.store.read(original.id); expect(loaded.requests[0]).toMatchObject({ response: '它早就不是"刚搭好架子"了。', suggestedQuestions: ['继续'], unparsedResponse: raw }); expect(loaded.requests[0].discussionError).toBeUndefined(); expect(loaded.requests[0].discussionSnapshot).toEqual(original.requests[0].discussionSnapshot); await f.unchanged(); }); it('propagates incomplete results and retains diagnostics for recovered new replies', () => { const current = request({ replyIncomplete: true, replyParseError: '旧解析错误' }); applyTeacherReply(current, '{"reply":"还没有说完'); expect(current.replyIncomplete).toBe(true); expect(current.replyParseError).toBeTruthy(); expect(current.unparsedResponse).toBe('{"reply":"还没有说完'); applyTeacherReply(current, quoteRaw); expect(current.response).toBe(completeReply); expect(current.replyIncomplete).toBeUndefined(); expect(current.replyParseError).toBeUndefined(); expect(current.discussionError).toBeUndefined(); expect(current.unparsedResponse).toBe(quoteRaw); const recovered = structuredClone(current); applyTeacherReply(current, quoteRaw); expect(current).toEqual(recovered); }); });