403 lines
22 KiB
TypeScript
403 lines
22 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 { 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> = {}): 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);
|
|
});
|
|
});
|