Compare commits
3
Commits
a77ddbd681
...
1c07f7b5cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c07f7b5cb | ||
|
|
6dee9ea62c | ||
|
|
8ccc4650a2 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# ADR: 官方编程老师与项目会话导航
|
||||
|
||||
- Status: Accepted / implemented, project-scoped ongoing chat amended 2026-09-28
|
||||
- Status: Accepted / implemented, independent reasoning and answer streaming amended 2026-09-29
|
||||
- Date: 2026-09-22
|
||||
- Approval: 用户确认项目默认 Agent、项目下会话、多老师列表与移除试聊;随后确认使用 Yuxi 原生智能体、客户端提供三个只读工具、沿用学生账号付费,并要求实施及合并。2026-09-24 明确取消客户端固定老师/朋友分类,统一使用后端下发智能体,并要求合并上下文预算修复。
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
## Decision
|
||||
|
||||
- 2026-09-29 远程输入区改动与本地流式回复合并:麦克风识别只追加当前草稿,不自动发送,账号/项目/老师/来源切换或关闭时取消过期结果。默认模型沿用老师发布配置;学生可在 Main 与云端能力允许时为后续手动问题显式选择模型,按账号/老师记忆。每轮固定 modelId 与请求身份,重试保持原选择;人设、资源、发布版本、项目边界和学生计费不变,主动检查仍使用云端配置。旧服务能力不足时明确不可用,不忽略选择;配套 WS/Yuxi 上线仍为独立发布工作。
|
||||
- 快捷提示放在输入框上方,云端文案/实际提示词及空列表兼容规则保持;回复卡片采用紧凑样式,普通问题和求助均在完成时间之后只显示一次。发送时间与完成时间来自真实记录,运行中显示状态,未知时间不伪造。默认求助兼容文案更新为“帮我整体看🧠”。
|
||||
- 每位下发老师分别显示介绍气泡,按账号/项目/老师保存已介绍状态和操作 Agent 的完整回复轮数。进入对应咨询或显式关闭仅消费这一位;未操作时于第三轮成功操作回复后收起,旧历史、老师咨询、失败或中间工具事件不计数。咨询期间介绍组收起,真实未读主动建议继续使用独立已读边界;没有目录的旧入口保留欢迎语兼容。
|
||||
|
||||
- 2026-09-29 用户要求 Yuxi 老师智能体的思考过程与结果都流式展示并授权合并。Main 从既有主线程事件分别接收思考增量和可替换的回答预览,经现有项目聊天 SSE 下发;思考使用独立字段保存,运行时展开、结束后折叠并可重新展开,不混入正文、工具活动、后续模型上下文或带回草稿。结构化回复未结束时仅投影已解码正文,不显示 JSON 外壳或未完成快捷回复;最终回复仍完整解析并确认正文和卡片。新消息或工具续接替换当前正文预览,同一问题的思考保留;取消或失败保留已收到的片段与原文。子线程仍只推进游标。只展示上游实际返回的思考,不更改模型配置或生成替代思考。
|
||||
|
||||
- 2026-09-29 用户要求并授权合并可配置快捷提示。老师快捷提示由 Yuxi 的有序 `teacher_shortcuts` 配置拥有,每项分别保存展示文案 `label` 和实际提示词 `prompt`;最多 8 项,去除首尾空白后文案 1–40 字符、提示词 1–6000 字符。普通保存只改草稿,显式发布固定列表并以 `shortcuts` 下发;同一发布重试读取原快照。明确的空列表隐藏所有入口,历史快照缺字段或 null 保留客户端旧入口,未配置草稿提供可编辑的默认项。Makelore 按顺序渲染文案,点击通过普通提问发送实际提示词,聊天记录显示实际提问;目录刷新保留项目聊天和草稿。
|
||||
|
||||
- 2026-09-29 用户确认一次显式发布和及时刷新并授权合并。Yuxi 管理员在可管理的原生智能体编辑页一次“保存并发布”即可更新 Makelore;普通保存只改草稿。Yuxi 固定不可变快照并携同一操作身份交给 WS,只有 WS 登记提交后才报成功,失败重试不发布后来编辑的草稿。WS 按来源绑定稳定老师 ID,首次复用唯一启用旧项,歧义需显式选择;保留默认、停用和历史,不自动合并旧身份,重新启用需要显式动作。
|
||||
@@ -36,10 +42,12 @@
|
||||
- 咨询回答以 Markdown 渲染标题、列表、表格、代码、HTTP(S) 链接/图片和公式,长内容在栏内滚动;普通 JSON 和代码保持正文。兼容旧 `{intro, questions}` 和含 `tool` 的输出,只提取正文和快捷回复。完整正文可从保留原文中在本地恢复;无法确认完整时显示缺失提示,由用户明确点击重新回答,保持原问题、引用和项目/Pi 来源,保留现有草稿,采用当前发布版本。被动恢复不调用模型或改写已完成原始轮文件;不完整回答和未解析原文不进入后续模型上下文。 原文默认折叠、按字面展示。
|
||||
- 工具活动使用实际主线程事件及 Main 本地读取结果的名称/状态,按 run/call 关联与去重,单独折叠展示;参数、输出、错误原文与推理不进入活动 DTO 或回答。未观察到结果的调用明确显示结果缺失,不补写过期事件或旧话题。Yuxi chat_service 保证只有 AI 角色进入回答流,客户端不按内容外形猜测清洗。
|
||||
|
||||
- 输入为空时显示暖黄色“继续看看👀”,输入后隐藏;点击才发起求助。进入咨询立即收起静态欢迎语并按账号/项目记住,真实未读建议保持显式查看/收起边界。
|
||||
- 输入为空时显示已发布快捷提示,输入后隐藏;点击才发起求助。介绍与欢迎语按上述新旧入口分别记忆,真实未读建议保持显式查看/收起边界。
|
||||
|
||||
## Evidence And Release Boundary
|
||||
|
||||
- 2026-09-29 流式源 `8ccc4650a2d36ea4a1a7426dbaffc8178255a5e3` 已合入本地 main。322 项相关单测、标准类型检查、变更文件 lint、生产构建及 1 项 Electron 流式交互通过;合并保持产品与测试源一致。已核对 Yuxi 提交中的思考事件生产与保留协议,无配套服务端变更。需更新客户端;生产模型是否返回思考仍取决于云端配置与供应商,未进行真实收费模型或安装版验收。扩展 Main 类型检查仍有 66 项既有诊断,未涉及修改的老师文件。见[源记录](../30-worklog/tasks/20260929-teacher-stream-f00acb6f.md)和[集成记录](../30-worklog/tasks/20260929-merge-teacher-stream-1e4177fe.md)。
|
||||
|
||||
- 2026-09-29 目录刷新源通过 208 项相关单测、类型/lint/Vite 构建及独立审查;合并保持产品与测试源一致。WS 先升级 `20260929_0099` 和 revision/发布接口,再更新 Yuxi 与客户端;无本次客户端数据迁移。以下旧发布记录为历史,不代表当前 WS migration head。见[本次集成](../30-worklog/tasks/20260929-integrate-teacher-ml-a19f72c4.md)。
|
||||
|
||||
- 2026-09-28 组件移除源 317def79129503062d15076585881ceae753055c 无冲突快进合入本地 main。47 项相关单测、12 项浏览器布局、1 项 Electron 咨询场景及类型/lint/构建通过;产品与测试保持源字节,沿用其验证。没有服务端或历史数据迁移;未推送、打包或更新安装版。见[源任务](../30-worklog/tasks/20260928-remove-teacher-cards-ef1cf59c.md)和[集成记录](../30-worklog/tasks/20260928-merge-teacher-cards-a09c07f3.md)。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Decision Index
|
||||
|
||||
- [老师思考与回答流式展示](ADR-2026-09-22-coding-teacher.md):2026-09-29 已合并;主线程思考独立保存与折叠展示,正文逐步投影并由最终回复确认,快捷回复仅完成后显示;思考不进入后续模型上下文、工具活动或带回草稿。
|
||||
|
||||
- [老师可配置快捷提示](ADR-2026-09-22-coding-teacher.md):2026-09-29 已合并;Yuxi 有序展示文案/实际提示词,显式发布冻结,WS 版本化下发,客户端普通提问;空列表隐藏入口,历史缺字段保持旧入口。
|
||||
|
||||
- 2026-09-29 修订[下发智能体咨询决定](ADR-2026-09-22-coding-teacher.md):Yuxi 一次发布保持稳定老师身份;客户端五秒前台 revision 检查、入场/焦点刷新独立于聊天状态,保留项目连续聊天和运行快照。
|
||||
@@ -50,3 +52,5 @@ Create or update an ADR when a choice affects:
|
||||
- data model
|
||||
- long-term maintenance
|
||||
- user-facing workflow
|
||||
|
||||
- 2026-09-29 远程输入区与本地流式输出合并:[老师模型与语音、时间戳及独立介绍](ADR-2026-09-22-coding-teacher.md)保留项目聊天和云端人设;[桌面窗口/空作品预览边界](../20-architecture/system-overview.md#integrated-desktop-and-consultation-boundaries-2026-09-29)由 Main 持有。模型选择配套服务仍需单独上线。
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
当前项目中顶栏选择下发智能体 → Main 通过 /api/coding/projects/:projectId/agent-conversations/:agentId 读取账号/项目/智能体唯一聊天与最近50轮,打开本身不调用模型 → 学生发送时幂等获取该项目聊天,冻结项目/Pi 来源/当前发布版本 → 获取WS专用凭据 → Yuxi原生Request/Run。同项目/Pi/版本沿用内部线程;同项目来源或版本改变时新建执行段并仅携带该项目有预算的公开交流,不创建第二个可见聊天。切换项目恢复独立聊天,不复用前一项目历史或云端checkpoint。名称、人设和资料来自发布定义。读取中断 → Main校验本轮绑定、取消与额度 → 列目录/读取文件/读取会话原文 → 主动回传配对结果 → 原问题关联Run续接。协议2为12批/64KiB累计成功结果/8KiB单页,旧协议六批;最后一批消费后要求最终回答。保留原问题时限与学生付款人;文件使用读取时内容,会话使用本轮快照。云端只保存消息和收到的片段,完整工程与Pi原始日志保留本机。
|
||||
|
||||
Renderer 自由输入/空输入框“继续看看👀” → Main 统一 reply-v1 → 完整正文及 0–3 条可选快捷回复。完整正文可从保留原文中在本地恢复;无法确认完整时显示缺失提示,由用户明确点击重新回答,保持原问题、引用和项目/Pi 来源,保留现有草稿,采用当前发布版本。被动恢复不调用模型或改写已完成原始轮文件;不完整回答和未解析原文不进入后续模型上下文。 不确定旧请求仍复用原身份与输入来源,移除退役组件字段;旧组件只留原档案,修改路由退役。
|
||||
Renderer 自由输入/空输入框上方已发布快捷提示(历史缺字段默认“帮我整体看🧠”,显式空列表隐藏) → Main 统一 reply-v1 → 完整正文及 0–3 条可选快捷回复。完整正文可从保留原文中在本地恢复;无法确认完整时显示缺失提示,由用户明确点击重新回答,保持原问题、引用和项目/Pi 来源,保留现有草稿,采用当前发布版本。被动恢复不调用模型或改写已完成原始轮文件;不完整回答和未解析原文不进入后续模型上下文。不确定旧请求仍复用原身份、模型和输入来源,移除退役组件字段;旧组件只留原档案,修改路由退役。模型选择默认沿用发布模型,显式选择经 Main 能力和可用性检查后按请求冻结;云端不支持时不发送覆盖值。语音识别仅追加当前账号/项目/智能体草稿,不自动发送。
|
||||
|
||||
云端事件 → Main 先推进游标,再仅转发云端主线程文本 → Host SSE/本机历史 → 右侧面板。子智能体文本不混入正文或带回草稿;断线按游标与持久状态恢复。关闭面板不取消已接受问题,停止/退出请求取消原问题;重启后下次提问先停止旧问题。带回回答只追加草稿,不自动发送,旧本地老师话题保留原执行路径。见[老师决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。
|
||||
云端事件 → Main 先推进游标,再分别投影云端主线程思考和正文 → Host SSE/本机历史 → 右侧面板。思考独立折叠,正文从流式 reply-v1 逐步解码,最终回复确认正文和快捷回复;卡片仅在完整终态后显示,位于实际完成时间下方。思考不进入工具活动、后续模型上下文或带回草稿;子智能体事件只推进游标。断线按游标与持久状态恢复。关闭面板不取消已接受问题,停止/退出请求取消原问题;重启后下次提问先停止旧问题。带回回答只追加草稿,不自动发送,旧本地老师话题保留原执行路径。见[老师决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。
|
||||
|
||||
编译完整请求 → 本地路径按近似Token预算、云端路径按实际JSON query字节预算裁剪来源节选 → 提交前按同一计量复核。问题、明确引用及固定回复协议保持完整;真正的固定配置超限单独提示。明确引用绑定旧项目/Pi时须移除引用或恢复原来源。聊天轮文件原子写入后更新轻量索引;恢复与旧记录导入按原始身份去重,不重发模型。旧全局聊天依据每轮可证明的项目分入对应存储,映射已读并开启新云端执行段,旧讨论只留原档案;原文件不修改,无项目归属的轮保留原档案。历史页和SSE按请求ID合并且排除跨项目迟到响应,已读归Main,文字草稿和阅读缓存按账号/项目/智能体保存。旧全局草稿只导入其记录的项目,保留原值。现代聊天停止旧定时跟进,独立主动观察后续接入相应项目的同一聊天。
|
||||
|
||||
@@ -149,4 +149,4 @@ Main 在接收带图消息时立即将上传 attachment id 放入 optimistic use
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-09-22
|
||||
2026-09-29
|
||||
@@ -1,5 +1,7 @@
|
||||
# System Overview
|
||||
|
||||
老师咨询沿用 Main-owned Yuxi 事件与项目聊天 SSE。`cloud-runner` 分离主线程 `reasoning_content`(兼容已保留的 `additional_reasoning_content`)增量和当前消息的回答原文预览;service 用 `previewTeacherReply` 从未完成结构化回复投影正文,最终仍由完整解析确认。新消息/工具续接替换正文预览,问题级独立 `reasoning` 字段继续累积,终态初次读取也消费保留事件。Renderer 分开展示思考和 Markdown 正文,运行时展开思考、终态收起,快捷回复只在完成后可见。取消/失败保留片段与原文;思考随轮保存但不进入模型上下文或带回正文,子线程只推进游标。无新增服务端协议或模型配置,未返回思考的模型仍正常输出正文。
|
||||
|
||||
咨询输入区从当前老师发布定义的 `shortcuts` 按序渲染展示文案,并通过已有普通提问路径发送实际提示词;不附加旧 suggestions 意图。明确的空列表隐藏入口,缺字段/null 保留旧入口。多项长文案在限定高度内滚动,正在回答时不可点击;目录更新不替换项目聊天或草稿,同一未确认提问重试保留请求身份。详见[老师决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。
|
||||
|
||||
云端咨询的 Main 声明 `read_protocol=2`:目录按层分页(默认深度 4、最多 6,跳过生成缓存),文件与捕获会话返回连续文本及准确的下一页行/Unicode 字符列。文件增量解码,不受 UI 预览大小限制。每问题最多 12 批、累计成功结果 64 KiB、单页 8 KiB;剩余不足 512 字节时停止读取。Yuxi 消费最后一批结果后通过 `tool_choice: none` 请求最终答案,保留原身份、版本、时限与幂等续接;未声明新协议的旧客户端保持六批。工具活动以问题与逻辑 tool_call_id 合并跨 Run 重放,原始 interrupt 工具错误不单独判为失败。Main 区分读取额度、上下文失效与协议错误;失败问题不自动重试。须先更新 Yuxi API/worker,再更新客户端;生产供应商与安装版验收仍待完成。
|
||||
@@ -141,3 +143,13 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
|
||||
## Last Updated
|
||||
|
||||
2026-09-22
|
||||
|
||||
## Integrated desktop and consultation boundaries (2026-09-29)
|
||||
|
||||
老师输入复用语音草稿能力,经 Main 提供无凭据模型目录并核对云端可选模型能力;每次发送冻结 modelId,重试保持原身份和选择。流式思考/正文保持独立字段,最终正文、真实完成时间及快捷回复各显示一次。人设、资源、发布版本和付款人不变;云端能力上线不由本次客户端合并完成。
|
||||
|
||||
macOS 红色关闭销毁窗口,全屏时先完成原生退出;Dock、托盘和菜单通过当前窗口提供器重建界面。认证、Host API、运行时和任务租约属于 Main 进程,窗口/WebContents 持有展示与 Renderer 租约,旧原生视图不得挂到后继窗口。Windows/Linux 仍关闭到托盘。本地 Windows 验证不能代替 macOS 原生验收。
|
||||
|
||||
作品预览由 Main 在创建操作会话或派发启动问题前检查项目内容;确定为空或仅元数据/知识/素材时返回本地 empty 状态,不调用模型,重开或 host show 可重新检查。已有页面、未知文件及无法确认内容的项目仍走既有启动恢复路径。
|
||||
|
||||
空操作对话显示欢迎语、持续挂载的输入框和灵感邀请;邀请与侧栏社区按钮仅经明确点击在系统浏览器打开既定社区地址,不恢复内嵌作品广场或发布页面。老师介绍独立于实际主动建议,按项目/老师计数三轮成功操作回复,快捷提示仍来自发布配置。
|
||||
@@ -4,6 +4,10 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
|
||||
|
||||
## Integrated Through
|
||||
|
||||
- 2026-09-29:续接用户已授权的老师流式集成任务,将远程 `a77ddbd6813bb71d490544edfef6e1f35240070f` 与本地 `6dee9ea62c42b98a747352378fe1c74ab77f48a5` 合并。保留独立思考/正文流,接入按请求固定的模型选择、语音草稿、真实时间戳、紧凑快捷回复和各老师独立介绍;完成态正文及卡片各显示一次。同步纳入本地空作品检测、社区外部链接和 macOS 主窗口重建。582 项相关单测、47 项浏览器场景、9 项 Electron 场景、标准类型检查、变更文件 lint 与生产构建通过;修正一处仍假设单欢迎气泡的旧布局断言。Windows 夹具不代表原生 macOS、真实模型或云端模型覆盖能力已上线。失效源工作树通过保留残留目录、官方 release-missing/start 恢复登记;三份原有未跟踪文档及远程来源记录保持原样。未推送、部署、打包或更新安装版。见[续接记录](tasks/20260929-merge-teacher-stream-1e4177fe.md#remote-merge-resume)。
|
||||
|
||||
- 2026-09-29:按用户要求,将老师流式源 `8ccc4650a2d36ea4a1a7426dbaffc8178255a5e3` 从 `f443f3b` 无冲突快进合入本地 `main`。Yuxi 主线程思考独立流式显示并保存,正文从未完成结构化回复逐步投影,最终回复确认正文与快捷回复;思考不进入工具活动、后续模型上下文或带回草稿。工具续接与取消保留已收内容,子线程仍只推进游标。产品与测试保持源一致,沿用 322 项相关单测、标准类型检查、lint、生产构建及 1 项 Electron 场景证据;扩展 Main 仍有 66 项既有诊断。已核对 Yuxi 现有事件协议,无配套服务端改动;需更新客户端,真实模型思考事件与安装版尚未验收。三份外来文档原样保留且未提交;未推送、部署或打包。见[源记录](tasks/20260929-teacher-stream-f00acb6f.md)和[集成记录](tasks/20260929-merge-teacher-stream-1e4177fe.md)。
|
||||
|
||||
- 2026-09-29:按用户要求,将已独立审查的快捷提示源 `53d5b6b` 无冲突快进合入本地 `main`。Yuxi 配置多项展示文案和实际提示词,显式发布冻结后经 WS 下发,Makelore 按序显示并通过普通提问发送;空列表隐藏入口,历史缺字段保留旧入口。源验证 73 项 UI 单测、21 项浏览器布局场景、类型检查、lint 和生产构建通过;独立 Reviewer PASS。合并保留已审查产品与测试,新增修改仅为项目记忆;原有跨仓 7 份外来文档保持未提交。快捷提示不新增迁移;上线顺序为 WS API、Yuxi、Makelore,本次未推送、部署或打包。见[源记录](tasks/20260929-teacher-shortcuts-ml-8ce941d7.md)及[集成记录](tasks/20260929-integrate-shortcuts-ml-b75d3c29.md)。
|
||||
|
||||
- 2026-09-29:用户确认“一次发布,自动更新 Makelore”并要求合并。源 `913ccad5774df9a8e1f8259394658a524f8255d4` 无冲突快进合入本地 `main`。Yuxi 管理员在可管理的原生智能体编辑页一次“保存并发布”即可更新 Makelore;普通保存只改草稿。Yuxi 固定不可变快照并携同一操作身份交给 WS,只有 WS 登记提交后才报成功,失败重试不发布后来编辑的草稿。WS 按来源绑定稳定老师 ID,首次复用唯一启用旧项,歧义需显式选择;保留默认、停用和历史,不自动合并旧身份,重新启用需要显式动作。 前台每五秒读取小型目录 revision,进入模块及回到前台补查,变化才重新读取完整配置;后台停止轮询。刷新独立于聊天、输入和正在回答,不触发模型。Main 每次接受新问题仍读取当前版本,运行中的问题固定原版本,同项目可见聊天保持连续。 独立 Reviewer 发现的历史字符串 ID `coding-teacher` 被 UUID 约束拒绝问题已修复并复核通过。WS 33 项(含真实 PostgreSQL 迁移/并发)、Yuxi 2260 后端单测/1 跳过及发布 HTTP/PG、worker/Redis、浏览器和前端构建,MakeLore 208 项及类型/lint/构建通过;具体分批证据见源记录。必须先部署 WS `20260929_0099`/API,再更新 Yuxi 和客户端;本次仅本地合并,未推送、部署或测量生产延迟。原有跨仓共 7 份外来文档保留未提交。此条更新旧运营必经和旧话题固定版本的发布说明,下方日期条目保留为历史。见[源记录](tasks/20260929-teacher-publish-ml-e6b32168.md)、[集成记录](tasks/20260929-integrate-teacher-ml-a19f72c4.md)。
|
||||
@@ -1226,4 +1230,4 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-09-22
|
||||
2026-09-29
|
||||
@@ -0,0 +1,54 @@
|
||||
# Task: Integrate teacher reasoning and answer streaming into main
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260929-merge-teacher-stream-1e4177fe
|
||||
- Mode: Integration
|
||||
- Branch: main
|
||||
- Worktree: D:\Datas\OthersProjects\makelore
|
||||
- Base commit: f443f3bc668dfc6c9df111506d739d343ee82bba
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Integrate completed teacher streaming source 8ccc4650a2d36ea4a1a7426dbaffc8178255a5e3 into local main and promote its accepted project-memory changes.
|
||||
- Authorized continuation also merges remote a77ddbd6813bb71d490544edfef6e1f35240070f into local 6dee9ea62c42b98a747352378fe1c74ab77f48a5 and recovers the interrupted source-worktree registry cleanup.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User explicitly requested merging into main. Concurrent and Planning Gates Passed with exact registry identity and exclusive integration lock. Loaded entry, own/source records, positioning, current state, teacher ADR, architecture and domain/success rules; retained prior same-base peer scope assessment. Historical placeholder peer records remain unknown with no concrete conflicting dependency.
|
||||
- Reused the human-authorized primary-checkout handoff recorded by the preceding integration task 20260929-integrate-shortcuts-ml-b75d3c29. Its drift check proved the same three foreign documents unchanged before official release/start --adopt-existing. Preserve them without staging: 20260901-package-122-c5e8.md, 20260901-package-123-d7f3.md and 20260902-client-hang-diagnosis-a47c9e2b.md under this task-record directory.
|
||||
- Source records are read-only. Main owns cloud transport; preserve project isolation, published model configuration and billing. No subagents, product redesign, cloud writes, push, deployment or packaging.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Fast-forwarded local main from f443f3bc668dfc6c9df111506d739d343ee82bba to source 8ccc4650a2d36ea4a1a7426dbaffc8178255a5e3 without conflicts. Main now includes independent streamed reasoning and answer previews with final reply reconciliation.
|
||||
- Promoted the user-authorized source candidate into the teacher ADR, decision index, system overview, business rules and Integrated Through. README arrived with the source. The new visible reasoning rule supersedes the old visibility exclusion without including reasoning in answers, tools, future model context or bring-back drafts.
|
||||
- Remote continuation resolved README, TeacherChatPanel and both Main teacher test conflicts by retaining streaming and the remote model/voice/timestamp/intro behavior. The final reply and quick cards each render once; final cards follow the completion timestamp. Both cloud-stream and frozen-model regression cases remain.
|
||||
- Accepted remote source outcomes are reflected in canonical teacher decisions, architecture/data flow, domain rules and current state: per-request model choice with server capability support, voice draft isolation, real timestamps, individual introductions, local empty-work detection, external community invitation and macOS window recreation. No cloud deployment or native macOS acceptance is claimed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Source evidence: 322 distinct relevant unit cases, standard typecheck, scoped lint, final Vite production compile and one Electron streaming interaction passed. Extended Main typecheck has 66 documented existing diagnostics, none in changed teacher files. Source used deterministic fixtures; real paid-provider and installed-client acceptance remain unverified.
|
||||
- Integration checks passed: source ancestry, identical product/tests/source record and diff whitespace. Reused source test evidence because additional edits only update canonical documents. Task-aware documentation drift checked before commit/completion.
|
||||
- Remote merge verification: 27 focused unit files / 582 cases passed (308 teacher/model/voice/timestamp cases and 274 Main/window/preview/community/Pi cases); standard pnpm typecheck, changed TypeScript-file ESLint and production build passed. Four browser specs / 47 scenarios passed. Nine relevant Electron scenarios passed: eight on the initial run, plus the corrected legacy geometry case on a focused rerun. That case had assumed a single welcome bubble; its fixed 65px limit was replaced with the actual introduction group's native viewport clearance (8–9px), retaining the later assertion that clearance disappears when consultation opens. No product change was needed for this failure.
|
||||
- Initial checks exposed incomplete existing node_modules (missing tsc and transitive packages). Restored dependencies with pinned pnpm 10.33.4 and frozen lockfile; force reinstallation reused the existing store. package.json and pnpm-lock.yaml remain unchanged. Paid model, deployed server/model override capability, installed-client and native macOS checks remain outside this Windows fixture validation.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- User explicitly approved cleaning source task 20260929-teacher-stream-f00acb6f at D:\Datas\OthersProjects\.codex-worktrees\makelore\20260929-teacher-stream-f00acb6f, including ignored dependencies/build output while preserving its branch and commits. Rechecked ready/managed registry state, clean source, committed source record and source inclusion in main; resolved the exact approved path before official retire from the primary checkout.
|
||||
- Cleanup BLOCKED: official retire exited 1 with Git error `Directory not empty`. Git removed the linked-worktree registration and .git file, but left a partial directory (including node_modules and some source/resources). Branch codex/20260929-teacher-stream-f00acb6f-teacher-stream still resolves to 8ccc4650a2d36ea4a1a7426dbaffc8178255a5e3. Retained residue and remaining task registry state; did not bypass the skill's exclusive-retire rule with manual deletion or reconstruct metadata. Requires separate supported recovery. This does not invalidate the completed merge.
|
||||
- Shipping the updated client and verifying production model reasoning availability remain release work, outside this local merge.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Source candidate accepted and promoted as described above; no unresolved canonical conflict or additional promotion.
|
||||
- Remote accepted candidates promoted as described in Outcome. See [interrupted retirement reflection](../../60-reflection/cases/20260929-merge-teacher-stream-1e4177fe__interrupted-retire.md); any bundled-skill recovery implementation remains a separate task.
|
||||
|
||||
## Remote Merge Resume
|
||||
|
||||
- User authorized continuation of this owner and completion of the in-progress merge from remote a77ddbd6813bb71d490544edfef6e1f35240070f into local 6dee9ea62c42b98a747352378fe1c74ab77f48a5. Preserve three existing untracked records and all imported source records unchanged; commit locally, without push.
|
||||
- Initial official start was blocked by invalid peer worktree 20260929-teacher-stream-f00acb6f after the prior interrupted retirement. Verified retained source branch at 8ccc465 and ancestry in main, absent .git and remaining directory. User explicitly authorized exceptional residual cleanup and official registry recovery. Automatic approval review rejected recursive deletion with only “blocked by policy”; no deletion ran. Renamed the residual directory reversibly to D:\Datas\OthersProjects\.codex-worktrees\makelore\20260929-teacher-stream-f00acb6f-retained, preserving its bytes, then used official release-missing --force successfully. Source branch and commits remain. This supersedes the unresolved cleanup blocker above; the retained directory still exists.
|
||||
- Official start --adopt-existing then exited 0; status verified this task, integration mode, main, primary worktree, base f443f3b and planning status. Planning Gate Passed after refreshed entry/own/source records, positioning/current state, teacher ADR/index, architecture/domain and evidence/reflection/commitment context. Read peer records at registered paths; 143 remaining peers stay isolated. Historical template scopes remain unknown without a concrete blocking dependency.
|
||||
- Plan: combine streamed reasoning/prose with the remote model/voice/timestamp/composer changes; retain both test sets and remove duplicate suggestions rendering. Verify typecheck, focused units, production build, affected browser and Electron scenarios, then reconcile accepted canonical facts, pass documentation drift and commit/complete. No subagents, runtime/installed-app update, paid requests, cloud rollout, or unrelated feature work.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Task: Stream Yuxi teacher reasoning and answers in Makelore
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260929-teacher-stream-f00acb6f
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260929-teacher-stream-f00acb6f-teacher-stream
|
||||
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260929-teacher-stream-f00acb6f
|
||||
- Base commit: f443f3bc668dfc6c9df111506d739d343ee82bba
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Stream Yuxi main-thread reasoning and answer previews through Main and the teacher panel, preserving final reply validation, project isolation and tool continuation.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Concurrent/Planning Gates Passed: official scripts established the exact feature identity above. Loaded entry, task, positioning, integrated state, teacher ADR, architecture/domain/success and relevant indexes; peer task records were read only. Historical placeholder scopes remain unknown; adjacent completed teacher/observer/packaging work has no conflicting dependency. No subagents.
|
||||
- User explicitly requests both reasoning and results streamed. This revises the old exclusion of reasoning from visible teacher chat, while keeping it separate from answers, tools and future model context. Main remains transport owner; no provider configuration or server mutation.
|
||||
- Plan: verify the committed Yuxi event contract; add incremental prose projection and separate reasoning stream; cover continuation, final reconciliation, cancellation, persistence and visible streaming; run focused tests, typecheck, lint/build and available Electron UI fixture.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Implemented separate main-thread reasoning deltas and replaceable answer previews through the existing Main-owned stream. Yuxi committed chat_service/agent_run_service confirm message_delta reasoning_content (and retained additional_reasoning_content) is already transported; no Yuxi or WS changes were needed.
|
||||
- Partial reply JSON is decoded into prose without requiring its closing string/object or exposing quick-reply fields. Final output remains authoritative, and a new message/tool continuation resets the preview while retaining question reasoning. Terminal event draining retains reasoning from fast completed/interrupted Runs. Child events only advance the cursor.
|
||||
- The panel expands real reasoning while running, streams answer Markdown independently, folds reasoning when finished and exposes quick replies only after completion. Cancellation/failure preserves received previews and original bytes; saved reasoning is separate from follow-up model context and bring-back answer text. Old suggestion records without the current presentation marker keep their existing raw-output hiding behavior.
|
||||
- README updated; no shared canonical project-memory writes. Implementation is isolated from the primary checkout and other tasks; no merge, push, deployment, package or installation.
|
||||
|
||||
## Verification
|
||||
|
||||
- 322 distinct relevant unit cases passed across eight files, in focused batches: cloud runner, service, reply parser/streaming/history, conversation store and both panel suites. Coverage includes incomplete escapes/fences, live preview, terminal reconciliation, main-thread filtering, cursor continuation, tool-read reasoning, cancellation, disk reload, context exclusion and historical raw-JSON hiding.
|
||||
- pnpm run typecheck passed; scoped ESLint passed; final pnpm run build:vite passed (Renderer/Main/Preload/utility).
|
||||
- New Electron fixture test `teacher reasoning and answer stream separately before the final response` passed. Inspected teacher-streaming.png: separate visible reasoning/prose while stop remains available, then collapse/reopen and quick replies. This uses deterministic Host API/SSE fixtures, not a production provider.
|
||||
- Extended Main typecheck reports 66 diagnostics, matching the documented existing count; none mention the modified teacher files. Standard project typecheck passes. No full unit suite or live paid-provider/installed-client acceptance was claimed.
|
||||
- git diff --check passed; final task-aware documentation drift gate run before handoff.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Integrate this branch and ship an updated Makelore client to affect installed users. Verify production Yuxi/model actually emits reasoning when enabled; models without reasoning events still stream their answer normally. This task did not alter cloud model/reasoning configuration.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Targets: coding-teacher ADR, architecture/domain and integrated state. Proposal: explicitly document independent live main-thread reasoning and replaceable answer previews, with final answer reconciliation and final-only quick replies; keep reasoning out of tools, follow-up context and bring-back text. Evidence: committed Yuxi producer contract, the focused unit cases, Electron fixture and production compile above. Future impact: streaming/progress, cancellation and history consumers preserve these separate fields. Conflict: intentionally supersedes the earlier exclusion of visible reasoning, as directly authorized by this user request; no additional product-direction confirmation is needed. Promote only under Integration Gate.
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Project conversations and distributed agents
|
||||
|
||||
- 学生可在服务端支持时为手动老师问题选择模型;默认沿用发布配置,选择按账号/老师记忆,每轮冻结并纳入幂等比较。模型选择不改变人设、资源、发布版本或付款人,主动检查仍按云端配置。语音只追加草稿,跨账号/项目/老师/来源的迟到结果丢弃。
|
||||
- 每位老师介绍分别按账号/项目/老师记忆,点击或关闭只消费对应项;未操作时在三轮成功操作回复后收起,不把旧历史、老师咨询、工具中间事件或失败算作轮次。正文、真实消息时间及终态快捷回复各显示一次。
|
||||
- 老师智能体的主线程思考与回答分别流式展示。思考独立保存,运行时展开、终态折叠且可重开,不进入正文、工具活动、后续模型上下文或带回草稿;无上游思考事件则不显示思考区。未完成结构化回复仅展示已解码正文,最终解析确认完整正文和快捷回复;快捷回复仅完成后显示。工具续接或新消息替换当前正文预览并保留本问题思考;取消/失败保留已收到的片段和原文,不冒充完整回答。
|
||||
|
||||
- 老师快捷提示由 Yuxi 的有序 `teacher_shortcuts` 配置拥有,每项分别保存展示文案 `label` 和实际提示词 `prompt`;最多 8 项,去除首尾空白后文案 1–40 字符、提示词 1–6000 字符。普通保存只改草稿,显式发布固定列表并以 `shortcuts` 下发;同一发布重试读取原快照。明确的空列表隐藏所有入口,历史快照缺字段或 null 保留客户端旧入口,未配置草稿提供可编辑的默认项。Makelore 按顺序渲染文案,点击通过普通提问发送实际提示词,聊天记录显示实际提问;目录刷新保留项目聊天和草稿。
|
||||
|
||||
- 前台每五秒读取小型目录 revision,进入模块及回到前台补查,变化才重新读取完整配置;后台停止轮询。刷新独立于聊天、输入和正在回答,不触发模型。Main 每次接受新问题仍读取当前版本,运行中的问题固定原版本,同项目可见聊天保持连续。
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Interrupted worktree retirement can block every subsequent task start
|
||||
|
||||
The prior official retire removed Git registration and the worktree .git file but failed with Directory not empty. Its ownership/reservation remained, so the next authorized main continuation was rejected for an invalid peer worktree. A missing task record warning alone would not block; the invalid Git worktree did.
|
||||
|
||||
The source branch and integrated commit were verified before recovery. The user explicitly approved a manual exception for this exact residual path. Automatic approval review rejected recursive deletion. The reversible fallback renamed the residual directory with a -retained suffix, preserving all bytes; official release-missing --force then removed the now-missing original path's matching records. Official start and status subsequently passed. No registry JSON was edited and no other checkout was touched. The retained directory remains available, and source branches/commits remain intact.
|
||||
|
||||
Promotion candidate: extend the maintained skill's official retirement workflow with a bounded interrupted-retirement recovery that can distinguish absent Git metadata from a live checkout, preserve residue by default and reconcile exact task records transactionally. Evidence: failed retire recorded by the source integration task and this follow-up's actual start/release/start results. Future impact: prevent one interrupted cleanup from blocking unrelated authorized work. Skill changes require separate approval; no bundled script was modified here.
|
||||
@@ -179,10 +179,11 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
- 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。
|
||||
- 进入项目时,每位已下发智能体各显示一个浅色自我介绍气泡,外层透明,不显示公共底板、标题或整组关闭入口;整张气泡可点击,独立关闭按钮只收起该老师介绍,优先使用云端介绍,缺省使用发布欢迎语;不调用模型、不声称已检查项目。气泡进入项目即依次出现,不点击时保留到操作 Agent 第 3 轮完整回复结束后自动收起,也可分别关闭;按账号、项目、智能体保存计数及已介绍状态,刷新与操作对话切换延续进度。旧历史、老师咨询、工具调用、重试中间状态、失败/中止和其他项目不计入,新增智能体单独补上并从零计数。点击气泡或头像进入对应咨询,只记住这一位已介绍;咨询期间收起介绍组,关闭后其余未查看介绍仍可见,悬停头像仍可查看完整介绍。没有下发目录的旧入口保留原欢迎语展示兼容。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。
|
||||
- 智能体支持自由提问;输入框为空时,上方显示老师已发布的快捷提示,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“帮我整体看🧠”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。
|
||||
- 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。
|
||||
- 老师咨询展示流式思考过程、老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。
|
||||
- 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在快捷求助、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。
|
||||
- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。新输出超过 3 条快捷回复时保留完整正文、提示格式错误并保留原始回答,不展示超额卡片或静默截取;历史中已有的更多卡片在读取与后续保存时仍原样保留。独立资源上限只用于防护,不做旧式字数截断;格式错误的原文默认折叠、字面显示,不再次送进模型。
|
||||
- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后确认最终正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。新输出超过 3 条快捷回复时保留完整正文、提示格式错误并保留原始回答,不展示超额卡片或静默截取;历史中已有的更多卡片在读取与后续保存时仍原样保留。独立资源上限只用于防护,不做旧式字数截断;格式错误的原文默认折叠、字面显示,不再次送进模型。
|
||||
- 回复中的引号或换行格式有误时,仅在正文边界明确、修复后整体合法的情况下恢复完整文字,不把半句话当成完整回答。附加建议损坏时仍展示完整正文;正文无法确认完整时显示“这次回复未能完整显示”,由学生点击“重新回答”,沿用原问题、引用与来源;持续聊天须回到原项目和操作对话后重试,新一轮按当前发布版本执行,旧话题仍使用绑定版本,保留当前草稿,连续点击不重复发送。打开聊天或旧话题会从保留的原文中在本地恢复可确认的正文,不调用模型,不因显示修复而改写磁盘记录;原始内容继续折叠保留。不完整的回答和建议不进入后续模型上下文。
|
||||
- Yuxi 老师回复中的主线程思考过程与正文实时分开显示:思考区在生成期间展开,结束后折叠并可重新查看;正文在结构化回复尚未结束时逐步显示,不展示 JSON 包装或未完成的快捷回复。工具读取后的新消息替换临时正文,完成时按最终结果校准,快捷回复在完成解析后出现。停止或失败时保留已收到的思考与部分正文,恢复历史不会重新调用模型;思考不进入后续提问上下文或带回主会话的正文。仅展示 Yuxi 实际发出的推理字段,未开启或不提供思考的模型不显示虚构过程。
|
||||
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
|
||||
- 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。
|
||||
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。
|
||||
|
||||
@@ -39,6 +39,13 @@ export interface TeacherCloudTransport {
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TeacherCloudStreamUpdate {
|
||||
/** Current assistant message snapshot, replaced across messages and Runs. */
|
||||
response?: string;
|
||||
/** New reasoning text, accumulated across the question's tool continuations. */
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
export function teacherCloudTransport(account: TeacherAccount): TeacherCloudTransport {
|
||||
let session: TeacherSession | undefined;
|
||||
const fetchCloud = async (path: string, body?: unknown, signal?: AbortSignal) => {
|
||||
@@ -181,6 +188,7 @@ export function prepareCloudTeacher(
|
||||
saveRequestId: (id: string) => Promise<void>,
|
||||
transport: TeacherCloudTransport = teacherCloudTransport(account),
|
||||
onToolActivity: (activity: TeacherToolActivity) => void = () => undefined,
|
||||
onStream: (update: TeacherCloudStreamUpdate) => void = () => undefined,
|
||||
) {
|
||||
const tools = createTeacherReadTools(access);
|
||||
return {
|
||||
@@ -282,6 +290,44 @@ export function prepareCloudTeacher(
|
||||
readBytes = 0;
|
||||
let runText = '',
|
||||
messageId = '';
|
||||
let reasoningMessage = '';
|
||||
const readEvents = async (threadId: string) => {
|
||||
await transport.events(
|
||||
'/runs/' + encodeURIComponent(runId) + '/events?after_seq=' + encodeURIComponent(cursor),
|
||||
bounded,
|
||||
(_event, envelope, id) => {
|
||||
if (id) cursor = id;
|
||||
// Parent Runs include child events; only the main thread is visible.
|
||||
if (envelope.thread_id !== threadId) return;
|
||||
const payload = envelope.payload ? object(envelope.payload) : {};
|
||||
for (const item of Array.isArray(payload.items) ? payload.items : payload.chunk ? [payload.chunk] : []) {
|
||||
const chunk = object(item);
|
||||
const activity = cloudToolActivity(chunk, requestId);
|
||||
if (activity) reportActivity(activity);
|
||||
const event = chunk.stream_event ? object(chunk.stream_event) : {};
|
||||
if (event.type !== 'message_delta') continue;
|
||||
if (typeof event.message_id === 'string' && event.message_id !== messageId) {
|
||||
if (!structuredReply && messageId && runText) onText('\n\n');
|
||||
messageId = event.message_id;
|
||||
runText = '';
|
||||
if (structuredReply) onStream({ response: '' });
|
||||
}
|
||||
const reasoning = typeof event.reasoning_content === 'string' ? event.reasoning_content
|
||||
: typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : '';
|
||||
if (reasoning) {
|
||||
const identity = runId + ':' + messageId;
|
||||
onStream({ reasoning: (reasoningMessage && reasoningMessage !== identity ? '\n\n' : '') + reasoning });
|
||||
reasoningMessage = identity;
|
||||
}
|
||||
if (typeof event.content === 'string') {
|
||||
runText += event.content;
|
||||
if (structuredReply) onStream({ response: runText });
|
||||
else onText(event.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
while (Date.now() < deadline) {
|
||||
bounded.throwIfAborted();
|
||||
access.assertCurrent();
|
||||
@@ -290,11 +336,18 @@ export function prepareCloudTeacher(
|
||||
undefined,
|
||||
bounded
|
||||
);
|
||||
// A fast run/continuation may already be terminal before our first GET.
|
||||
// Drain its retained events before moving on so reasoning is not lost.
|
||||
if (view.status !== 'running' && view.status !== 'pending' && typeof view.thread_id === 'string') {
|
||||
try { await readEvents(view.thread_id); }
|
||||
catch { bounded.throwIfAborted(); access.assertCurrent(); }
|
||||
}
|
||||
if (view.continued_run_id) {
|
||||
runId = identifier(view.continued_run_id);
|
||||
cursor = '0-0';
|
||||
runText = '';
|
||||
messageId = '';
|
||||
if (structuredReply) onStream({ response: '' });
|
||||
continue;
|
||||
}
|
||||
if (view.status === 'interrupted') {
|
||||
@@ -347,6 +400,7 @@ export function prepareCloudTeacher(
|
||||
cursor = '0-0';
|
||||
runText = '';
|
||||
messageId = '';
|
||||
if (structuredReply) onStream({ response: '' });
|
||||
onProgress('智能体正在继续思考…');
|
||||
continue;
|
||||
}
|
||||
@@ -354,8 +408,8 @@ export function prepareCloudTeacher(
|
||||
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.
|
||||
// Commit only the final answer; onStream carries replaceable previews
|
||||
// from messages that may precede a tool read or another draft.
|
||||
if (structuredReply) onText(output);
|
||||
else if (output.startsWith(runText)) onText(output.slice(runText.length));
|
||||
else if (output) onText('\n\n' + output);
|
||||
@@ -375,39 +429,7 @@ export function prepareCloudTeacher(
|
||||
const threadId = identifier(view.thread_id);
|
||||
onProgress('智能体正在思考…');
|
||||
try {
|
||||
await transport.events(
|
||||
'/runs/' +
|
||||
encodeURIComponent(runId) +
|
||||
'/events?after_seq=' +
|
||||
encodeURIComponent(cursor),
|
||||
bounded,
|
||||
(_event, envelope, id) => {
|
||||
if (id) cursor = id;
|
||||
// Yuxi 的父 Run 也包含子线程事件,智能体正文只接收云端主线程文本。
|
||||
if (envelope.thread_id !== threadId) return;
|
||||
const payload = envelope.payload ? object(envelope.payload) : {};
|
||||
for (const item of Array.isArray(payload.items)
|
||||
? payload.items
|
||||
: payload.chunk
|
||||
? [payload.chunk]
|
||||
: []) {
|
||||
const chunk = object(item);
|
||||
// A checkpoint continuation changes Run, not the logical tool call.
|
||||
const activity = cloudToolActivity(chunk, requestId);
|
||||
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) {
|
||||
if (!structuredReply && messageId && runText) onText('\n\n');
|
||||
messageId = event.message_id;
|
||||
runText = '';
|
||||
}
|
||||
runText += event.content;
|
||||
if (!structuredReply) onText(event.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
await readEvents(threadId);
|
||||
} catch (error) {
|
||||
bounded.throwIfAborted();
|
||||
access.assertCurrent();
|
||||
|
||||
@@ -37,6 +37,7 @@ import { teacherModelCatalog, validateTeacherModelId } from './model-catalog';
|
||||
import { prepareCloudTeacher } from './cloud-runner';
|
||||
import { readTeacherSource } from './source-reader';
|
||||
import { applyTeacherReply, teacherReplyInstructions } from './reply';
|
||||
import { previewTeacherReply } from '../../shared/teacher-reply';
|
||||
import { subscribeWorksSquareSession } from '../services/works-square-session';
|
||||
|
||||
export interface TeacherScope {
|
||||
@@ -577,6 +578,7 @@ export class CodingTeacherService {
|
||||
...(store instanceof TeacherConversationStore ? { archive: await store.history() } : {}),
|
||||
assertCurrent: () => this.assertAccount(account),
|
||||
};
|
||||
let streamedResponse = '';
|
||||
const model = isCloud && access
|
||||
? (this.options.prepareCloud ?? prepareCloudTeacher)(account, topic, input.requestId, access,
|
||||
(progress) => {
|
||||
@@ -595,6 +597,16 @@ export class CodingTeacherService {
|
||||
else activities[index] = activity;
|
||||
topic.revision++;
|
||||
this.events.emit(key, structuredClone(topic));
|
||||
}, update => {
|
||||
this.assertAccount(account);
|
||||
const current = topic.requests.find(request => request.id === input.requestId)!;
|
||||
if (update.reasoning) current.reasoning = (current.reasoning ?? '') + update.reasoning;
|
||||
if (update.response !== undefined) {
|
||||
streamedResponse = update.response;
|
||||
current.response = previewTeacherReply(streamedResponse);
|
||||
}
|
||||
topic.revision++;
|
||||
this.events.emit(key, structuredClone(topic));
|
||||
})
|
||||
: await (this.options.prepareModel ?? prepareTeacherModel)(account, modelDefinition,
|
||||
access,
|
||||
@@ -689,10 +701,9 @@ export class CodingTeacherService {
|
||||
: '智能体回复失败,已保留本次问题与收到的内容。';
|
||||
} finally {
|
||||
// Keep received bytes inert even when an aborted runner resolves normally.
|
||||
if (structuredReply && current.status !== 'completed' && structuredText)
|
||||
current.unparsedResponse = structuredText;
|
||||
if (structuredReply && current.status !== 'completed' && (structuredText || streamedResponse))
|
||||
current.unparsedResponse = structuredText || streamedResponse;
|
||||
if (intent === 'suggestions' && current.status !== 'completed') {
|
||||
current.response = '';
|
||||
delete current.suggestedQuestions;
|
||||
}
|
||||
current.finishedAt = new Date().toISOString();
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface TeacherRequest {
|
||||
/** Original answer retained for failed or repaired parsing; never fed back as context. */
|
||||
unparsedResponse?: string;
|
||||
toolActivity?: TeacherToolActivity[];
|
||||
/** Provider-emitted main-thread reasoning, separate from the answer/context. */
|
||||
reasoning?: string;
|
||||
|
||||
intent?: TeacherRequestIntent;
|
||||
sourceConversationId?: string;
|
||||
|
||||
@@ -224,6 +224,44 @@ export function isTeacherReplyEnvelope(raw: string): boolean {
|
||||
return withinBytes(raw, MAX_TEACHER_REPLY_BYTES) && candidates(raw.trim()).length > 0;
|
||||
}
|
||||
|
||||
/** Preview received prose only; final validation still belongs to parseTeacherReply. */
|
||||
export function previewTeacherReply(raw: string): string {
|
||||
if (!withinBytes(raw, MAX_TEACHER_REPLY_BYTES)) return '';
|
||||
const text = raw.trimStart();
|
||||
const found = candidates(text);
|
||||
if (!found.length) {
|
||||
// Wait for enough of an opening JSON/fence to identify the reply envelope.
|
||||
if (/^\{/.test(text)) {
|
||||
try { JSON.parse(text); } catch { return ''; }
|
||||
}
|
||||
if (/^(`{1,3}|~{1,3})([^\n]*)$/.test(text)
|
||||
|| /^(?:```|~~~)(?:json|makelore-teacher(?:-reply|-discussion)?)?\s*\n\s*\{?[^:]*$/i.test(text)) return '';
|
||||
return text;
|
||||
}
|
||||
if (found.length !== 1) return '';
|
||||
const candidate = found[0];
|
||||
const scanned = fields(candidate.content);
|
||||
if (scanned.duplicate || scanned.proseStart === undefined) return '';
|
||||
const start = scanned.proseStart;
|
||||
if (candidate.content[start] !== '"') return '';
|
||||
let end = start + 1;
|
||||
while (end < candidate.content.length) {
|
||||
const char = candidate.content[end];
|
||||
if (char === '"') break;
|
||||
if (char === '\\') {
|
||||
const size = candidate.content[end + 1] === 'u' ? 6 : 2;
|
||||
if (end + size > candidate.content.length) break;
|
||||
end += size;
|
||||
} else end++;
|
||||
}
|
||||
try {
|
||||
// Incomplete escapes remain buffered, never displayed as raw protocol text.
|
||||
const prose: string = JSON.parse('"' + candidate.content.slice(start + 1, end) + '"');
|
||||
return [text.slice(0, candidate.start).trim(), prose.replace(/[\uD800-\uDBFF]$/, '')]
|
||||
.filter(Boolean).join('\n\n');
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function projectReply(envelope: Record<string, unknown>): TeacherReply {
|
||||
const reply = own(envelope, 'reply') ? envelope.reply : envelope.intro;
|
||||
const suggestions = own(envelope, 'quickReplies') ? envelope.quickReplies : envelope.questions;
|
||||
|
||||
@@ -557,6 +557,10 @@ function ProjectTeacherChatPanel({
|
||||
: <div className="flex flex-col items-end gap-1"><p className="consultation-user max-w-[92%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm px-4 py-3 text-[13px] leading-7">{request.text}</p><MessageTimestamp value={request.createdAt} label="发送时间" className="text-right" /></div>}
|
||||
{request.references.length > 0 && <details className="text-xs text-muted-foreground"><summary>本轮引用 {request.references.length} 项</summary>{request.references.map((ref, index) => <pre className="whitespace-pre-wrap" key={index}>{ref.text}</pre>)}</details>}
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"><TeacherAvatar definition={definition} className="h-6 w-6 rounded-lg" />{definition?.name ?? label}</div>
|
||||
{request.reasoning && <details open={['running', 'preparing'].includes(request.status)} className="min-w-0 text-xs text-muted-foreground" data-testid="teacher-reasoning">
|
||||
<summary className="cursor-pointer">思考过程</summary>
|
||||
<div className="mt-2 whitespace-pre-wrap break-words border-l-2 pl-3 leading-6 [overflow-wrap:anywhere]" aria-label="智能体思考过程">{request.reasoning}</div>
|
||||
</details>}
|
||||
{!!request.toolActivity?.length && <details className="text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">使用了 {request.toolActivity.length} 项工具</summary>
|
||||
<ul className="mt-2 space-y-2" aria-label="智能体工具活动">
|
||||
@@ -573,11 +577,8 @@ function ProjectTeacherChatPanel({
|
||||
{request.intent !== 'check-in' && request.text.trim() && <button type="button" disabled={helpUnavailable || !retryScopeMatches(request)} onClick={() => void send({ text: request.text, intent: request.intent }, request)} className="consultation-retry">重新回答</button>}
|
||||
{!retryScopeMatches(request) && <p className="text-xs text-muted-foreground">回到原项目和操作对话后,可以重新回答。</p>}
|
||||
</div> : request.intent === 'suggestions' ? <>
|
||||
{['running', 'preparing'].includes(request.status)
|
||||
? <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>
|
||||
: request.status === 'completed' && <>
|
||||
<TeacherReply text={request.response} />
|
||||
</>}
|
||||
{request.response && (request.status === 'completed' || request.presentation === 'reply-v1') && <TeacherReply text={request.response} />}
|
||||
{['running', 'preparing'].includes(request.status) && <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>}
|
||||
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-retry">帮我整体看🧠</button>}
|
||||
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{label}正在想…</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
|
||||
<MessageTimestamp value={request.finishedAt} pending={['running', 'preparing'].includes(request.status)} />
|
||||
|
||||
@@ -26,6 +26,8 @@ type ConsultationReply = {
|
||||
suggestedQuestions: string[];
|
||||
replyParseError?: string;
|
||||
unparsedResponse?: string;
|
||||
status?: 'running' | 'completed';
|
||||
reasoning?: string;
|
||||
};
|
||||
|
||||
type TeacherCatalogFixture = {
|
||||
@@ -2447,8 +2449,10 @@ test('teacher stays in the top-right header without legacy check-ins or taking o
|
||||
expect(browserViewportBounds!.y).toBeGreaterThanOrEqual(workInvitationBounds!.y + workInvitationBounds!.height);
|
||||
const workPane = page.getByRole('tabpanel', { name: '作品', exact: true });
|
||||
const workPanel = page.getByTestId('agent-browser-panel');
|
||||
// Account for existing DOM toolbars instead of reserving a full bubble row.
|
||||
expect((await workPanel.boundingBox())!.y - (await workPane.boundingBox())!.y).toBeLessThan(65);
|
||||
// Multiple introductions need their actual height, with only the native-view gap added.
|
||||
const nativeGap = browserViewportBounds!.y - workInvitationBounds!.y - workInvitationBounds!.height;
|
||||
expect(nativeGap).toBeGreaterThanOrEqual(8);
|
||||
expect(nativeGap).toBeLessThanOrEqual(9);
|
||||
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
|
||||
await expect(composer).toHaveValue('我还在写自己的想法');
|
||||
await page.clock.fastForward(300_000);
|
||||
@@ -2661,3 +2665,56 @@ test('work failure offers a useful recovery and retains the student draft after
|
||||
expect(new Set(opens.map((item) => item.body?.request_id)).size).toBe(2);
|
||||
expect(requests.filter((item) => item.path.endsWith('/prompt'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('teacher reasoning and answer stream separately before the final response', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(app);
|
||||
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,
|
||||
}));
|
||||
await installCodingFirstChatHost(app, connection, true, false, false, undefined, false, {
|
||||
response: '', suggestedQuestions: [], status: 'running', reasoning: '先观察项目代码。',
|
||||
});
|
||||
await settleSnapshot(app);
|
||||
await disableCodingEventSource(page);
|
||||
try {
|
||||
await page.reload(); page = await getStableWindow(app);
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
|
||||
const teacher = page.getByTestId('teacher-chat-panel');
|
||||
await teacher.getByRole('textbox', { name: '向智能体提问' }).fill('帮我看看项目');
|
||||
await teacher.getByRole('button', { name: '提问', exact: true }).click();
|
||||
const reasoning = teacher.getByTestId('teacher-reasoning');
|
||||
await expect(reasoning).toHaveAttribute('open');
|
||||
await expect(reasoning.getByText('先观察项目代码。', { exact: true })).toBeVisible();
|
||||
await expect(teacher.getByTestId('teacher-reply')).toHaveCount(0);
|
||||
const send = (await readState(app)).captured.find(item => item.path.endsWith('/messages') && item.method === 'POST')!;
|
||||
const current = await page.evaluate(async path => {
|
||||
const result = await window.electron.ipcRenderer.invoke('hostapi:fetch', { path, method: 'GET' }) as {
|
||||
data: { json: { topic: import('../../shared/coding-teacher').TeacherTopic } };
|
||||
};
|
||||
return result.data.json.topic;
|
||||
}, send.path.replace(/\/messages$/, ''));
|
||||
current.requests[0].reasoning += '再检查交互。'; current.revision++;
|
||||
await emitCodingEvent(page, 'snapshot', current);
|
||||
await expect(reasoning).toContainText('先观察项目代码。再检查交互。');
|
||||
current.requests[0].response = '可以先'; current.revision++;
|
||||
await emitCodingEvent(page, 'snapshot', current);
|
||||
await expect(teacher.getByTestId('teacher-reply')).toHaveText('可以先');
|
||||
await expect(teacher.getByRole('button', { name: '停止回复', exact: true })).toBeVisible();
|
||||
current.requests[0].response = '可以先观察玩家反馈。'; current.revision++;
|
||||
await emitCodingEvent(page, 'snapshot', current);
|
||||
await expect(teacher.getByTestId('teacher-reply')).toHaveText('可以先观察玩家反馈。');
|
||||
await teacher.screenshot({ path: test.info().outputPath('teacher-streaming.png') });
|
||||
current.requests[0].status = 'completed'; current.requests[0].suggestedQuestions = ['怎么观察?']; current.revision++;
|
||||
await emitCodingEvent(page, 'snapshot', current);
|
||||
await expect(reasoning).not.toHaveAttribute('open');
|
||||
await reasoning.locator('summary').click();
|
||||
await expect(reasoning.getByLabel('智能体思考过程')).toBeVisible();
|
||||
await expect(teacher.getByRole('button', { name: '怎么观察?', exact: true })).toBeVisible();
|
||||
await expect(teacher.getByTestId('teacher-reply')).not.toContainText('检查交互');
|
||||
expect((await readState(app)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST')).toHaveLength(1);
|
||||
} finally { await releaseSnapshot(app); }
|
||||
});
|
||||
@@ -759,6 +759,7 @@ it.each(['suggestions', 'reply-v1', 'discussion-v1'] as const)(
|
||||
let firstReads = 0, resumedReads = 0;
|
||||
const text = vi.fn();
|
||||
const activity = vi.fn();
|
||||
const stream = vi.fn();
|
||||
const transport: TeacherCloudTransport = {
|
||||
json: vi.fn(async (url) => {
|
||||
if (url === '/questions') return { request_id: requestId, run_id: 'before-read' };
|
||||
@@ -783,18 +784,31 @@ it.each(['suggestions', 'reply-v1', 'discussion-v1'] as const)(
|
||||
payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }] },
|
||||
});
|
||||
if (url.includes('/before-read/')) {
|
||||
accept('message', { thread_id: 'teacher-thread', payload: { items: [{ stream_event: {
|
||||
type: 'message_delta', message_id: 'preamble', reasoning_content: '需要先读取项目。',
|
||||
} }] } }, '0-1');
|
||||
accept('message', message('teacher-thread', 'preamble', '我先检查重力。'), '1-0');
|
||||
accept('message', message('teacher-thread', 'draft', '{"reply":"读取前的草案"}'), '2-0');
|
||||
} else {
|
||||
accept('message', message('child-thread', 'child', '子线程内容'), '1-0');
|
||||
accept('message', { thread_id: 'teacher-thread', payload: { items: [{ stream_event: {
|
||||
type: 'message_delta', message_id: 'final', reasoning_content: '已经获得项目证据。',
|
||||
} }] } }, '1-1');
|
||||
accept('message', message('teacher-thread', 'final', finalOutput.slice(0, 12)), '2-0');
|
||||
expect(stream).toHaveBeenLastCalledWith({ response: finalOutput.slice(0, 12) });
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
}
|
||||
}),
|
||||
};
|
||||
await prepareCloudTeacher(
|
||||
f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity
|
||||
f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity, stream
|
||||
).run([{ role: 'user', content: '一起讨论' }], new AbortController().signal, text);
|
||||
expect(text.mock.calls).toEqual([[finalOutput]]);
|
||||
expect(stream.mock.calls).toContainEqual([{ response: '' }]);
|
||||
expect(stream.mock.calls).toContainEqual([{ response: finalOutput.slice(0, 12) }]);
|
||||
expect(stream.mock.calls.flatMap(([update]) => update.reasoning ?? []).join(''))
|
||||
.toBe('需要先读取项目。\n\n已经获得项目证据。');
|
||||
expect(JSON.stringify(stream.mock.calls)).not.toContain('子线程内容');
|
||||
expect(activity.mock.calls).toEqual([
|
||||
[{ id: requestId + ':file', name: 'read_project_file', status: 'running' }],
|
||||
[{ id: requestId + ':file', name: 'read_project_file', status: 'completed' }],
|
||||
@@ -871,6 +885,41 @@ it('keeps only the cloud main-thread answer while advancing past child events on
|
||||
expect(streamUrls[1]).toContain('after_seq=2-0');
|
||||
});
|
||||
|
||||
it('streams main-thread reasoning through reconnect and drains a completed run before finalizing', async () => {
|
||||
const f = await fixture();
|
||||
f.topic.requests.push({ id: requestId, text: '检查', references: [], createdAt: 'now',
|
||||
sourceCursor: f.access.source.cursor, sourceCapturedAt: 'now', includedSourceMessageIds: [],
|
||||
omittedMessages: 0, status: 'running', response: '', presentation: 'reply-v1' });
|
||||
let reads = 0;
|
||||
const stream = vi.fn(), text = vi.fn();
|
||||
const transport: TeacherCloudTransport = {
|
||||
json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'one' }
|
||||
: ++reads === 1 ? { status: 'running', thread_id: 'teacher-thread' }
|
||||
: { status: 'completed', thread_id: 'teacher-thread', output: '{"reply":"最后正文","quickReplies":[]}' }),
|
||||
events: vi.fn(async (url, _signal, accept) => {
|
||||
const event = (thread_id: string, message_id: string, fields: Record<string, string>) => ({
|
||||
thread_id, payload: { chunk: { stream_event: { type: 'message_delta', message_id, ...fields } } },
|
||||
});
|
||||
if (url.endsWith('0-0')) {
|
||||
accept('message', event('teacher-thread', 'answer', { reasoning_content: '先看代码。' }), '1-0');
|
||||
expect(stream).toHaveBeenLastCalledWith({ reasoning: '先看代码。' });
|
||||
accept('message', event('child-thread', 'child', { reasoning_content: 'child-private' }), '2-0');
|
||||
throw new Error('stream interrupted');
|
||||
}
|
||||
expect(url).toContain('after_seq=2-0');
|
||||
accept('message', event('teacher-thread', 'answer', { additional_reasoning_content: '再确认结果。' }), '3-0');
|
||||
accept('message', event('teacher-thread', 'answer', { content: '{"reply":"最后' }), '4-0');
|
||||
expect(stream).toHaveBeenLastCalledWith({ response: '{"reply":"最后' });
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
}),
|
||||
};
|
||||
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest,
|
||||
transport, undefined, stream).run([], new AbortController().signal, text);
|
||||
expect(stream.mock.calls.flatMap(([update]) => update.reasoning ?? []).join('')).toBe('先看代码。再确认结果。');
|
||||
expect(text.mock.calls).toEqual([['{"reply":"最后正文","quickReplies":[]}']]);
|
||||
expect(transport.events).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('transmits the frozen per-question model while retaining the teacher version', async () => {
|
||||
const f = await fixture();
|
||||
f.topic.requests.push({ id: requestId, modelId: 'selected-model', teacherVersion: 9, text: '问题', references: [], status: 'running',
|
||||
|
||||
@@ -370,16 +370,27 @@ describe('teacher side chat', () => {
|
||||
expect(screen.getByText('[图片未能显示:坏图片]')).toBeVisible();
|
||||
});
|
||||
|
||||
it('updates streamed Markdown without duplicating the final answer', async () => {
|
||||
const running = { ...first, requests: [request({ status: 'running', response: '## 正在回答\n\n**先检查' })] };
|
||||
it.each(['question', 'suggestions'] as const)('updates streamed %s Markdown once and places final cards after the timestamp', async intent => {
|
||||
const reasoning = '先观察已有代码。';
|
||||
const running = { ...first, requests: [request({ intent, presentation: 'reply-v1', status: 'running',
|
||||
response: '## 正在回答\n\n**先检查', reasoning })] };
|
||||
api.read.mockResolvedValue(running);
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
expect(screen.getByRole('heading', { name: '正在回答' })).toBeVisible();
|
||||
const completed = { ...running, revision: 2, requests: [request({ response: '## 正在回答\n\n**先检查文件**' })] };
|
||||
expect(screen.getByTestId('teacher-reasoning')).toHaveAttribute('open');
|
||||
expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent(reasoning);
|
||||
expect(screen.queryByRole('button', { name: '接着检查' })).toBeNull();
|
||||
const completed = { ...running, revision: 2, requests: [request({ intent, presentation: 'reply-v1', reasoning,
|
||||
response: '## 正在回答\n\n**先检查文件**', suggestedQuestions: ['接着检查'], finishedAt: '2026-09-29T10:00:00Z' })] };
|
||||
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');
|
||||
expect(screen.getAllByRole('button', { name: '接着检查' })).toHaveLength(1);
|
||||
expect(screen.getByTestId('teacher-reasoning')).not.toHaveAttribute('open');
|
||||
const stamp = screen.getByTestId('teacher-chat-panel').querySelector('time[datetime="2026-09-29T10:00:00.000Z"]');
|
||||
expect(stamp).not.toBeNull();
|
||||
expect(stamp!.compareDocumentPosition(screen.getByRole('button', { name: '接着检查' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps tool activity separate from the answer and stops stale running indicators', async () => {
|
||||
|
||||
@@ -1664,6 +1664,39 @@ describe('project consultations with selected cloud teachers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['completed', 'cancelled'] as const)('persists streamed reasoning and prose separately when %s', async terminal => {
|
||||
const f = await fixture({ cloudTeacher: true, mockCloud: true });
|
||||
const scope = { ...f.scope, sourceId: 'project' };
|
||||
const topic = await f.service.create(scope, undefined, undefined, 9);
|
||||
f.replyWith(terminal === 'completed' ? '{"reply":"最终答案","quickReplies":["继续"]}' : '');
|
||||
const requestId = crypto.randomUUID();
|
||||
const seen: TeacherTopic[] = [];
|
||||
const unsubscribe = await f.service.subscribe(scope, topic.id, next => seen.push(next));
|
||||
await f.service.send(scope, topic.id, { requestId, text: '检查项目' });
|
||||
const update = f.prepareCloud.mock.calls[0][8] as (value: { response?: string; reasoning?: string }) => void;
|
||||
update({ reasoning: '先分析当前代码。' });
|
||||
update({ response: '{"reply":"正在生成' });
|
||||
const live = await f.service.read(scope, topic.id);
|
||||
expect(live.requests[0]).toMatchObject({ status: 'running', reasoning: '先分析当前代码。', response: '正在生成' });
|
||||
expect(seen.at(-1)!.requests[0].response).toBe('正在生成');
|
||||
update({ response: '' });
|
||||
expect((await f.service.read(scope, topic.id)).requests[0].response).toBe('');
|
||||
update({ response: '{"reply":"新的正文' });
|
||||
if (terminal === 'cancelled') await f.service.cancel(scope, topic.id, requestId);
|
||||
else f.finish();
|
||||
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe(terminal));
|
||||
unsubscribe();
|
||||
const restarted = await f.restart();
|
||||
const saved = (await restarted.read(scope, topic.id)).requests[0];
|
||||
expect(saved.reasoning).toBe('先分析当前代码。');
|
||||
expect(saved.response).toBe(terminal === 'completed' ? '最终答案' : '新的正文');
|
||||
if (terminal === 'cancelled') expect(saved.unparsedResponse).toBe('{"reply":"新的正文');
|
||||
const nextId = crypto.randomUUID();
|
||||
await restarted.send(scope, topic.id, { requestId: nextId, text: '接着解释' });
|
||||
expect(JSON.stringify(f.run.mock.calls.at(-1)![0])).not.toContain('先分析当前代码。');
|
||||
await restarted.cancel(scope, topic.id, nextId);
|
||||
});
|
||||
|
||||
it.each([false, true])('freezes explicit model without changing teacher definition (cloud=%s)', async cloudTeacher => {
|
||||
const f = await fixture({ cloudTeacher, mockCloud: cloudTeacher });
|
||||
const t = await f.service.create(f.scope, undefined, undefined, cloudTeacher ? 9 : undefined);
|
||||
|
||||
@@ -210,3 +210,28 @@ describe('continuous agent panel', () => {
|
||||
expect(api.sendConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, 'suggestions'] as const)('updates reasoning and answer independently before completion (%s)', async intent => {
|
||||
let current = topic([{ ...request('streaming'), intent, presentation: 'reply-v1', status: 'running', response: '' }]);
|
||||
api.conversation.mockResolvedValue({ topic: current, before: null });
|
||||
api.seenConversation.mockImplementation(async () => current);
|
||||
await mount();
|
||||
await waitFor(() => expect(api.events).toHaveBeenCalled());
|
||||
const emit = async (patch: Partial<TeacherRequest>) => {
|
||||
current = { ...current, revision: current.revision + 1, requests: [{ ...current.requests[0], ...patch }] };
|
||||
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })));
|
||||
};
|
||||
await emit({ reasoning: '先读项目。' });
|
||||
expect(screen.getByTestId('teacher-reasoning')).toHaveAttribute('open');
|
||||
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先读项目。');
|
||||
await emit({ reasoning: '先读项目。再分析玩法。', response: '正在生成正文' });
|
||||
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('正在生成正文');
|
||||
expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('再分析玩法');
|
||||
expect(screen.getByRole('button', { name: '停止回复' })).toBeEnabled();
|
||||
await emit({ status: 'completed', response: '完整正文', suggestedQuestions: ['再讲一个例子'] });
|
||||
expect(screen.getByTestId('teacher-reasoning')).not.toHaveAttribute('open');
|
||||
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('完整正文');
|
||||
expect(screen.getByRole('button', { name: '再讲一个例子' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByText('思考过程'));
|
||||
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('再分析玩法。');
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { previewTeacherReply } from '../../shared/teacher-reply';
|
||||
|
||||
describe('teacher streaming prose', () => {
|
||||
it('decodes text before the envelope or string closes, withholding incomplete escapes', () => {
|
||||
const reply = '先看 "代码"。\n路径 C:\\game\n🍎';
|
||||
const raw = JSON.stringify({ reply, quickReplies: ['下一步'], tool: { reply: 'never-visible' } });
|
||||
const previews = Array.from({ length: raw.length + 1 }, (_, i) => previewTeacherReply(raw.slice(0, i)));
|
||||
for (const preview of previews) {
|
||||
expect(reply.startsWith(preview)).toBe(true);
|
||||
expect(preview).not.toContain('quickReplies');
|
||||
}
|
||||
expect(previews[raw.indexOf('代码') + 2]).toBe('先看 "代码');
|
||||
expect(previews.at(-1)).toBe(reply);
|
||||
expect(previewTeacherReply('{"reply":"你好\\u4')).toBe('你好');
|
||||
expect(previewTeacherReply('{"reply":"你好\\u4e16')).toBe('你好世');
|
||||
});
|
||||
|
||||
it.each(['```json\n', '```JSON\r\n', '~~~json\n', '```makelore-teacher-reply\n', '```\n'])(
|
||||
'streams fenced replies without leaking the protocol (%s)', fence => {
|
||||
const raw = fence + '{"quickReplies":[],"reply":"逐步回答';
|
||||
for (let i = 0; i <= raw.length; i++) {
|
||||
const preview = previewTeacherReply(raw.slice(0, i));
|
||||
expect('逐步回答'.startsWith(preview)).toBe(true);
|
||||
}
|
||||
expect(previewTeacherReply(raw)).toBe('逐步回答');
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts plain text and completed ordinary JSON while excluding nested retired payloads', () => {
|
||||
expect(previewTeacherReply('## 标题\n\n逐步说明')).toBe('## 标题\n\n逐步说明');
|
||||
expect(previewTeacherReply('{"count":1}')).toBe('{"count":1}');
|
||||
expect(previewTeacherReply('{"tool":{"reply":"不要显示"},"reply":"正文')).toBe('正文');
|
||||
expect(previewTeacherReply('{"intro":"旧版正文","questions":[')).toBe('旧版正文');
|
||||
expect(previewTeacherReply('{"reply":"正文","quickReplies":["隐藏')).toBe('正文');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user