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