diff --git a/.project-docs/30-worklog/tasks/20260924-agent-rendering-a8c2d419.md b/.project-docs/30-worklog/tasks/20260924-agent-rendering-a8c2d419.md index a5a6364f..553758f2 100644 --- a/.project-docs/30-worklog/tasks/20260924-agent-rendering-a8c2d419.md +++ b/.project-docs/30-worklog/tasks/20260924-agent-rendering-a8c2d419.md @@ -8,12 +8,13 @@ - Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260924-agent-rendering-a8c2d419 - Base commit: 0a8fce67a4ea6de291f82c09c17d3efe0ea7b92c - Owner: codex -- Status: Blocked +- Status: Ready for Integration ## Scope -- 修复用户截图中的咨询面板渲染:空旧草稿导致数字 0;定位序列化项目上下文进入智能体回复的问题。客户端仅修改证据确认的显示缺陷,云端事件生产问题在独立 Yuxi 任务处理。 +- 修复用户截图中的咨询面板渲染:空旧草稿导致数字 0;定位序列化项目上下文进入智能体回复的问题。云端事件生产问题在独立 Yuxi 任务处理,客户端随后按用户要求完善回答解析、富文本和工具活动展示。 - 后续用户追加“这次回复没有整理完整 / 这次整理没有完成”的截图,继续同一回复渲染任务,核对结构化回复解析;先前产品修复提交 3c68155 保留且尚未合并部署。 +- 2026-09-24 用户进一步要求参考 Yuxi 问答页面完善客户端数据适配、解析和渲染。继续本任务:区分正常 Markdown/JSON 示例与讨论协议,使用专门的正文渲染,保留解析失败的可检查原文;只投影工具身份与状态,不改变云端正文 string 合同,不以此声称找回两条已丢失回答。 ## Intent And Constraints @@ -24,7 +25,11 @@ - 已从本地历史确认异常回复含序列化上下文;Yuxi 对非 AI 消息的错误投影在关联任务修复,客户端不添加猜测式内容清洗。 - 空旧草稿条件显式转换为 Boolean,避免 references.length 为 0 时作为 React 子节点显示。保留有内容的旧草稿入口,产品流程不变;README 无需改变。 -- 追加的结构化格式错误尚未确认根因、未修改产品解析代码。当前阻塞为原始模型回答缺失;旧版客户端仍显示0不代表未部署的3c68155修复回归。 +- 两条历史格式错误的原始回答无法还原,仍不能声称找到其唯一根因。用户随后授权按 Yuxi 问答方式改进渲染,已建立受支持内容的独立回归:7种 Markdown/JSON/代码示例误判及1项纯文本渲染失败先红后绿。 +- discussion-v1 的 parser 仅识别顶层 reply/quickReplies/tool 字段或专用围栏。Markdown 链接、普通 JSON 对象/数组、嵌套 reply 数据与 JS 示例保持正文;保留原有有效 envelope、完整 reply 恢复及组件生命周期校验。 +- 新 TeacherReply 复用已有 ReactMarkdown、GFM、KaTeX 依赖,支持标题、列表、表格、代码、HTTP(S) 链接/图片与公式;代码/表格栏内滚动,图片失败显示替代说明,HTML不执行,代码中的字面转义不全局替换。链接复用现有 Main-owned 打开接口,未新增依赖。 +- 解析失败时保留可恢复正文与旧讨论卡,同时将完整 unparsedResponse 持久保存为默认折叠的字面内容。Main 的后续会话文本只取 response,不重复注入诊断副本。旧版已经覆盖的原文不能恢复。 +- 工具活动来自实际主线程 SSE typed tool events 或 Main 三项本地读取,按 run/call 合并与去重,独立折叠显示名称/状态;不复制 args、args_delta、output、error 或 reasoning。完成后尚无结果的调用显示“未收到执行结果”,不假称成功。只记录本客户端观察到的活动,不补写已过期云端事件/旧话题。completed output非string明确返回格式错误,不猜测拆包。 ## Verification @@ -37,12 +42,21 @@ - 追加截图诊断:官方 start/status 已恢复同一任务;复用未变项目记忆及113项既有scope,补读新增只读Reviewer记录,Concurrent/Planning Gates再次通过。精确匹配本地13:17的两条问题,均为discussion-v1/completed,只有固定INVALID_REPLY和INVALID_TOOL被落盘,未存模型原文;读取仅限用户报告的项目话题。 - 当前代码将 structuredText 交给 applyDiscussionReply 后覆盖 response,失败时不保存原始回答。截图两种提示并存,说明没有提取出有效reply且结构化解析/组件校验失败;单纯云端output非string或为空只会产生INVALID_REPLY,不会额外产生INVALID_TOOL。未掌握原始输出,不把格式错误、截断或解析误判中的某一种说成已确认根因。 - 现有Reviewer独立核对final-output合同:teacher request_view返回run_id,客户端随后读取run_view;get_agent_run_result.output来自同次output_message.content(Text string),checkpoint最终AI内容按字符串或text block抽取,无messages transport wrapper。structuredReply只传入完成态该字符串,字段/类型合同一致。故没有证据支持接口字段适配或猜测式parser补丁。 +- 本轮用户授权的渲染改造:同一任务 check/start/status 与 Planning Gate 再次通过;114个owner身份集合未变,补读已有Reviewer最新记录,113个peer无语义冲突。复用现有只读reviewer调查Yuxi Web MessageProcessor/MarkdownPreview与老师SSE,没有创建新智能体。 +- 红测命令:pnpm exec vitest run tests/unit/teacher-discussion.test.ts tests/unit/coding-teacher-ui.test.tsx -t 'preserves Markdown|renders assistant Markdown' --maxWorkers=1,8 failed,实际显示INVALID_REPLY/INVALID_TOOL并找不到Markdown heading。修复后全部通过。 +- 最终相关单测:pnpm exec vitest run teacher-discussion teacher-cloud-activity coding-teacher --maxWorkers=1:8 files / 287 passed;覆盖正文格式、代码转义、公式/图片、无HTML执行、工具字段投影/子线程过滤/重放、真实Main工具续接、状态落盘重读、失败原文落盘与上下文排除。 +- 最后复审发现 Yuxi 真实 tool-finished/tool-error 只带 tool_call_id,不重复名称;纠正初步合同核对中的工具名假设。3项真实shape测试先红,修复为按run/call关联既有名称并支持tool-error,再全相关287项通过。最终重新build通过,扩展Main tsc的66条输出与修复前逐字一致。此处源于实际生产者/audit测试,不是虚构兼容格式。 +- 现有独立Reviewer最后复审PASS,无剩余阻断finding;独立复跑adapter/runner两文件33项通过,确认失败原文不会经teacherHistory/read_conversation重新投影、工具终态与子线程隔离正确。本人task-aware文档漂移门禁及diff-check通过;本任务可交付集成,源码验收与线上部署验收分开。 +- Renderer typecheck、changed-file ESLint、build:vite通过;Main扩展检查 pnpm exec tsc --noEmit --composite false --incremental false -p tsconfig.node.json 仍为既有66条诊断,本次teacher模块无诊断,不宣称全量Main类型通过。构建仍有既有chunk及混合import提示。 +- MAKELORE_LAYOUT_BROWSER_CHANNEL=msedge pnpm exec playwright test tests/e2e/teacher-discussion-layout.spec.ts:12 passed;覆盖319/508px富文本、已有四类讨论卡、草稿与固定输入框,目视检查319px截图通过。真实浏览器+模拟接口,不是已安装客户端/线上模型验收。 ## Follow-ups +- 本轮实现、本地验证与最后只读复审完成,不需要再次索取旧原文来完成已授权的渲染改造。若用户补交原始回答,可另行核对原截图精确原因。 + - 部署 Yuxi 并更新客户端后才能影响用户当前安装,部署后验收一次真实提问及只读工具续接。历史异常原文保持不变。当前仅交付任务分支修复,不含合并或部署。 -- 等待用户提供13:17两次问题中任一次Yuxi原始回答(异步问题已发出);复用已授权的现有只读Reviewer完成final-output字段合同核对,没有创建新智能体,不重试已拒绝的凭据/云端事件探针。在拿到可复现原文前不猜测修改parser,也不把上轮角色过滤保证为本轮格式错误的修复。追加诊断未达Ready for Integration,不调用complete,保留同一任务所有权以便恢复。 +- 不重试已拒绝的凭据/云端事件探针。新的 parser 变更依据本轮明确复现的普通内容误判;不将上轮角色过滤或本轮渲染修复表述为已验证解决两条已丢失原文的线上错误。 ## Promotion Candidates -- None recorded. +- Target: 10-decisions/ADR-2026-09-22-coding-teacher.md、20-architecture/system-overview.md。Proposal: 记录正文Markdown渲染、明确业务协议判定、解析失败原文默认折叠留存及工具元数据独立展示。Evidence: 用户本轮明确授权、287项测试与12项布局用例。Future impact: 后续新增多模态/工具渲染应有真实类型合同,不能按正文外形猜测;不改变学生计费、版本或文件权限。Semantic conflicts: README原“不展示模型原始JSON”改为正常正文不展示协议、失败可主动展开原文,现用户授权已覆盖。Human confirmation: 本次实现无需;canonical promotion待Integration Gate。 diff --git a/README.md b/README.md index 3e9e0fb7..90070ef3 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 智能体讨论采用“上方固定整理内容、下方独立滚动对话、底部原有输入框”的布局。普通回答可带直接发送的引导问题;想法板、结构图、流程/条件图和逐项对照由同一次模型回复提供结构化数据。先邀请学生“用这个一起想”,进入后程序锁定信息结构,智能体随讨论更新同一份内容;解释问题可仅回复文字。节点点击只选择讨论焦点,对照里的“聊聊这一点”直接发问,都保留输入草稿。 - 想法板区分已留下、智能体建议和暂放内容,可由学生采纳、暂放、选择先想哪项。“把想法理一理”明确转换为智能体归纳的结构图,并可“回去补充想法”。“只聊天”暂停整理,“先这些”暂时结束并保留未确定内容,“接着改”恢复原工具;这些操作只影响智能体讨论,不创建项目分支、不执行作品修改。状态、版本和历史快照按当前账号/项目/话题保存。 -- Main 校验结构、引用和内容长度,拒绝未经学生选择的类型变更与旧版本更新。结构化回复在完整校验后一次应用;生成中显示简短处理状态,停止、失败或无效组件保留上一份内容,不展示模型原始 JSON。现有旧纯文本、主动关心、运营预览保持兼容。 +- Main 校验结构、引用和内容长度,拒绝未经学生选择的类型变更与旧版本更新。结构化回复在完整校验后一次应用;生成中显示简短处理状态,停止、失败或无效组件保留上一份内容。普通 Markdown 链接、JSON 对象/数组和代码示例保持正文,仅讨论协议的顶层字段或专用围栏进入组件解析。解析失败仍保留完整的可读 reply,并将本次原始回答保存为默认折叠、字面显示的“查看收到的原始内容”,不把它再次加入模型上下文;既有丢失原文的历史不能恢复。 +- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按运行/调用身份合并名称和状态,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。 - 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。 - 每轮格式由 Main 的对应意图协议决定;`discussion.ts` 为支持组件的请求注入唯一 `{reply, quickReplies, tool}` 协议。运营教学补充不另写字段协议或要求始终纯文字。工具内讨论保留类型、稳定 ID、未修改内容和采纳状态;暂停/未进入时 `tool:null`,没有实质变化时也可保留原内容。结构图、流程和对照目前没有独立的采纳/来源字段,待定、建议与预测只能在展示文字中明确,不能据此推导已确认共识。 - 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询历史和适用的当前组件,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。“先这些”仍仅结束本轮整理,保留各条目状态,不确认全部候选、不自动制作。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。 diff --git a/electron/coding-teacher/cloud-activity.ts b/electron/coding-teacher/cloud-activity.ts new file mode 100644 index 00000000..fc197b19 --- /dev/null +++ b/electron/coding-teacher/cloud-activity.ts @@ -0,0 +1,32 @@ +import type { TeacherToolActivity } from '../../shared/coding-teacher'; + +export interface ToolActivityUpdate { + id: string; + name?: string; + status: TeacherToolActivity['status']; +} + +function object(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; +} + +/** Project Yuxi's typed tool events; arguments, outputs and internal errors never enter the UI. */ +export function cloudToolActivity(chunk: Record, runId: string): ToolActivityUpdate | undefined { + const event = object(chunk.stream_event); + if (event?.type === 'tool_call' || event?.type === 'tool_call_delta') { + if (typeof event.tool_call_id !== 'string' || !event.tool_call_id || typeof event.name !== 'string' || !event.name) return; + return { id: runId + ':' + event.tool_call_id, name: event.name, status: 'running' }; + } + const custom = object(chunk.event); + if (chunk.status !== 'stream_event' || custom?.method !== 'tools') return; + const data = object(custom.data); + if (!data || typeof data.tool_call_id !== 'string' || !data.tool_call_id) return; + if (data.event !== 'tool-started' && data.event !== 'tool-finished' && data.event !== 'tool-error') return; + const output = object(data.output); + return { + id: runId + ':' + data.tool_call_id, + ...(typeof data.tool_name === 'string' && data.tool_name ? { name: data.tool_name } : {}), + status: data.event === 'tool-started' ? 'running' + : data.event === 'tool-error' || data.error || output?.status === 'error' || output?.status === 'failed' ? 'failed' : 'completed', + }; +} diff --git a/electron/coding-teacher/cloud-runner.ts b/electron/coding-teacher/cloud-runner.ts index d0b8cee5..e27e1ca2 100644 --- a/electron/coding-teacher/cloud-runner.ts +++ b/electron/coding-teacher/cloud-runner.ts @@ -1,6 +1,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import type { PublicUsage } from '../../shared/coding-conversation-contracts'; -import type { TeacherTopic } from '../../shared/coding-teacher'; +import type { TeacherTopic, TeacherToolActivity } from '../../shared/coding-teacher'; +import { cloudToolActivity, type ToolActivityUpdate } from './cloud-activity'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { assertTeacherAccount, @@ -163,7 +164,8 @@ export function prepareCloudTeacher( access: TeacherReadAccess, onProgress: (text: string) => void, saveRequestId: (id: string) => Promise, - transport: TeacherCloudTransport = teacherCloudTransport(account) + transport: TeacherCloudTransport = teacherCloudTransport(account), + onToolActivity: (activity: TeacherToolActivity) => void = () => undefined, ) { const tools = createTeacherReadTools(access); return { @@ -203,6 +205,17 @@ export function prepareCloudTeacher( const bounded = AbortSignal.any([signal, AbortSignal.timeout(60 * 60 * 1000)]); let questionId: string | undefined; let completed = false; + const activities = new Map(); + const reportActivity = (activity: ToolActivityUpdate) => { + const previous = activities.get(activity.id); + // Replayed starts/deltas cannot put a finished tool back into a spinner. + if (previous && (previous.status !== 'running' + || (previous.status === activity.status && (!activity.name || activity.name === previous.name)))) return; + // Finished/error events carry the call id but need not repeat its name. + const merged = { ...activity, name: activity.name ?? previous?.name ?? '' }; + activities.set(activity.id, merged); + onToolActivity(merged); + }; try { // 重启后本地快照已经丢失,先停止旧请求,再以新的问题建立读取作用域。 const previous = topic.requests.filter((item) => item.id !== requestId).at(-1); @@ -291,10 +304,13 @@ export function prepareCloudTeacher( const call = object(raw); if (typeof call.tool_call_id !== 'string' || typeof call.name !== 'string') throw new TeacherError(502, 'teacher_protocol_invalid', '智能体读取请求无效。'); + reportActivity({ id: runId + ':' + call.tool_call_id, name: call.name, status: 'running' }); + const result = await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded); results.push({ tool_call_id: call.tool_call_id, - ...(await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded)), + ...result, }); + reportActivity({ id: runId + ':' + call.tool_call_id, name: call.name, status: result.status === 'success' ? 'completed' : 'failed' }); } // POST 重试使用完全相同的结果,文件变化也不会导致重复续接或不同输入。 const resumed = await transport.json( @@ -310,7 +326,9 @@ export function prepareCloudTeacher( continue; } if (view.status === 'completed') { - const output = typeof view.output === 'string' ? view.output : ''; + if (typeof view.output !== 'string') + throw new TeacherError(502, 'teacher_protocol_invalid', '智能体返回的正文格式不受支持,请重试。'); + const output = view.output; // Structured UI replies must contain only the final answer. A cloud // run can stream a preamble or draft before reading and continuing. if (structuredReply) onText(output); @@ -349,6 +367,8 @@ export function prepareCloudTeacher( ? [payload.chunk] : []) { const chunk = object(item); + const activity = cloudToolActivity(chunk, runId); + if (activity) reportActivity(activity); const event = chunk.stream_event ? object(chunk.stream_event) : {}; if (event.type === 'message_delta' && typeof event.content === 'string') { if (typeof event.message_id === 'string' && event.message_id !== messageId) { diff --git a/electron/coding-teacher/discussion.ts b/electron/coding-teacher/discussion.ts index ae2d4532..94b08f4e 100644 --- a/electron/coding-teacher/discussion.ts +++ b/electron/coding-teacher/discussion.ts @@ -58,7 +58,11 @@ export function applyDiscussionReply(topic: TeacherTopic, request: TeacherReques const parsed = parseTeacherDiscussionReply(raw); request.response = parsed.reply; request.suggestedQuestions = parsed.quickReplies; - if (parsed.toolError) request.discussionError = parsed.toolError; + if (parsed.toolError) { + request.discussionError = parsed.toolError; + // Keep inspectable evidence instead of replacing the only copy with an error. + request.unparsedResponse = raw; + } if (!parsed.tool) return; const current = topic.discussion; if (!current) { diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index 722c015d..6aebbc1f 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -444,6 +444,14 @@ export class CodingTeacherService { }, async cloudRequestId => { topic.requests.at(-1)!.cloudRequestId = cloudRequestId; await store.save(topic); + }, undefined, activity => { + const current = topic.requests.at(-1)!; + const activities = current.toolActivity ??= []; + const index = activities.findIndex(item => item.id === activity.id); + if (index < 0) activities.push(activity); + else activities[index] = activity; + topic.revision++; + this.events.emit(key, structuredClone(topic)); }) : await (this.options.prepareModel ?? prepareTeacherModel)(account, topic.definition, access, diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 5ff6e074..33c3f1b0 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -83,6 +83,9 @@ export interface TeacherRequest { discussionContext?: TeacherDiscussionContext; discussionSnapshot?: TeacherDiscussionContent; discussionError?: string; + /** Original answer retained when discussion parsing fails; never fed back as context. */ + unparsedResponse?: string; + toolActivity?: TeacherToolActivity[]; intent?: TeacherRequestIntent; sourceConversationId?: string; @@ -105,6 +108,11 @@ export interface TeacherRequest { cloudRequestId?: string; progress?: string; } +export interface TeacherToolActivity { + id: string; + name: string; + status: 'running' | 'completed' | 'failed'; +} export interface TeacherTopic { discussion?: TeacherDiscussion; role?: LegacyConsultationRole; diff --git a/shared/teacher-discussion.ts b/shared/teacher-discussion.ts index df286cf9..4e01f4c9 100644 --- a/shared/teacher-discussion.ts +++ b/shared/teacher-discussion.ts @@ -223,8 +223,8 @@ function stringToken(raw: string, start: number): { value: string; end: number } return undefined; } -/** Recover only a complete top-level JSON string field, never nested tool data or a partial string. */ -function recoverReply(raw: string): string | undefined { +/** Inspect only top-level fields, including when a later value was truncated. */ +function findTopLevelField(raw: string, names: readonly string[]): number | undefined { if (!raw.trimStart().startsWith('{')) return undefined; let depth = 0, expectingKey = false; for (let cursor = 0; cursor < raw.length; cursor++) { @@ -232,16 +232,13 @@ function recoverReply(raw: string): string | undefined { if (character === '"') { const token = stringToken(raw, cursor); if (!token) return undefined; - if (depth === 1 && expectingKey && token.value === 'reply') { + if (depth === 1 && expectingKey && names.includes(token.value)) { let valueStart = token.end; while (/\s/.test(raw[valueStart] ?? '') && valueStart < raw.length) valueStart++; if (raw[valueStart] !== ':') return undefined; valueStart++; while (/\s/.test(raw[valueStart] ?? '') && valueStart < raw.length) valueStart++; - if (raw[valueStart] !== '"') return undefined; - const value = stringToken(raw, valueStart)?.value; - if (value?.trim() && value.length <= MAX_REPLY_LENGTH) return value.trim(); - return undefined; + return valueStart; } if (depth === 1) expectingKey = false; cursor = token.end - 1; @@ -257,6 +254,14 @@ function recoverReply(raw: string): string | undefined { return undefined; } +/** Recover a complete reply string, never nested tool data or a partial string. */ +function recoverReply(raw: string): string | undefined { + const start = findTopLevelField(raw, ['reply']); + if (start === undefined || raw[start] !== '"') return undefined; + const value = stringToken(raw, start)?.value; + return value?.trim() && value.length <= MAX_REPLY_LENGTH ? value.trim() : undefined; +} + function invalidReply(candidate: string): TeacherDiscussionReply { return { reply: recoverReply(candidate) ?? INVALID_REPLY, quickReplies: [], toolError: INVALID_TOOL }; } @@ -264,18 +269,15 @@ function invalidReply(candidate: string): TeacherDiscussionReply { /** Parse final model output. Callers keep the existing tool whenever toolError is present. */ export function parseTeacherDiscussionReply(raw: string): TeacherDiscussionReply { const trimmed = raw.trim(); - // JSON-labelled fences are transport, including truncated fences. Ordinary programming fences remain text. - const fence = /(?:^|\n)[ \t]*```(?:json|makelore-teacher(?:-discussion)?)[ \t]*(?:\r?\n|$)/i.exec(trimmed); - const bareFence = /^```[ \t]*\r?\n(?=\s*[{[])/.exec(trimmed); - const opening = fence ?? bareFence; - const unfencedEnvelope = /\{\s*"(?:reply|quickReplies|tool)"\s*:/.exec(trimmed); - const startsJson = /^[{[]/.test(trimmed); - const looksStructured = !!opening || !!unfencedEnvelope || startsJson || /^```(?:json|makelore-teacher)/i.test(trimmed); - if (!looksStructured) { - return { reply: trimmed.slice(0, MAX_REPLY_LENGTH) || INVALID_REPLY, quickReplies: [] }; - } - - let candidate = !opening && !startsJson && unfencedEnvelope ? trimmed.slice(unfencedEnvelope.index) : trimmed; + // A Markdown link, JSON example or code fence is not a discussion envelope. + // Only our explicit fence or top-level protocol fields select this parser. + const firstFence = /(?:^|\n)[ \t]*```([^\r\n]*)[ \t]*(?:\r?\n|$)/.exec(trimmed); + const language = firstFence?.[1].trim().toLowerCase(); + const explicitFence = language === 'makelore-teacher' || language === 'makelore-teacher-discussion'; + const opening = firstFence && (explicitFence || language === 'json' || language === '') ? firstFence : null; + const unfencedEnvelope = !firstFence && !trimmed.startsWith('{') + ? /(?:^|\n)[ \t]*(\{\s*"(?:reply|quickReplies|tool)"\s*:)/.exec(trimmed) : null; + let candidate = unfencedEnvelope ? trimmed.slice(unfencedEnvelope.index).trimStart() : trimmed; let validFence = true; if (opening) { const remainder = trimmed.slice(opening.index + opening[0].length); @@ -283,6 +285,9 @@ export function parseTeacherDiscussionReply(raw: string): TeacherDiscussionReply candidate = closing ? remainder.slice(0, closing.index) : remainder; validFence = !!closing && !/(?:^|\n)[ \t]*```/.test(candidate); } + if (!explicitFence && findTopLevelField(candidate, ['reply', 'quickReplies', 'tool']) === undefined) { + return { reply: trimmed.slice(0, MAX_REPLY_LENGTH) || INVALID_REPLY, quickReplies: [] }; + } if (new TextEncoder().encode(trimmed).length > MAX_INPUT_BYTES) return invalidReply(candidate.slice(0, MAX_INPUT_BYTES)); if (!validFence) return invalidReply(candidate); try { diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index 70d8033e..129b1937 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -13,6 +13,7 @@ import type { import { TeacherDiscussionPanel, DiscussionFocus } from './TeacherDiscussionPanel'; import type { TeacherDiscussionAction } from '../../../shared/coding-teacher'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; +import { TeacherReply } from './TeacherReply'; export function TeacherChatPanel({ projectId, @@ -413,11 +414,21 @@ export function TeacherChatPanel({ :

{request.text}

} {request.references.length > 0 &&
本轮引用 {request.references.length} 项{request.references.map((ref, index) =>
{ref.text}
)}
}
{definition?.name ?? label}
+ {!!request.toolActivity?.length &&
+ 使用了 {request.toolActivity.length} 项工具 +
    + {request.toolActivity.map(activity =>
  • + {activity.name === 'list_project_files' ? '查看项目文件列表' : activity.name === 'read_project_file' ? '读取项目文件' : activity.name === 'read_conversation' ? '读取会话原文' : '使用智能体工具'} + {activity.status === 'completed' ? '已完成' : activity.status === 'failed' ? '未完成' + : ['running', 'preparing'].includes(request.status) ? '进行中' : '未收到执行结果'} +
  • )} +
+
} {request.intent === 'suggestions' ? <> {['running', 'preparing'].includes(request.status) ?

我看看你最近做到了哪里…

: request.status === 'completed' && <> -

{request.response}

+
{request.suggestedQuestions?.map((question) => )}
@@ -425,11 +436,15 @@ export function TeacherChatPanel({

也可以直接在下面说说你的想法。

} {['failed', 'cancelled', 'interrupted'].includes(request.status) && } - :
{request.response || (['running', 'preparing'].includes(request.status) ? {request.discussionContext ? '正在梳理你的新想法…' : `${label}正在想…`} : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : '')}
} + : request.response ? :
{['running', 'preparing'].includes(request.status) ? {request.discussionContext ? '正在梳理你的新想法…' : `${label}正在想…`} : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} {discussionEnabled && request.intent !== 'suggestions' && request.status === 'completed' && topic.discussion?.status !== 'active' && topic.discussion?.status !== 'offered' && Boolean(request.suggestedQuestions?.length) &&
{request.suggestedQuestions?.map(question => )}
} {request.discussionError &&

{request.discussionError}

} + {request.unparsedResponse &&
+ 查看收到的原始内容 +
{request.unparsedResponse}
+
} {request.progress && ['running', 'preparing'].includes(request.status) &&

{request.progress}

} {(request.truncatedMessages ?? 0) > 0 &&

{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,智能体可按需读取原文。'}

} {request.omittedMessages > 0 &&

本轮参考了较近的讨论,省略了 {request.omittedMessages} 条较早内容。

} diff --git a/src/pages/Chat/TeacherReply.tsx b/src/pages/Chat/TeacherReply.tsx new file mode 100644 index 00000000..5b11f9c9 --- /dev/null +++ b/src/pages/Chat/TeacherReply.tsx @@ -0,0 +1,46 @@ +import { memo, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; +import { toast } from 'sonner'; +import { openConversationExternalUrl, safeConversationExternalUrl } from '@/lib/conversation-links'; + +function ReplyImage({ src, alt }: { src?: string; alt?: string }) { + const [failed, setFailed] = useState(false); + const url = safeConversationExternalUrl(src); + if (!url || failed) return [图片未能显示:{alt || '图片'}]; + return {alt setFailed(true)} />; +} + +/** Render answer text only. Tool activity and discussion data have separate, typed UI. */ +export const TeacherReply = memo(function TeacherReply({ text }: { text: string }) { + return
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children, start }) =>
    {children}
, + blockquote: ({ children }) =>
{children}
, + a: ({ href, children }) => { + const url = safeConversationExternalUrl(href); + return url ? { + event.preventDefault(); + void openConversationExternalUrl(url).catch(() => toast.error('链接无法打开')); + }}>{children} : {children}; + }, + code: ({ children, className }) => {children}, + pre: ({ children }) =>
{children}
, + table: ({ children }) =>
{children}
, + thead: ({ children }) => {children}, + th: ({ children, style }) => {children}, + td: ({ children, style }) => {children}, + img: ({ src, alt }) => , + }}>{text}
+
; +}); diff --git a/tests/e2e/fixtures/teacher-discussion-api.ts b/tests/e2e/fixtures/teacher-discussion-api.ts index 4aa4f887..98c0b9c7 100644 --- a/tests/e2e/fixtures/teacher-discussion-api.ts +++ b/tests/e2e/fixtures/teacher-discussion-api.ts @@ -11,6 +11,14 @@ const samples: Record = { const scenario = new URLSearchParams(location.search).get('kind') ?? 'ideas'; let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(scenario === 'ordinary' && i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]), ...(scenario === 'structure' ? { previousIdeas: structuredClone(samples.ideas) as Extract } : {}) } } : {}) }; const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null }); +if (scenario === 'rich-text') { + current.requests = [{ + ...current.requests[0], text: '帮我看看项目和最近的讨论', + response: '## 项目建议\n\n**先核对已有设计**,再决定下一步。\n\n- 检查入口文件\n- 保留当前玩法\n\n| 文件 | 作用 | 需要确认 |\n| --- | --- | --- |\n| src/main.ts | 初始化项目 | 游戏循环与计时 |\n| public/assets | 图片资源 | 构建后路径是否可用 |\n\n```js\nconst message = "保留字符串里的\\n,不把它变成实际换行";\nconst resourcePath = "/assets/" + "very-long-file-name-".repeat(15);\n```\n\n运动距离:$s = vt$。\n\n![项目截图](https://example.com/teacher-preview.png)\n\n[项目说明](https://example.com/readme)\n\n> 先验证观察到的现象。', + discussionError: '这次整理没有完成,先保留原来的内容。', + unparsedResponse: '{"reply":"正文已保留","tool":{"kind":"structure",', + }]; +} let timer: ReturnType | undefined; const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); }; export const teacherApi = { diff --git a/tests/e2e/fixtures/teacher-discussion-layout.tsx b/tests/e2e/fixtures/teacher-discussion-layout.tsx index 80ebcb2c..3cfddb65 100644 --- a/tests/e2e/fixtures/teacher-discussion-layout.tsx +++ b/tests/e2e/fixtures/teacher-discussion-layout.tsx @@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client'; import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel'; import '../../../src/styles/globals.css'; import '../../../src/pages/Chat/classroom-workspace.css'; +import 'katex/dist/katex.min.css'; const width = Number(new URLSearchParams(location.search).get('width')) || 508; createRoot(document.getElementById('root')!).render(
智能体讨论交互测试 · 真实页面组件 / 模拟回复
diff --git a/tests/e2e/teacher-discussion-layout.spec.ts b/tests/e2e/teacher-discussion-layout.spec.ts index 7edcbd71..7651e745 100644 --- a/tests/e2e/teacher-discussion-layout.spec.ts +++ b/tests/e2e/teacher-discussion-layout.spec.ts @@ -20,6 +20,34 @@ test.beforeAll(async () => { await server.listen(); url = server.resolvedUrls!.local[0]; }); test.afterAll(async () => { await server?.close(); }); +for (const width of [319, 508]) { + test(`rich replies keep code, tables and images inside the panel at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width: 1180, height: 800 }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('https://example.com/teacher-preview.png', route => route.fulfill({ + contentType: 'image/svg+xml', + body: 'Flybot project preview', + })); + await page.goto(`${url}?kind=rich-text&width=${width}`); + const body = page.getByTestId('teacher-reply'); + await expect(body.getByRole('heading', { name: '项目建议' })).toHaveCount(1); + await expect(body.getByRole('table')).toHaveCount(1); + await expect(body.locator('.katex')).toHaveCount(1); + await expect(body.getByRole('img', { name: '项目截图' })).toBeVisible(); + await expect.poll(() => body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + await expect.poll(() => page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + const raw = page.getByText('查看收到的原始内容'); + await expect(raw.locator('..')).not.toHaveAttribute('open'); + await raw.click(); + await expect(raw.locator('..').locator('pre')).toContainText('"tool"'); + const input = page.getByRole('textbox', { name: '向智能体提问' }); + await expect(input).toBeVisible(); + await page.locator('.consultation-messages').evaluate(el => { el.scrollTop = 0; }); + await page.screenshot({ path: test.info().outputPath(`rich-reply-${width}.png`) }); + expect(errors).toEqual([]); + }); +} for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'structure', 'flow', 'comparison']) { test(`${kind} pins the current tool and composer at ${width}px`, async ({ page }) => { await page.setViewportSize({ width: 1180, height: 800 }); diff --git a/tests/unit/coding-teacher-cloud.test.ts b/tests/unit/coding-teacher-cloud.test.ts index 95ea5d4b..8ca61e37 100644 --- a/tests/unit/coding-teacher-cloud.test.ts +++ b/tests/unit/coding-teacher-cloud.test.ts @@ -113,6 +113,44 @@ it('submits only the published persona, source evidence and current question in expect(body.local_context.tools).toEqual(['list_project_files', 'read_project_file', 'read_conversation']); }); +it.each([null, { content: 'not a supported output field' }, ['text block']])('reports unsupported final output instead of silently losing %j', async output => { + const f = await fixture(); + const text = vi.fn(); + const transport: TeacherCloudTransport = { + json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'run' } : { status: 'completed', output }), + events: vi.fn(), + }; + await expect(prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport) + .run([{ role: 'user', content: '问题' }], new AbortController().signal, text)) + .rejects.toMatchObject({ code: 'teacher_protocol_invalid' }); + expect(text).not.toHaveBeenCalled(); +}); + +it('merges typed tool activity across replay without adding tool data or child events to the answer', async () => { + const f = await fixture(); + let reads = 0; + const activity = vi.fn(), text = vi.fn(); + const transport: TeacherCloudTransport = { + json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'run' } + : ++reads === 1 ? { status: 'running', thread_id: 'main' } : { status: 'completed', output: '最终正文' }), + events: vi.fn(async (_url, _signal, accept) => { + const start = { stream_event: { type: 'tool_call', tool_call_id: 'call', name: 'search', args: { private: 'private-args' } } }; + const finish = { status: 'stream_event', event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'call', output: { status: 'success', content: 'private-result' } } } }; + accept('messages', { thread_id: 'child', payload: { items: [start] } }, '1-0'); + accept('messages', { thread_id: 'main', payload: { items: [start] } }, '2-0'); + accept('custom', { thread_id: 'main', payload: { chunk: finish } }, '3-0'); + accept('messages', { thread_id: 'main', payload: { items: [start] } }, '4-0'); + }), + }; + await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity) + .run([{ role: 'user', content: '问题' }], new AbortController().signal, text); + expect(activity.mock.calls).toEqual([ + [{ id: 'run:call', name: 'search', status: 'running' }], + [{ id: 'run:call', name: 'search', status: 'completed' }], + ]); + expect(text.mock.calls).toEqual([['最终正文']]); +}); + it('submits the active discussion protocol, current tool content and selected focus', async () => { const f = await fixture(); f.topic.definition.limits.max_input_tokens = 16000; @@ -448,6 +486,7 @@ it.each(['suggestions', 'discussion-v1'] as const)( const finalOutput = JSON.stringify(result); let firstReads = 0, resumedReads = 0; const text = vi.fn(); + const activity = vi.fn(); const transport: TeacherCloudTransport = { json: vi.fn(async (url) => { if (url === '/questions') return { request_id: requestId, run_id: 'before-read' }; @@ -481,9 +520,13 @@ it.each(['suggestions', 'discussion-v1'] as const)( }), }; await prepareCloudTeacher( - f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport + f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity ).run([{ role: 'user', content: '一起讨论' }], new AbortController().signal, text); expect(text.mock.calls).toEqual([[finalOutput]]); + expect(activity.mock.calls).toEqual([ + [{ id: 'before-read:file', name: 'read_project_file', status: 'running' }], + [{ id: 'before-read:file', name: 'read_project_file', status: 'completed' }], + ]); const response = text.mock.calls.map(([delta]) => delta).join(''); expect(format === 'suggestions' ? parseTeacherSuggestions(response) diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx index f4f3d658..dfefddbd 100644 --- a/tests/unit/coding-teacher-ui.test.tsx +++ b/tests/unit/coding-teacher-ui.test.tsx @@ -96,6 +96,19 @@ async function ready() { await waitFor(() => expect(streams.has('first')).toBe(true)); } describe('teacher side chat', () => { + it('renders assistant Markdown, tables, code and images instead of literal markup', async () => { + const response = '## 项目建议\n\n**先验证跳跃**\n\n- 保留现有玩法\n\n| 文件 | 用途 |\n| --- | --- |\n| main.ts | 入口 |\n\n```json\n{"name":"flybot"}\n```\n\n[说明](https://example.com/readme)\n\n![项目截图](https://example.com/preview.png)'; + api.read.mockResolvedValue({ ...first, requests: [request({ response })] }); + render(); + await ready(); + expect(screen.getByRole('heading', { name: '项目建议' })).toBeVisible(); + expect(screen.getByRole('table')).toHaveTextContent('main.ts'); + expect(screen.getByText('保留现有玩法').closest('li')).not.toBeNull(); + expect(screen.getByText('{"name":"flybot"}').closest('pre')).not.toBeNull(); + expect(screen.getByRole('link', { name: '说明' })).toHaveAttribute('href', 'https://example.com/readme'); + expect(screen.getByRole('img', { name: '项目截图' })).toHaveAttribute('src', 'https://example.com/preview.png'); + }); + it('does not render a zero for an empty legacy friend draft', async () => { localStorage.setItem('makelore-consultation-draft:signed-out:p:friend', JSON.stringify({ text: '', references: [] })); render(); @@ -105,6 +118,68 @@ describe('teacher side chat', () => { expect(screen.queryByText('查看旧版朋友草稿')).not.toBeInTheDocument(); }); + it('keeps malformed originals folded and literal while rendering the recovered answer', async () => { + const raw = '{"reply":"正文已保留","tool":'; + api.read.mockResolvedValue({ ...first, requests: [request({ + response: '**正文已保留**', discussionError: '这次整理没有完成,先保留原来的内容。', unparsedResponse: raw, + })] }); + render(); + await ready(); + expect(screen.getByText('正文已保留').tagName).toBe('STRONG'); + const details = screen.getByText('查看收到的原始内容').closest('details'); + expect(details).not.toHaveAttribute('open'); + expect(details?.querySelector('pre')?.textContent).toBe(raw); + expect(details?.querySelector('script')).toBeNull(); + }); + + 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)', + })] }); + render(); + await ready(); + const body = screen.getByTestId('teacher-reply'); + expect(body.querySelector('.katex')).not.toBeNull(); + expect(body.querySelector('pre')?.textContent).toContain('const value = "\\n";'); + expect(body.querySelector('script')).toBeNull(); + expect(screen.queryByRole('link', { name: '危险' })).not.toBeInTheDocument(); + expect(screen.queryByRole('img', { name: '本地图片' })).not.toBeInTheDocument(); + fireEvent.error(screen.getByRole('img', { name: '坏图片' })); + expect(screen.getByText('[图片未能显示:坏图片]')).toBeVisible(); + }); + + it('updates streamed Markdown without duplicating the final answer', async () => { + const running = { ...first, requests: [request({ status: 'running', response: '## 正在回答\n\n**先检查' })] }; + api.read.mockResolvedValue(running); + render(); + await ready(); + expect(screen.getByRole('heading', { name: '正在回答' })).toBeVisible(); + const completed = { ...running, revision: 2, requests: [request({ response: '## 正在回答\n\n**先检查文件**' })] }; + await act(async () => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(completed) }))); + expect(screen.getAllByTestId('teacher-reply')).toHaveLength(1); + expect(screen.getByText('先检查文件').tagName).toBe('STRONG'); + }); + + it('keeps tool activity separate from the answer and stops stale running indicators', async () => { + api.read.mockResolvedValue({ ...first, requests: [request({ + response: '已经看过项目文件。', + toolActivity: [ + { id: 'one', name: 'read_project_file', status: 'completed' }, + { id: 'two', name: 'read_conversation', status: 'failed' }, + { id: 'three', name: 'cloud-search', status: 'running' }, + ], + })] }); + render(); + await ready(); + const details = screen.getByText('使用了 3 项工具').closest('details')!; + expect(details).not.toHaveAttribute('open'); + expect(details).toHaveTextContent('读取项目文件已完成'); + expect(details).toHaveTextContent('读取会话原文未完成'); + expect(details).toHaveTextContent('未收到执行结果'); + expect(screen.getByTestId('teacher-reply')).toHaveTextContent('已经看过项目文件。'); + expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('读取会话原文'); + }); + it('retains failed input and retries with the same request id, then appends a reply only on click', async () => { const bringBack = vi.fn(); api.send diff --git a/tests/unit/coding-teacher.test.ts b/tests/unit/coding-teacher.test.ts index 025e688b..ade3d073 100644 --- a/tests/unit/coding-teacher.test.ts +++ b/tests/unit/coding-teacher.test.ts @@ -198,6 +198,9 @@ describe('cloud coding teacher', () => { await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed')); const saved = (await f.service.read(f.scope, topic.id)).requests[0]; expect(saved.response).toBe('计数器从 42 开始。'); + expect(saved.toolActivity).toEqual([{ id: 'parent:read-counter', name: 'read_project_file', status: 'completed' }]); + const restarted = await f.restart(); + expect((await restarted.read(f.scope, topic.id)).requests[0].toolActivity).toEqual(saved.toolActivity); expect(saved.cloudRequestId).toBe('cloud-question'); expect(JSON.stringify(requests[0].body)).toContain('const title'); expect(saved.truncatedMessages).toBeGreaterThan(0); @@ -1377,6 +1380,25 @@ describe('structured teacher service integration', () => { describe('project consultations with selected cloud teachers', () => { + it('persists malformed discussion output without putting it into future model context', async () => { + const f = await fixture(); + const scope = { ...f.scope, sourceId: 'project' }; + const selected = await f.service.create(scope); + const raw = '{"reply":"这段正文完整。","tool":{"privateDiagnostic":"only-in-original"'; + f.replyWith(raw); + await f.service.send(scope, selected.id, { + requestId: crypto.randomUUID(), text: '看看项目', presentation: 'discussion-v1', sourceConversationId: f.scope.sourceId, + }); + f.finish(); + await vi.waitFor(async () => expect((await f.service.read(scope, selected.id)).requests[0].status).toBe('completed')); + const restarted = await f.restart(); + const saved = await restarted.read(scope, selected.id); + expect(saved.requests[0]).toMatchObject({ response: '这段正文完整。', unparsedResponse: raw }); + const compiled = compileTeacherContext(saved.definition, context, saved.requests, '继续聊', []); + expect(JSON.stringify(compiled.messages)).not.toContain('only-in-original'); + expect(JSON.stringify(compiled.messages)).toContain('这段正文完整。'); + }); + it.each(['local', 'yuxi'])('keeps the published identity and project tools on the %s runtime', async (runtime) => { const f = await fixture({ cloudTeacher: runtime === 'yuxi', mockCloud: runtime === 'yuxi' }); const scope = { ...f.scope, sourceId: 'project' }; diff --git a/tests/unit/teacher-cloud-activity.test.ts b/tests/unit/teacher-cloud-activity.test.ts new file mode 100644 index 00000000..31dc36b9 --- /dev/null +++ b/tests/unit/teacher-cloud-activity.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { cloudToolActivity } from '../../electron/coding-teacher/cloud-activity'; + +describe('teacher cloud tool activity projection', () => { + it.each(['tool_call', 'tool_call_delta'])('takes only identity and state from %s', type => { + expect(cloudToolActivity({ stream_event: { + type, tool_call_id: 'call', name: 'read_project_file', args: { path: 'private.ts' }, args_delta: '{"secret":', + } }, 'run')).toEqual({ id: 'run:call', name: 'read_project_file', status: 'running' }); + }); + it.each([ + ['tool-started', undefined, undefined, 'running'], + ['tool-finished', { type: 'tool', content: '{"private":"value"}', status: 'success' }, undefined, 'completed'], + ['tool-finished', 'result text', undefined, 'completed'], + ['tool-finished', { status: 'error', content: 'internal error' }, undefined, 'failed'], + ['tool-finished', undefined, 'internal stack trace', 'failed'], + ])('projects %s without leaking tool output', (event, output, error, status) => { + expect(cloudToolActivity({ status: 'stream_event', event: { method: 'tools', data: { + event, tool_call_id: 'call', tool_name: 'read_conversation', output, error, + } } }, 'run')).toEqual({ id: 'run:call', name: 'read_conversation', status }); + }); + it('does not guess activity from answer text, reasoning or unidentified deltas', () => { + for (const chunk of [ + { stream_event: { type: 'message_delta', content: '{"tool_call_id":"call","name":"read_project_file"}' } }, + { stream_event: { type: 'message_delta', reasoning_content: 'internal reasoning' } }, + { stream_event: { type: 'tool_call_delta', index: 0, args_delta: '{}' } }, + { status: 'stream_event', event: { method: 'messages', data: { event: 'tool-started', tool_call_id: 'call', tool_name: 'test' } } }, + ]) expect(cloudToolActivity(chunk, 'run')).toBeUndefined(); + }); + it.each([ + ['tool-finished', { type: 'tool', content: 'tool result', status: 'success' }, undefined, 'completed'], + ['tool-error', undefined, 'private error', 'failed'], + ])('accepts real %s events that carry only the call id', (event, output, error, status) => { + expect(cloudToolActivity({ status: 'stream_event', event: { method: 'tools', data: { + event, tool_call_id: 'call', output, error, + } } }, 'run')).toEqual({ id: 'run:call', status }); + }); +}); diff --git a/tests/unit/teacher-discussion-state.test.ts b/tests/unit/teacher-discussion-state.test.ts index f737197a..ef529e5a 100644 --- a/tests/unit/teacher-discussion-state.test.ts +++ b/tests/unit/teacher-discussion-state.test.ts @@ -304,6 +304,22 @@ describe('applying completed teacher replies', () => { expect(turn.discussionSnapshot).toBeUndefined(); }); + it('retains the original failed answer for inspection without changing the discussion', () => { + const owner = topic(flow, 'paused'); + const before = structuredClone(owner.discussion); + const turn = request(owner); + const raw = '{"reply":"收到,先看看项目。","tool":{"kind":"flow",'; + applyDiscussionReply(owner, turn, raw); + expect(turn.response).toBe('收到,先看看项目。'); + expect(turn.unparsedResponse).toBe(raw); + expect(owner.discussion).toEqual(before); + const plainTurn = request(owner); + applyDiscussionReply(owner, plainTurn, '[项目说明](https://example.com)'); + expect(plainTurn.discussionError).toBeUndefined(); + expect(plainTurn.unparsedResponse).toBeUndefined(); + expect(owner.discussion).toEqual(before); + }); + it('keeps legacy text as a normal answer without creating a component', () => { const owner = topic(); const turn = request(owner); diff --git a/tests/unit/teacher-discussion.test.ts b/tests/unit/teacher-discussion.test.ts index bfac06cc..defe1796 100644 --- a/tests/unit/teacher-discussion.test.ts +++ b/tests/unit/teacher-discussion.test.ts @@ -176,6 +176,20 @@ describe('teacher discussion model envelope', () => { .toEqual({ reply: '柯基、冒险和菜园都是好点子。', quickReplies: [] }); }); + it.each([ + '[项目说明](https://example.com/readme)\n\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```', + '```json\n[{"id":1,"label":"入口"}]\n```', + '{"example":{"reply":"这是数据字段,不是回答协议"}}', + '```js\nconst example = {"reply":"代码示例"};\n```', + ])('preserves Markdown links and JSON examples as readable content: %s', raw => { + expect(parseTeacherDiscussionReply(raw)).toEqual({ reply: raw, quickReplies: [] }); + }); + it('distinguishes an absent tool from an explicit null and supports a tool-only envelope', () => { expect(parseTeacherDiscussionReply('{"reply":"这轮只解释一下。"}')) .toEqual({ reply: '这轮只解释一下。', quickReplies: [] }); @@ -213,8 +227,7 @@ describe('teacher discussion model envelope', () => { '{"reply":"还没有说完', '{"tool":{"reply":"不能拿嵌套结构充当正文"},', '```json\n{"tool":{"secret":"raw-data"}}', - '```json\nnot valid json\n```', - '[{"reply":"不是合法 envelope"}]', + '```makelore-teacher-discussion\nnot valid json\n```', '{"reply":13,"tool":{"kind":"html"}}', '```json\n{"reply":"结束"}\n```\n```json\n{"reply":"第二个"}\n```', ])('never exposes malformed JSON as the student reply: %s', raw => {