209 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|