// @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":""}}'); 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({ ...envelope, parseError: expect.any(String) }); expect(parsed.reply).not.toContain('tool'); expect(parseTeacherReply('{"reply":"第一份","reply":"第二份"}').parseError).toBeTruthy(); }); 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|想法板|流程图|整理想法|理解关系|承接|共识|已确认内容|待定想法/); }); });