Files
makelore/tests/unit/teacher-reply-history.test.ts

209 lines
11 KiB
TypeScript

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