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

310 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment node
import { describe, expect, it } from 'vitest';
import {
MAX_TEACHER_QUICK_REPLIES,
MAX_TEACHER_QUICK_REPLY_BYTES,
MAX_TEACHER_REPLY_BYTES,
parseTeacherReply,
} from '../../shared/teacher-reply';
import { applyTeacherReply, teacherReplyInstructions } from '../../electron/coding-teacher/reply';
import type { TeacherRequest } from '../../shared/coding-teacher';
const fourHistoricalReplies = [
'看懂规则|摸头会改变什么?',
'想想作用|我们聊聊摸头的作用。',
'试试结果|怎样知道变化发生了?',
'换个话题|我想先聊别的地方。',
];
describe('teacher text and suggestions transport', () => {
const envelope = { reply: '🍎 **先看看重力带来的变化。**', quickReplies: ['继续解释', '比较不同选择'] };
it.each([
(json: string) => json,
(json: string) => '```json\n' + json + '\n```',
(json: string) => '```JSON\r\n' + json + '\r\n```',
(json: string) => '```\n' + json + '\n```',
(json: string) => '~~~json\n' + json + '\n~~~',
(json: string) => '```makelore-teacher\n' + json + '\n```',
(json: string) => '```makelore-teacher-reply\n' + json + '\n```',
(json: string) => '```makelore-teacher-discussion\n' + json + '\n```',
])('accepts current and historical envelope fences', wrap => {
expect(parseTeacherReply(wrap(JSON.stringify(envelope)))).toEqual(envelope);
});
it.each([
'[项目说明](https://example.com/readme)\n\n先看已有设计。',
'我们可以用流程图看看。\n```js\nif (awake) welcome();\n```',
'[1, 2, 3]',
'[{"reply":"普通数组示例"}]',
'```json\nnot valid json\n```',
'{"scripts":{"dev":"vite"},"name":"flybot"}',
'项目配置可以这样写:\n\n```json\n{"name":"flybot","scripts":{"dev":"vite"}}\n```',
'{"example":{"reply":"嵌套数据字段"}}',
'```js\nconst example = {"reply":"代码示例"};\n```',
'字段示例 `{"reply":"行内示例"}` 不属于传输协议。',
'``{"reply":"含反引号 ` 的示例"}``',
'这是问卷接口的 JSON 示例:\n```json\n{"questions":["喜欢什么颜色?"]}\n```',
'{"tool":"read_project_file","path":"src/main.ts"}',
'{"tool":{"name":"read_project_file","arguments":{"path":"src/main.ts"}}}',
'{"intro":"普通页面介绍"}',
])('preserves ordinary Markdown and code examples verbatim: %s', raw => {
expect(parseTeacherReply(raw)).toEqual({ reply: raw, quickReplies: [] });
});
it('preserves natural prefixes and trailing prose around a reply envelope', () => {
const raw = '🧭 先观察现象。\n```json\n' + JSON.stringify(envelope) + '\n```\n\n再比较两种感受。';
expect(parseTeacherReply(raw)).toEqual({
reply: '🧭 先观察现象。\n\n' + envelope.reply + '\n\n再比较两种感受。',
quickReplies: envelope.quickReplies,
});
expect(parseTeacherReply('提示:' + JSON.stringify(envelope)).reply)
.toBe('提示:\n\n' + envelope.reply);
});
it('supports legacy intro/questions without enforcing writing style or old counts', () => {
const intro = '云端指定前缀:' + '完整解释。'.repeat(200);
const questions = ['我需要更多例子', '请给出完整方法。'.repeat(40), '自由写下想法', '继续'];
expect(parseTeacherReply(JSON.stringify({ intro, questions })))
.toEqual({ reply: intro, quickReplies: questions });
expect(parseTeacherReply(JSON.stringify({ intro: '随时继续。', questions: [] })))
.toEqual({ reply: '随时继续。', quickReplies: [] });
expect(parseTeacherReply(JSON.stringify({ intro: '一个入口。', questions: ['继续'] })))
.toEqual({ reply: '一个入口。', quickReplies: ['继续'] });
});
it('keeps four historical shortcuts intact when decoding either envelope format', () => {
const reply = '这些是之前留下的讨论入口。';
for (const historical of [
{ reply, quickReplies: fourHistoricalReplies },
{ intro: reply, questions: fourHistoricalReplies },
]) {
expect(parseTeacherReply(JSON.stringify(historical)))
.toEqual({ reply, quickReplies: fourHistoricalReplies });
}
});
it('preserves long ordinary and structured replies without the old 12000 character truncation', () => {
const reply = '🍏 云端前缀\n\n' + '这是一段完整的详细解释。'.repeat(10000);
expect(parseTeacherReply(reply)).toEqual({ reply, quickReplies: [] });
expect(parseTeacherReply(JSON.stringify({ reply, quickReplies: [] })))
.toEqual({ reply, quickReplies: [] });
});
it.each([null, 13, '旧结构', { kind: 'unknown', broken: true }, { kind: 'flow', edges: [{ to: 'missing' }] }])(
'ignores any valid-JSON legacy tool value: %j', tool => {
expect(parseTeacherReply(JSON.stringify({ ...envelope, tool }))).toEqual(envelope);
}
);
it('projects only text fields, never execution metadata or hostile object keys', () => {
const parsed = parseTeacherReply('{"reply":"正文保留。","quickReplies":["继续"],"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}},"tool":{"kind":"html","html":"<script>run()</script>"}}');
expect(parsed).toEqual({ reply: '正文保留。', quickReplies: ['继续'] });
expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false);
expect(JSON.stringify(parsed)).not.toContain('script');
});
it.each([
'{"reply":"完整说明。","quickReplies":["继续","更多例子"],"tool":{"kind":"flow",',
'```json\n{"reply":"完整说明。","quickReplies":["继续","更多例子"],"tool":',
'{"quickReplies":["继续","更多例子"],"tool":{"nodes":[]},"reply":"完整说明。",',
'{"intro":"完整说明。","questions":["继续","更多例子"],"tool":[',
])('recovers complete text and suggestions before truncated legacy data: %s', raw => {
expect(parseTeacherReply(raw)).toMatchObject({
reply: '完整说明。', quickReplies: ['继续', '更多例子'], parseError: expect.any(String),
});
expect(Object.keys(parseTeacherReply(raw)).sort()).toEqual(['parseError', 'quickReplies', 'reply']);
});
it('recovers JSON escaped text and keeps only complete suggestion arrays', () => {
const reply = '他说:“保留 \\ 反斜杠。”\n正文含有 {"tool": "只是文字"}。';
const parsed = parseTeacherReply('{"reply":' + JSON.stringify(reply) + ',"quickReplies":["还没有结束');
expect(parsed).toMatchObject({ reply, quickReplies: [], parseError: expect.any(String) });
});
it.each([
'{"reply":"不完整正文',
'{"tool":{"kind":"flow","reply":"nested-secret"},',
'{"tool":{"kind":"ideas","items":[]}}',
'```makelore-teacher-discussion\n{"tool":{"secret":"raw-data"}}',
'```makelore-teacher-discussion\ninvalid-envelope\n```',
'{"reply":13,"tool":{"kind":"html"}}',
'{reply:"不合法的协议",tool:{kind:"ideas"}}',
])('does not expose malformed envelopes or component-only data: %s', raw => {
const parsed = parseTeacherReply(raw);
expect(parsed.parseError).toBeTruthy();
expect(parsed.reply).not.toMatch(/\{|```|nested-secret|raw-data|invalid-envelope/);
expect(parsed.reply).toBeTruthy();
expect(parsed.quickReplies).toEqual([]);
});
it('marks multiple envelopes or duplicate fields as ambiguous without showing legacy JSON', () => {
const parsed = parseTeacherReply(JSON.stringify(envelope) + '\n{"reply":"第二份","tool":{"kind":"ideas"}}');
expect(parsed).toMatchObject({ incomplete: true, quickReplies: [], parseError: expect.any(String) });
expect(parsed.reply).not.toContain('tool');
expect(parseTeacherReply('{"reply":"第一份","reply":"第二份"}').parseError).toBeTruthy();
});
it('recovers the complete screenshot reply around internal unescaped quotes', () => {
const reply = '它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。';
const raw = '{"reply":"' + reply + '","quickReplies":[],"tool":{"kind":"structure","nodes":[{"id":"core","label":"旧结构"}]}}';
expect(parseTeacherReply(raw)).toEqual({ reply, quickReplies: [], recovered: true });
});
it.each([
'他说"再看一下",然后按 "继续"。',
'第一行\n第二行\r\n制表符\t与 "引号" 一起保留。',
'示例代码:\n```js\nconst text = "hello";\nif (ready) { show(text); }\n```\n然后继续说明。',
])('repairs only prose escaping without rewriting its content: %s', reply => {
expect(parseTeacherReply('{"reply":"' + reply + '","quickReplies":["继续"]}'))
.toEqual({ reply, quickReplies: ['继续'], recovered: true });
});
it('leaves correctly encoded quotes, backslashes, code and Unicode untouched', () => {
const reply = '它早就不是"刚搭好架子"了。\\路径\\\n```json\n{"reply":"代码例子"}\n```\n🍎 继续。';
expect(parseTeacherReply(JSON.stringify({ reply, quickReplies: [] })))
.toEqual({ reply, quickReplies: [] });
});
it('does not label a quoted prefix or a genuinely interrupted string as complete', () => {
for (const raw of [
'{"reply":"它早就不是"刚搭好架子',
'{"reply":"它早就不是"刚搭好架子"那种阶段了',
'{"reply":"它早就不是"',
'{"reply":"半句", ordinary words are still part of broken prose',
'{"reply":"引号 \\q 无效","quickReplies":[]}',
]) {
expect(parseTeacherReply(raw)).toMatchObject({ incomplete: true, quickReplies: [], parseError: expect.any(String) });
expect(parseTeacherReply(raw).reply).not.toMatch(/它早就不是|半句|ordinary words|无效/);
}
});
it('keeps complete prose when suggestions fail without swallowing their bytes', () => {
for (const tail of ['["断在按钮里', 'null}', '[13]}', '["坏" }']) {
const parsed = parseTeacherReply('{"reply":"完整回答,包含 \\"正确引号\\"。","quickReplies":' + tail);
expect(parsed).toMatchObject({ reply: '完整回答,包含 "正确引号"。', quickReplies: [], parseError: expect.any(String) });
expect(parsed.incomplete).toBeUndefined();
expect(parsed.reply).not.toContain('quickReplies');
}
});
it('refuses to guess across ambiguous prose/auxiliary boundaries or duplicate bodies', () => {
const raw = '{"reply":"代码里有 "text", "tool": {"reply":"不能冒充正文"} 后面还有正文。","quickReplies":[]}';
expect(parseTeacherReply(raw)).toMatchObject({ incomplete: true });
expect(parseTeacherReply('{"reply":"第一句","reply":"第二句"}')).toMatchObject({ incomplete: true });
expect(parseTeacherReply(raw).reply).not.toContain('不能冒充正文');
});
it.each([
'{"reply":"正文保留。"},"tool":{"kind":"ideas","items":[]}',
'{"reply":"正文保留。"}}',
])('does not append malformed legacy envelope tails as prose: %s', raw => {
expect(parseTeacherReply(raw)).toMatchObject({
reply: '正文保留。', quickReplies: [], parseError: expect.any(String),
});
});
it('preserves a valid reply while reporting invalid suggestion types', () => {
for (const quickReplies of [null, {}, ['继续', 13], [''], [' ']]) {
expect(parseTeacherReply(JSON.stringify({ reply: '完整正文', quickReplies })))
.toMatchObject({ reply: '完整正文', quickReplies: [], parseError: expect.any(String) });
}
});
it('makes resource failures explicit instead of silently truncating content', () => {
const huge = '中'.repeat(Math.floor(MAX_TEACHER_REPLY_BYTES / 3) + 1);
expect(huge.length).toBeLessThan(MAX_TEACHER_REPLY_BYTES);
const parsed = parseTeacherReply(huge);
expect(parsed.parseError).toContain('数据大小');
expect(parsed.reply).not.toBe(huge.slice(0, 12000));
for (const quickReplies of [
Array.from({ length: MAX_TEACHER_QUICK_REPLIES + 1 }, () => '继续'),
['字'.repeat(Math.floor(MAX_TEACHER_QUICK_REPLY_BYTES / 3) + 1)],
]) {
expect(parseTeacherReply(JSON.stringify({ reply: '正文保留', quickReplies })))
.toMatchObject({ reply: '正文保留', quickReplies: [], parseError: expect.stringContaining('数据大小') });
}
});
it('handles deeply nested ignored legacy data without recursion', () => {
const raw = '{"reply":"正文保留。","tool":' + '['.repeat(12000) + '0' + ']'.repeat(12000) + '}';
expect(parseTeacherReply(raw)).toEqual({ reply: '正文保留。', quickReplies: [] });
});
});
describe('Main reply application', () => {
function request(): TeacherRequest {
return {
id: 'request', presentation: 'reply-v1', text: '继续', references: [],
createdAt: 'now', sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now',
includedSourceMessageIds: [], omittedMessages: 0, status: 'running', response: '',
discussionSnapshot: { kind: 'ideas', title: '历史记录' },
};
}
it('stores parsing errors and exact raw data while leaving archived components untouched', () => {
const current = request();
const raw = '{"reply":"保留完整正文。","quickReplies":["继续"],"tool":';
applyTeacherReply(current, raw);
expect(current.response).toBe('保留完整正文。');
expect(current.suggestedQuestions).toEqual(['继续']);
expect(current.replyParseError).toBeTruthy();
expect(current.unparsedResponse).toBe(raw);
expect(current.discussionSnapshot).toEqual({ kind: 'ideas', title: '历史记录' });
});
it('clears a previous parsing failure after a valid reply without retaining tool data', () => {
const current = request();
applyTeacherReply(current, '{"reply":');
applyTeacherReply(current, '{"reply":"完成。","quickReplies":[],"tool":{"kind":"ideas"}}');
expect(current.response).toBe('完成。');
expect(current.replyParseError).toBeUndefined();
expect(current.unparsedResponse).toBeUndefined();
expect(current.discussionSnapshot).toEqual({ kind: 'ideas', title: '历史记录' });
});
it.each([
{ quickReplies: [] },
{ quickReplies: ['看懂规则|摸头会改变什么?'] },
{ quickReplies: ['看懂规则|摸头会改变什么?', '看懂规则|摸头会改变什么?', '换个话题|我想聊别的地方。'] },
])('applies zero to three new shortcuts without stripping prefixes or deduplicating: $quickReplies', ({ quickReplies }) => {
const current = request();
current.replyParseError = '之前的格式错误';
current.unparsedResponse = '之前的原始输出';
applyTeacherReply(current, JSON.stringify({ reply: '先看看摸头的作用。', quickReplies }));
expect(current.response).toBe('先看看摸头的作用。');
expect(current.suggestedQuestions).toEqual(quickReplies);
expect(current.replyParseError).toBeUndefined();
expect(current.unparsedResponse).toBeUndefined();
});
it.each(['current', 'legacy'] as const)('rejects four newly generated shortcuts in a %s envelope without losing the reply or raw output', format => {
const current = request();
current.suggestedQuestions = ['上一轮的旧选项'];
const reply = '保留这一轮的完整正文。';
const envelope = format === 'current'
? { reply, quickReplies: fourHistoricalReplies }
: { intro: reply, questions: fourHistoricalReplies };
const raw = ' \n```json\n' + JSON.stringify(envelope, null, 2) + '\n```\n ';
expect(parseTeacherReply(raw)).toEqual({ reply, quickReplies: fourHistoricalReplies });
applyTeacherReply(current, raw);
expect(current.response).toBe(reply);
expect(current.suggestedQuestions).toEqual([]);
expect(current.replyParseError).toBeTruthy();
expect(current.unparsedResponse).toBe(raw);
expect(current.discussionSnapshot).toEqual({ kind: 'ideas', title: '历史记录' });
});
it('limits instructions to the text/suggestions shape and defers writing choices to cloud configuration', () => {
const instructions = teacherReplyInstructions();
expect(instructions).toContain('{"reply":string,"quickReplies":string[]}');
expect(instructions).toContain('云端配置');
expect(instructions).toMatch(/最多\s*3\s*条|0[–-]3\s*条/);
expect(instructions).toContain('空数组');
expect(instructions).not.toMatch(/Alice|120|400|2–3|学生口吻|kind|tool|想法板|流程图|整理想法|理解关系|承接|共识|已确认内容|待定想法/);
});
});