From 54a40d2636d83d2e3b63620dcdefebffbbf846df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Mon, 28 Sep 2026 14:38:58 +0800 Subject: [PATCH] fix: keep teacher runtime prompts within interface boundary --- ...0928-teacher-protocol-boundary-f2c8a601.md | 62 ++++++++++++++++++ README.md | 6 +- electron/coding-teacher/context.ts | 10 +-- electron/coding-teacher/reply.ts | 10 ++- src/pages/Chat/TeacherChatPanel.tsx | 1 + tests/e2e/pi-coding-first-chat.spec.ts | 65 ++++++++++++++++++- tests/unit/coding-teacher-cloud.test.ts | 4 +- tests/unit/coding-teacher-ui.test.tsx | 39 +++++++++++ tests/unit/coding-teacher.test.ts | 48 ++++++++++---- tests/unit/teacher-guidance.test.ts | 9 +-- tests/unit/teacher-reply-history.test.ts | 34 ++++++++++ tests/unit/teacher-reply.test.ts | 57 +++++++++++++++- 12 files changed, 313 insertions(+), 32 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md b/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md new file mode 100644 index 00000000..601f1778 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md @@ -0,0 +1,62 @@ +# Task: Keep teacher runtime instructions scoped to entry facts and UI protocol + +## Identity + +- Task ID: 20260928-teacher-protocol-boundary-f2c8a601 +- Mode: Feature +- Branch: codex/20260928-teacher-protocol-boundary-f2c8a601-teacher-protocol-boundary +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-teacher-protocol-boundary-f2c8a601 +- Base commit: 998796d77cba146ae2a007cbebfdaa1255a3413e +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Continue the accepted teacher/client boundary on top of completed legacy-component cleanup commit `998796d77cba146ae2a007cbebfdaa1255a3413e`. +- Keep entry markers factual and the reply protocol technical. Remove locally appended teaching strategy from active help, guided help, background checks, and reply-format instructions. +- Enforce the current 0–3 shortcut capacity for newly generated answers while preserving archived replies and exact diagnostics. +- Update focused tests and README; preserve card styling and existing interaction/scheduling machinery. + +## Intent And Constraints + +- User assigned main merge and push to another conversation. This task stays in its isolated feature branch and prepares a tested commit for integration. +- Cloud configuration owns Alice's teaching purpose, focus, tone, length, and shortcut prefixes; the host owns invocation facts, available context/tools, and interface protocol. +- Keep old component cleanup intact. Do not merge the older `89c913b` patch wholesale because it modifies a superseded protocol. +- No cloud configuration publishing, live app restart, scheduler redesign, or teacher-to-operator consensus handoff in this task. +- Gate passed before edits: task ownership/status verified; entry, task-relevant memory, and peer scopes reviewed. Relevant canonical documents are unchanged between the previously read baseline and `998796d`; shared memory remains read-only. + +## Outcome + +- Active-help and guided-help messages now identify the student's entry intent; background checks identify a program-triggered check without a fabricated student question. They no longer mandate a teaching strategy. +- Reply instructions now declare only the two fields, Markdown body support, optional 0–3 nonempty shortcut strings, and delegation of content/style to published configuration. +- Main rejects excess newly generated shortcuts without silently truncating or rewriting them: prose is retained, cards are withheld, and the exact raw answer and format diagnostic are saved. 0/1/3-card replies preserve prefixes and duplicates. The shared compatibility parser and historical store remain unchanged. +- The UI now shows recorded reply-format errors beside the preserved answer; raw content stays collapsed and inert. Existing colors, layout, click handling and drafts are unchanged. +- Regression coverage includes local/cloud service persistence, history read/list/save, all entry intents, current/legacy envelopes, and UI behavior. README reflects the responsibility boundary and current capacity. +- No merge, push, running-app restart, cloud config change, or canonical memory edit performed. + +## Verification + +- Dependency installation: pinned pnpm 10.33.4, frozen lockfile, offline cache; no tracked dependency change. +- Focused final suite: 269/269 passed across teacher-guidance, teacher-reply, teacher-reply-history, coding-teacher, coding-teacher-cloud, coding-teacher-model and coding-teacher-ui. +- `corepack pnpm run typecheck`: passed. +- ESLint for all changed TypeScript/TSX files, including the Electron E2E spec: passed. +- `corepack pnpm run build:vite`: passed after the final UI change. +- `git diff --check` and task-aware project-doc drift check: passed. +- Independent read-only review found no remaining teaching-policy directives in the scoped runtime files and confirmed lossless historical handling. +- Added a focused Electron E2E case for visible format errors, absent rejected shortcut buttons, collapsed exact raw text, and preserved drafts. Playwright test collection passed using an existing Electron distribution override; the native test was not executed to avoid launching another app window during concurrent work. Component/service assertions covering these behaviors passed. +- Real published-model responses were not tested. Existing topics retain their pinned configuration versions; code tests do not establish that an updated cloud prompt is active. + +## Follow-ups + +- Integrate this commit after the legacy-component cleanup; merging/pushing belongs to the user's other conversation. +- Validate actual Alice output with a new topic using the published cloud version. Local tests cannot prove model behavior or that existing pinned topics picked up a new prompt. +- Three-operation-round checks and shared confirmed context remain separate work. + +## Promotion Candidates + +- Target: canonical teacher decision/current-state documents during integration. + - Proposal: runtime help/check-in markers convey entry facts only; teaching strategy is entirely in the selected published agent configuration. Current newly generated replies accept 0–3 shortcuts; historical card counts remain lossless. + - Evidence: context/reply code and focused tests in this task; user's accepted client/agent boundary in this conversation. + - Future impact: future personas can use the same interface without inheriting Alice's teaching policy; prompt changes are evaluated against the actual published topic version. + - Semantic conflicts: supersedes cleanup task's remaining locally mandated single discussion focus and unrestricted new-card count; retains historical compatibility and UI cleanup. + - Human confirmation: not additionally required for these already accepted boundaries; no canonical edits in this feature task. diff --git a/README.md b/README.md index 4aa2b4f1..b291f5eb 100644 --- a/README.md +++ b/README.md @@ -172,10 +172,10 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 - 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 -- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史推荐一个具体切入点,并生成围绕它的快捷回复。点击快捷回复会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 +- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:点击后程序说明学生主动求助且尚未提出具体问题;“我也说不清,你带我看看”说明学生暂时说不清想问什么。智能体结合可用上下文,按云端配置回应。点击快捷回复会主动开始讨论。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 -- “帮我看看”和引导开口都由老师结合项目推荐一个具体切入点,快捷回复围绕这个切入点帮助学生接话;客户端不要求多个独立话题,也不固定正文长度、选项数量、前缀或表达风格。老师继续依据已配置职责帮助孩子整理想法、理解关系和承接已确认的共识,区分建议与已确认内容。 -- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。资源上限只用于防护,不做旧式字数截断;格式错误保留可读正文及原始回答,原文默认折叠、字面显示,不再次送进模型。 +- 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在“帮我看看”、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。 +- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。新输出超过 3 条快捷回复时保留完整正文、提示格式错误并保留原始回答,不展示超额卡片或静默截取;历史中已有的更多卡片在读取与后续保存时仍原样保留。独立资源上限只用于防护,不做旧式字数截断;格式错误的原文默认折叠、字面显示,不再次送进模型。 - 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。 - 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。 - 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。 diff --git a/electron/coding-teacher/context.ts b/electron/coding-teacher/context.ts index bc90a65c..3f94c81c 100644 --- a/electron/coding-teacher/context.ts +++ b/electron/coding-teacher/context.ts @@ -112,7 +112,7 @@ export function compileTeacherContext( const current: TeacherModelMessage = { role: intent === 'check-in' ? 'system' : 'user', content: intent === 'check-in' - ? '本轮是一次项目进展提醒,不是用户提问。按照已配置的人设和职责,结合来源操作对话中已完成的文字和咨询历史回应,表达方式遵循已配置的要求,不要求用户立即回答。仅依据已有证据,不重复上次提醒,不声称实际运行或试玩过作品。' + ? '本轮入口:程序触发的项目进展检查,学生没有在本轮主动提问。回应方式遵循当前智能体的云端配置。' : [ ...references.map( (ref) => @@ -122,10 +122,10 @@ export function compileTeacherContext( ref.text ), '当前问题:\n' + question, - ...(intent === 'suggestions' || intent === 'guided-help' - ? [ - '本轮学生希望你帮忙找到交流起点。依据当前项目、来源操作对话和咨询历史,推荐一个具体切入点,并说明为何值得从这里聊;快捷回复围绕这个切入点帮助学生接话。没有可用上下文时,坦诚从构思切入,不假定学生已完成功能,不编造作品表现或项目进展。', - ] + ...(intent === 'suggestions' + ? ['本轮入口:学生通过“帮我看看”主动请求帮助,尚未提出具体问题。'] + : intent === 'guided-help' + ? ['本轮入口:学生主动求助,表示暂时说不清想问什么。'] : []), ].join('\n\n'), }; diff --git a/electron/coding-teacher/reply.ts b/electron/coding-teacher/reply.ts index 0356e4f8..44e25b38 100644 --- a/electron/coding-teacher/reply.ts +++ b/electron/coding-teacher/reply.ts @@ -1,13 +1,21 @@ import type { TeacherRequest } from '../../shared/coding-teacher'; import { parseTeacherReply } from '../../shared/teacher-reply'; +const MAX_CURRENT_QUICK_REPLIES = 3; + export function teacherReplyInstructions(): string { - return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。'; + return `本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的快捷回复,最多 ${MAX_CURRENT_QUICK_REPLIES} 条,每条为非空字符串,没有快捷回复时用空数组。只使用这两个字段。回复的内容、语言、风格、长度和前缀遵循当前智能体的云端配置。`; } /** The original response remains available for diagnosis when parsing is incomplete. */ export function applyTeacherReply(request: TeacherRequest, raw: string): void { const parsed = parseTeacherReply(raw); + // Apply the current UI contract only to new replies; archived replies keep + // their original cards through the compatibility parser and topic store. + if (parsed.quickReplies.length > MAX_CURRENT_QUICK_REPLIES) { + parsed.quickReplies = []; + parsed.parseError ??= '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。'; + } request.response = parsed.reply; request.suggestedQuestions = parsed.quickReplies; if (parsed.parseError) { diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index 2b4b5678..ff88a4f3 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -386,6 +386,7 @@ export function TeacherChatPanel({ {request.intent !== 'suggestions' && request.status === 'completed' && Boolean(request.suggestedQuestions?.length) &&
{request.suggestedQuestions?.map((question, index) => )}
} + {request.replyParseError &&

{request.replyParseError}

} {request.unparsedResponse &&
查看收到的原始内容
{request.unparsedResponse}
diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index ff2c6257..dcfae0dc 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -20,6 +20,13 @@ type HostConnection = { token: string; }; +type ConsultationReply = { + response: string; + suggestedQuestions: string[]; + replyParseError?: string; + unparsedResponse?: string; +}; + async function disableCodingEventSource(page: Page): Promise { await page.addInitScript(() => { const sources = new Set(); @@ -88,9 +95,10 @@ async function installCodingFirstChatHost( managedCapabilities = false, removedModel = false, audioPreview?: { executionId: string; path: string; dataUrl: string }, + consultationReply?: ConsultationReply, ): Promise { await electronApp.evaluate(async (_, payload) => { - const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload; + const { connection, featureComplete, managedCapabilities, removedModel, audioPreview, consultationReply } = payload; const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); type MainState = { captured: CapturedRequest[]; @@ -553,7 +561,7 @@ async function installCodingFirstChatHost( if (path.endsWith(`${topicBase}/${topicId}/messages`) && method === 'POST') { const suggestionRequest = body!.intent === 'suggestions'; const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。'; - consultationTopics[consultationPath]={...currentTopic,revision:Number(currentTopic?.revision??0)+1,requests:[...(currentTopic?.requests as unknown[]??[]),{id:body!.requestId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response:teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]}; + consultationTopics[consultationPath]={...currentTopic,revision:Number(currentTopic?.revision??0)+1,requests:[...(currentTopic?.requests as unknown[]??[]),{id:body!.requestId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response:teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{}),...consultationReply}]}; return respond(consultationTopics[consultationPath],202); } if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic); @@ -798,7 +806,7 @@ async function installCodingFirstChatHost( if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } }); return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404); }); - }, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview }); + }, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview, consultationReply }); } test('saved Game Audio offers click-only playable local preview in Electron', async ({ launchElectronApp }, testInfo) => { @@ -1869,6 +1877,57 @@ test('project consultations preserve student drafts and switch between work and }); +test('project consultations show reply protocol errors without exposing rejected shortcuts', async ({ launchElectronApp }) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(electronApp); + const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string })); + const response = '可以先观察玩家在哪里停下来,再聊聊你想改的地方。'; + const quickReplies = ['看懂规则|怎样观察玩家?', '想想作用|我想聊聊按钮。', '试试结果|怎样看出变化?', ' **不要渲染**']; + const replyParseError = '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。'; + const unparsedResponse = '\n' + JSON.stringify({ reply: response, quickReplies }, null, 2) + '\n'; + await installCodingFirstChatHost(electronApp, connection, true, false, false, undefined, { + response, suggestedQuestions: [], replyParseError, unparsedResponse, + }); + await settleSnapshot(electronApp); + await disableCodingEventSource(page); + try { + await page.reload(); page = await getStableWindow(electronApp); + await page.getByTestId('ai-module-option-programming').click(); + await page.evaluate(() => { window.location.hash = '/chat'; }); + await expect(page.getByTestId('project-conversations')).toBeVisible(); + const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true }); + await composer.fill('保留操作草稿'); + await page.getByRole('button', { name: '与朋友聊天', exact: true }).click(); + const teacher = page.getByTestId('teacher-chat-panel'); + const teacherComposer = teacher.getByRole('textbox', { name: '向智能体提问' }); + await teacherComposer.fill('还没说完的困惑'); + await teacher.getByRole('button', { name: '帮我看看', exact: true }).click(); + const reply = teacher.locator('.consultation-messages > div').filter({ has: page.getByText(response, { exact: true }) }); + await expect(reply.getByText(response, { exact: true })).toBeVisible(); + await expect(reply.getByRole('status')).toHaveText(replyParseError); + await expect(reply.getByRole('status')).toBeVisible(); + await expect(teacher.locator('.consultation-follow-up')).toHaveCount(0); + for (const question of quickReplies) { + await expect(teacher.getByRole('button', { name: question, exact: true })).toHaveCount(0); + } + const raw = reply.locator('details').filter({ has: page.getByText('查看收到的原始内容', { exact: true }) }); + await expect(raw).not.toHaveAttribute('open'); + await expect(raw.locator('pre')).toBeHidden(); + await raw.locator('summary').click(); + await expect(raw.locator('pre')).toBeVisible(); + expect(await raw.locator('pre').textContent()).toBe(unparsedResponse); + await expect(raw.locator('button, strong')).toHaveCount(0); + await expect(teacherComposer).toHaveValue('还没说完的困惑'); + await expect(composer).toHaveValue('保留操作草稿'); + const requests = (await readState(electronApp)).captured; + expect(requests.filter(item => item.path.endsWith('/messages') && item.method === 'POST')).toHaveLength(1); + expect(requests.filter(item => item.path.endsWith('/prompt') && item.method === 'POST')).toHaveLength(0); + } finally { + await releaseSnapshot(electronApp); + } +}); + + test('settled prompt releases the composer without a client receipt and keeps the next draft', async ({ launchElectronApp }) => { const electronApp = await launchElectronApp({ skipSetup: true }); let page = await getStableWindow(electronApp); diff --git a/tests/unit/coding-teacher-cloud.test.ts b/tests/unit/coding-teacher-cloud.test.ts index 169ab69f..c6591405 100644 --- a/tests/unit/coding-teacher-cloud.test.ts +++ b/tests/unit/coding-teacher-cloud.test.ts @@ -407,8 +407,8 @@ it('submits a nonempty proactive check-in without inventing a user message', asy f.access.source.messages = []; const { body, compiled } = await submitCompiledContext(f, 'check-in'); expect(compiled.messages.map(message => message.role)).toEqual(['system', 'system']); - expect(compiled.messages.at(-1)?.content).toContain('本轮是一次项目进展提醒,不是用户提问'); - expect(body.query).toContain('仅依据已有证据'); + expect(compiled.messages.at(-1)?.content).toContain('程序触发的项目进展检查,学生没有在本轮主动提问'); + expect(body.query).toContain('回应方式遵循当前智能体的云端配置'); }); it('gives a server-defined friend the same scoped read tools', async () => { diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx index d19f5cec..e4a7a500 100644 --- a/tests/unit/coding-teacher-ui.test.tsx +++ b/tests/unit/coding-teacher-ui.test.tsx @@ -158,6 +158,29 @@ describe('teacher side chat', () => { expect(details?.querySelector('script')).toBeNull(); }); + it('shows the format error alongside the preserved reply while keeping excess shortcuts noninteractive', async () => { + const response = '先看看摸头会带来什么变化。'; + const quickReplies = ['看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头。', '试试结果|怎样看出变化?', '换个话题|我想聊别的地方。']; + const replyParseError = '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。'; + const raw = '\n' + JSON.stringify({ reply: response, quickReplies }, null, 2) + '\n'; + api.read.mockResolvedValue({ ...first, requests: [suggestionsRequest({ + presentation: 'reply-v1', response, suggestedQuestions: [], replyParseError, unparsedResponse: raw, + })] }); + render(); + await ready(); + + expect(screen.getByText(response)).toBeVisible(); + expect(screen.getByText(replyParseError)).toBeVisible(); + for (const shortcut of quickReplies) { + expect(screen.queryByRole('button', { name: shortcut, exact: true })).not.toBeInTheDocument(); + } + const details = screen.getByText('查看收到的原始内容').closest('details'); + expect(details).not.toHaveAttribute('open'); + expect(details?.querySelector('pre')?.textContent).toBe(raw); + expect(screen.getByLabelText('向智能体提问')).toBeEnabled(); + expect(api.send).not.toHaveBeenCalled(); + }); + it('renders formulas and preserves code escapes while rejecting executable links and HTML', async () => { api.read.mockResolvedValue({ ...first, requests: [request({ response: '$E=mc^2$\n\n```js\nconst value = "\\n";\n```\n\n[危险](javascript:alert)\n\n\n\n![本地图片](file:///C:/secret.png)\n\n![坏图片](https://example.com/missing.png)', @@ -591,6 +614,22 @@ describe('teacher side chat', () => { describe('legacy discussion history compatibility', () => { + it('shows every historical shortcut even when the old reply has more than three', async () => { + const shortcuts = ['看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头。', '试试结果|怎样看出变化?', '换个话题|我想聊别的地方。']; + api.read.mockResolvedValue({ ...first, requests: [request({ + presentation: 'discussion-v1', response: '这些是之前留下的讨论入口。', suggestedQuestions: shortcuts, + })] }); + render(); + await ready(); + + expect(screen.getByText('这些是之前留下的讨论入口。')).toBeVisible(); + for (const shortcut of shortcuts) { + expect(screen.getByRole('button', { name: shortcut, exact: true })).toBeEnabled(); + } + expect(screen.queryByText('查看收到的原始内容')).not.toBeInTheDocument(); + expect(api.send).not.toHaveBeenCalled(); + }); + const contents = [ { kind: 'ideas', title: '旧想法组件', items: [{ id: 'dog', text: '养只小狗', state: 'kept' }] }, { kind: 'structure', title: '旧结构组件', nodes: [{ id: 'dog', label: '照顾小狗' }] }, diff --git a/tests/unit/coding-teacher.test.ts b/tests/unit/coding-teacher.test.ts index 6d3a7b21..b4aac2ea 100644 --- a/tests/unit/coding-teacher.test.ts +++ b/tests/unit/coding-teacher.test.ts @@ -625,6 +625,28 @@ describe('teacher contextual discussion entry points', () => { }); }); + it.each([false, true])('persists prose and exact raw diagnostics instead of excess new shortcuts (cloud=%s)', async (cloudTeacher) => { + const f = await fixture(cloudTeacher ? { cloudTeacher: true, mockCloud: true } : {}); + const scope = newScope(f); + const topic = await f.service.create(scope); + const reply = '我们先看看规则会怎样影响结局。'; + const quickReplies = ['看懂规则|现在是什么规则?', '想想影响|摸头会改变什么?', '试试结果|怎样验证?', '换个话题|先聊别的。']; + const raw = JSON.stringify({ reply, quickReplies }, null, 2); + f.replyWith(raw); + await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '帮我看看' }); + const completed = await finishRequest(f, scope, topic.id); + expect(completed.requests[0]).toMatchObject({ + status: 'completed', response: reply, suggestedQuestions: [], + replyParseError: expect.stringContaining('最多 3 条'), unparsedResponse: raw, + }); + const disk = JSON.parse(await readFile(path.join( + f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json' + ), 'utf8')); + expect(disk.requests[0]).toMatchObject({ + response: reply, suggestedQuestions: [], replyParseError: completed.requests[0].replyParseError, unparsedResponse: raw, + }); + }); + it('does not treat a cancelled response as valid suggestions even when its JSON is complete', async () => { const f = await fixture(); const scope = newScope(f); @@ -695,7 +717,7 @@ describe('teacher contextual discussion entry points', () => { expect(saved.requests[0].suggestedQuestions).toBeUndefined(); }); - it('adds a one-question guided opening for this round without changing the teacher system instructions', async () => { + it('marks the guided-help entry without choosing a teaching strategy or changing the system instructions', async () => { const f = await fixture(); const scope = newScope(f); const topic = await f.service.create(scope); @@ -707,7 +729,7 @@ describe('teacher contextual discussion entry points', () => { }); expect(completed.requests[0].suggestedQuestions).toEqual([]); const messages = f.run.mock.calls[0][0]; - expect(messages.at(-1).content).toContain('推荐一个具体切入点'); + expect(messages.at(-1).content).toContain('学生主动求助,表示暂时说不清想问什么'); expect(messages[0]).toEqual(compileTeacherContext(definition, context, [], '问题', [], undefined, 'question', undefined, true).messages[0]); }); @@ -752,26 +774,27 @@ describe('teacher contextual discussion entry points', () => { expect(f.run).not.toHaveBeenCalled(); }); - it('budgets the round-specific instructions and honestly starts from ideas with no source', () => { + it('budgets the entry facts even when no source conversation is available', () => { const empty = { ...context, messages: [] }; const ordinary = compileTeacherContext(definition, empty, [], '老师,帮我看看', []); const suggestions = compileTeacherContext(definition, empty, [], '老师,帮我看看', [], 8000, 'suggestions'); expect(suggestions.messages[0]).toEqual(ordinary.messages[0]); - expect(suggestions.messages.at(-1)?.content).toContain('没有可用上下文时'); - expect(suggestions.messages.at(-1)?.content).toContain('不编造'); + expect(suggestions.messages.at(-1)?.content).toContain('学生通过“帮我看看”主动请求帮助'); + expect(suggestions.messages.some(message => message.content.startsWith('来源编程会话'))).toBe(false); expect(() => compileTeacherContext(definition, empty, [], '老师,帮我看看', [], estimateTeacherTokens(ordinary.messages), 'suggestions')) .toThrow('超过上下文预算'); }); - it.each(['suggestions', 'guided-help'] as const)('recommends one concrete starting point for %s without local expression policy', (intent) => { + it.each(['suggestions', 'guided-help'] as const)('passes %s entry facts without local teaching or expression policy', (intent) => { const selected = { ...definition, system_prompt: '云端配置:正文长度随内容,选项保留【我想聊】前缀。' }; const compiled = compileTeacherContext(selected, context, [], '帮我看看', [], 8000, intent, teacherReplyInstructions()); const current = compiled.messages.at(-1)!.content; - expect(current).toContain('推荐一个具体切入点'); - expect(current).toContain('快捷回复围绕这个切入点'); + expect(current).toContain(intent === 'suggestions' + ? '学生通过“帮我看看”主动请求帮助' : '学生主动求助,表示暂时说不清想问什么'); expect(compiled.messages[0].content).toContain(selected.system_prompt); const local = compiled.messages.slice(1).map(message => message.content).join('\n'); - for (const removed of ['400 字', '120 字', '2–3', '简短中文', '学生口吻', 'Alice', '组件', '先这些']) + for (const removed of ['400 字', '120 字', '2–3', '简短中文', '学生口吻', 'Alice', '组件', '先这些', + '推荐一个具体切入点', '说明为何', '快捷回复围绕', '从构思切入', '整理想法', '理解关系', '承接已确认的共识']) expect(local).not.toContain(removed); }); @@ -819,9 +842,8 @@ describe('project teacher check-ins', () => { expect(JSON.stringify(messages)).toContain('创建计数器'); expect(JSON.stringify(messages)).toContain('变量是什么意思'); expect(messages.at(-1)).toMatchObject({ role: 'system' }); - expect(messages.at(-1).content).toContain('表达方式遵循已配置的要求'); - expect(messages.at(-1).content).toContain('按照已配置的人设和职责'); - expect(messages.at(-1).content).toContain('不声称实际运行'); + expect(messages.at(-1).content).toContain('程序触发的项目进展检查,学生没有在本轮主动提问'); + expect(messages.at(-1).content).toContain('回应方式遵循当前智能体的云端配置'); expect(f.prepareModel).toHaveBeenLastCalledWith(expect.anything(), selected.definition, expect.objectContaining({ projectPath: f.created.project.path, source: context, assertCurrent: expect.any(Function), }), { finalOnly: false }); @@ -1435,7 +1457,7 @@ describe('project consultations with selected cloud teachers', () => { expect(result.topic?.id).toBe(topic.id); expect(result.topic?.requests[0]).toMatchObject({ intent: 'check-in', sourceConversationId: f.scope.sourceId }); const messages = f.run.mock.calls[0][0] as Array<{ role: string; content: string }>; - expect(messages.some(message => message.role === 'system' && message.content.includes('不是用户提问'))).toBe(true); + expect(messages.some(message => message.role === 'system' && message.content.includes('学生没有在本轮主动提问'))).toBe(true); expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT); expect(f.prepareCloud.mock.calls[0][3]).toMatchObject({ projectPath: f.created.project.path, source: context }); expect(f.prepareModel).not.toHaveBeenCalled(); diff --git a/tests/unit/teacher-guidance.test.ts b/tests/unit/teacher-guidance.test.ts index 7b1d0a73..2af80ab4 100644 --- a/tests/unit/teacher-guidance.test.ts +++ b/tests/unit/teacher-guidance.test.ts @@ -30,13 +30,14 @@ describe('published guidance and text reply wiring', () => { expect(system).toContain('启用的教学补充'); expect(system).not.toContain('不应加载的资料'); expect(compiled.messages.map(message => message.content).join('\n')) - .not.toMatch(/你是麦洛的创作老师|Alice|最多120字|不超过400字|2–3|学生自己的口吻/); + .not.toMatch(/你是麦洛的创作老师|Alice|最多120字|不超过400字|2–3|学生自己的口吻|推荐一个具体切入点|说明为何|快捷回复围绕|整理想法|理解关系|承接已确认的共识/); expect(definition).toEqual(before); expect(compiled.messages.at(-1)?.role).toBe(intent === 'check-in' ? 'system' : 'user'); if (instructions) expect(compiled.messages.at(-2)).toEqual({ role: 'system', content: instructions }); - if (intent === 'suggestions' || intent === 'guided-help') { - expect(compiled.messages.at(-1)?.content).toContain('推荐一个具体切入点'); - } + const entry = compiled.messages.at(-1)?.content; + if (intent === 'suggestions') expect(entry).toContain('学生通过“帮我看看”主动请求帮助'); + if (intent === 'guided-help') expect(entry).toContain('学生主动求助,表示暂时说不清想问什么'); + if (intent === 'check-in') expect(entry).toContain('程序触发的项目进展检查,学生没有在本轮主动提问'); } ); diff --git a/tests/unit/teacher-reply-history.test.ts b/tests/unit/teacher-reply-history.test.ts index 42f59047..922f76a0 100644 --- a/tests/unit/teacher-reply-history.test.ts +++ b/tests/unit/teacher-reply-history.test.ts @@ -112,6 +112,40 @@ async function fixture(value: TeacherTopic) { } 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); diff --git a/tests/unit/teacher-reply.test.ts b/tests/unit/teacher-reply.test.ts index d6bae637..2b1b09e6 100644 --- a/tests/unit/teacher-reply.test.ts +++ b/tests/unit/teacher-reply.test.ts @@ -9,6 +9,13 @@ import { 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: ['继续解释', '比较不同选择'] }; @@ -66,6 +73,17 @@ describe('teacher text and suggestions transport', () => { .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: [] }); @@ -195,10 +213,47 @@ describe('Main reply application', () => { 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).not.toMatch(/Alice|120|400|2–3|学生口吻|kind|tool|想法板|流程图/); + expect(instructions).toMatch(/最多\s*3\s*条|0[–-]3\s*条/); + expect(instructions).toContain('空数组'); + expect(instructions).not.toMatch(/Alice|120|400|2–3|学生口吻|kind|tool|想法板|流程图|整理想法|理解关系|承接|共识|已确认内容|待定想法/); }); });