310 lines
16 KiB
TypeScript
310 lines
16 KiB
TypeScript
// @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|想法板|流程图|整理想法|理解关系|承接|共识|已确认内容|待定想法/);
|
||
});
|
||
});
|