From e470bfdea6805437ae25952a50528b9d4d5b7d8f Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sun, 4 Oct 2026 00:54:30 +0800 Subject: [PATCH 1/3] Fix recent composer and upgrade regressions --- docs/daemon-api.md | 7 + .../changes/add-mesh-swarm-mode/design.md | 8 + .../specs/mesh-agent-observability/spec.md | 11 + openspec/changes/add-mesh-swarm-mode/tasks.md | 8 + .../.openspec.yaml | 2 + .../design.md | 25 + .../proposal.md | 23 + .../recent-work-release-integrity/spec.md | 22 + .../tasks.md | 16 + .../verification.md | 24 + .../sync-saved-model-reasoning/design.md | 29 ++ .../sync-saved-model-reasoning/proposal.md | 25 + .../specs/model-selection/spec.md | 46 ++ .../sync-saved-model-reasoning/tasks.md | 37 ++ src/adapters/upgrade/apply.cpp | 55 +- src/adapters/upgrade/diagnostics.cpp | 17 +- src/adapters/upgrade/diagnostics.hpp | 3 + src/adapters/upgrade/macos_bundle.cpp | 7 +- src/adapters/upgrade/package.cpp | 19 +- src/adapters/upgrade/upgrade.cpp | 12 +- src/apps/tui/commands/builtin_commands.cpp | 67 +-- src/apps/tui/commands/tasks_command.cpp | 83 ++++ src/apps/tui/commands/tasks_command.hpp | 8 + src/apps/web/routes/routes_misc.cpp | 77 +-- tests/upgrade/update_job_utf8_test.cpp | 468 ++++++++++++++++++ tests/upgrade/upgrade_diagnostics_test.cpp | 84 ++++ tests/web/web_server_smoke_test.cpp | 194 ++++++++ web/README.md | 1 + web/package.json | 1 + web/pnpm-lock.yaml | 24 + web/src/components/ChatView.jsx | 9 + web/src/lib/markdown.js | 3 + web/src/lib/markdownCjkEmphasis.test.js | 79 +++ web/src/lib/modelReasoningSync.js | 13 +- web/src/lib/modelReasoningSync.test.js | 39 +- web/src/lib/runTests.js | 1 + web/src/lib/sessionTranscript.test.js | 34 ++ web/src/lib/transcriptProjection.js | 57 +-- web/src/lib/transcriptProjection.test.js | 68 +++ 39 files changed, 1539 insertions(+), 167 deletions(-) create mode 100644 openspec/changes/integrate-recent-work-release-0-9-33/.openspec.yaml create mode 100644 openspec/changes/integrate-recent-work-release-0-9-33/design.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-33/proposal.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-33/specs/recent-work-release-integrity/spec.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-33/tasks.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-33/verification.md create mode 100644 openspec/changes/sync-saved-model-reasoning/design.md create mode 100644 openspec/changes/sync-saved-model-reasoning/proposal.md create mode 100644 openspec/changes/sync-saved-model-reasoning/specs/model-selection/spec.md create mode 100644 openspec/changes/sync-saved-model-reasoning/tasks.md create mode 100644 src/apps/tui/commands/tasks_command.cpp create mode 100644 src/apps/tui/commands/tasks_command.hpp create mode 100644 tests/upgrade/update_job_utf8_test.cpp create mode 100644 web/src/lib/markdownCjkEmphasis.test.js diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 4dbbd306c..2288005a3 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -4201,6 +4201,13 @@ Check and job responses include `log_path` when a log was created, and not change the upgrade outcome. Failure text includes the underlying error and available log path so existing clients can display actionable diagnostics. +Check and job responses are always valid UTF-8 JSON. Operating-system and +transport error text (which Windows reports in the ANSI code page, for example +GBK on Chinese systems) is converted to UTF-8 where it is produced, and local +paths are reported in UTF-8. Any byte that still is not valid UTF-8 is replaced +with U+FFFD instead of failing the request, so a failed job with such text in +`error` keeps polling successfully. + ### `POST /api/update/start` Checks for an update and starts one daemon-managed background update job. The diff --git a/openspec/changes/add-mesh-swarm-mode/design.md b/openspec/changes/add-mesh-swarm-mode/design.md index f4a8c698f..5c8952710 100644 --- a/openspec/changes/add-mesh-swarm-mode/design.md +++ b/openspec/changes/add-mesh-swarm-mode/design.md @@ -156,6 +156,14 @@ Payload: - **未做**:未读邮件计数(Codex 无)、面板按深度缩进、来源标签改用完整路径、采样中途因新邮件抢占输出、`` 名册注入、`agent_interrupt` 后的 `` 标记。 - **Web 芯片**:芯片 = 服务端模式(messages 快照 / `session_updated{swarm_mode}`)+ 未提交的本地选择,只有二者不同才随消息提交 `swarm_mode`,避免普通消息把 `/swarm` 刚切的模式改回去;信封在 transcript 摄入时转成系统提示行。 +## 2026-10-03:跨 agent 通知不拆分已处理摘要 + +`presentInterAgentMessage` 已将信封转换成系统提示行,但完成回合的投影把每条系统行都当作分段边界;通知间的工具和 assistant 活动分别生成「已处理」,并重复使用同一条 `turn_timing` 总耗时。 + +完成回合的活动收集改为:识别结构化 `metadata.inter_agent` 的系统通知,保留其顶层位置及展开正文,同时让通知两侧的活动归入同一摘要。摘要锚定第一条被折叠活动的位置,内部活动顺序不变,通知彼此的顺序不变;通知不重复收进摘要。真实用户消息、提问卡片、图片及其它系统提示仍保持原来的分段边界。实时活动、关闭自动折叠、原始会话记录及后端投递行为保持原有契约。 + +定向回归覆盖 MESSAGE / FINAL_ANSWER / NEW_TASK、持久总耗时、普通完成与 task_complete 两条路径,以及实时事件、历史加载、transcript_replace 的一致性。 + ## Open Questions - 移植的角色提示词具体措辞(在实现时对照 Codex 源文本逐段改写,不影响规格与任务拆分)。 diff --git a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md index 37c8f13c5..4d8e7964a 100644 --- a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md +++ b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md @@ -30,6 +30,17 @@ root 会话的后台任务面板 SHALL 列出该树的全部子 agent(任意 - **THEN** 信封行以相同的独立样式回放 - **AND** 该会话的摘要仍为其最后一条真实用户消息 +#### Scenario: 同一回合的多条通知不重复生成已处理摘要 +- **WHEN** 一个已完成回合的工具与 assistant 活动之间穿插多条跨 agent 通知,且没有其它必须独立分段的项目 +- **THEN** Web/Desktop SHALL 仅生成一条「已处理」活动摘要,总耗时只显示一次 +- **AND** 各通知 SHALL 仍按原顺序独立展示并可展开正文,摘要详情保留全部被折叠活动 +- **AND** 实时事件完成、历史加载及 transcript_replace SHALL 得到相同结果 + +#### Scenario: 其它可见边界不被跨 agent 通知合并 +- **WHEN** 通知附近存在提问卡片、图片、普通系统提示或下一条真实用户消息 +- **THEN** 系统 SHALL 保持这些项目原有的分段与展示行为 +- **AND** 关闭自动折叠时 SHALL 继续按原顺序显示活动与通知 + ### Requirement: 任意层级的权限请求与提问冒泡到 root 界面 任意深度子 agent 的权限请求与 AskUserQuestion SHALL 出现在 root 会话所在的界面(Web/Desktop 全局弹窗、TUI 确认/提问浮层),来源标签 SHALL 为该 agent 的任务名(会话标题);用户的回答 SHALL 路由回发起请求的 agent。 diff --git a/openspec/changes/add-mesh-swarm-mode/tasks.md b/openspec/changes/add-mesh-swarm-mode/tasks.md index 140cf1062..2b404c9eb 100644 --- a/openspec/changes/add-mesh-swarm-mode/tasks.md +++ b/openspec/changes/add-mesh-swarm-mode/tasks.md @@ -73,3 +73,11 @@ - [x] 9.2 单元测试中文注释;`acecode_unit_tests` fast profile 全绿,`pnpm test` / `pnpm build` 通过 - [x] 9.3 `openspec validate add-mesh-swarm-mode --strict` 通过;变更文档随分支强制加入版本库(`openspec/` 被 ignore) - [ ] 9.4 帮助页截图 AD-03 仍是旧菜单(只有一个「蜂群模式」),需补拍 + +## 10. 蜂群通知穿插时的已处理摘要修复(2026-10-03) + +- [x] 10.1 复现通知拆分已处理摘要的问题,修正完成回合投影,保留通知正文、活动详情与原有非通知边界 +- [x] 10.2 增加持久耗时、实时事件、历史加载及 transcript_replace 回归,覆盖 task_complete 与关闭自动折叠 +- [x] 10.3 运行定向测试、Web 全量测试、构建、OpenSpec 严格验证与差异检查,记录结果 + +验证记录:修复前定向用例复现同一回合 4 条已处理摘要(期望 1 条);修复后 `transcriptProjection.test.js`、`sessionTranscript.test.js` 通过。`pnpm test` 通过(2981 项),`pnpm build` 及正则兼容检查通过,`openspec validate add-mesh-swarm-mode --strict`、`git diff --check` 通过。Headless Edge 使用实际 TranscriptItems / Message / ActivityLine 组件验证 1280px 浅色折叠态与 720px 深色展开态:1 条已处理、3 条独立通知、全部通知正文和 4 条活动详情可展开,无横向溢出及脚本异常。未重新编译或替换已安装 Desktop。 diff --git a/openspec/changes/integrate-recent-work-release-0-9-33/.openspec.yaml b/openspec/changes/integrate-recent-work-release-0-9-33/.openspec.yaml new file mode 100644 index 000000000..f98e139fb --- /dev/null +++ b/openspec/changes/integrate-recent-work-release-0-9-33/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-04 diff --git a/openspec/changes/integrate-recent-work-release-0-9-33/design.md b/openspec/changes/integrate-recent-work-release-0-9-33/design.md new file mode 100644 index 000000000..f654da8ee --- /dev/null +++ b/openspec/changes/integrate-recent-work-release-0-9-33/design.md @@ -0,0 +1,25 @@ +## Context + +45 个注册 worktree 的状态、最近提交、文件时间及当前 master 覆盖情况已盘点。两个近期脏 worktree 分别为 cranky-jemison-14c349 与 ecstatic-dirac-7e2088;它们的 HEAD 已在 master,未提交差异只有部分已被此前复制整合。近期 reflog 中 preview-mesh-sync 暂存快照主要由 329faf92 和蜂群提交覆盖,需逐项核对避免重复应用。 + +## Goals / Non-Goals + +目标:完整整合有效改动,保留原有行为,修复发布阻断,并从唯一最终 master 提交生成完整发布。非目标:清理旧 worktree、重写历史、改变未涉及的功能、把尚未完成的 Claude 工作当成完成。 + +## Decisions + +1. 对比 worktree HEAD、当前 master 与脏文件做三方合并;源文件哈希核对后才写当前工作区。已有 UTF-8 修复不重复提交,冲突处保留公共安全 JSON 序列化与所有独立回归用例。 +2. `/tasks` 按现有独立命令模块模式拆出注册函数,保留原来的注册位置;不抬高 2060 行基线,不删除行为绕过检查。 +3. 使用新的 0.9.33 标签,不覆盖失败的旧版本标签。受保护 master 通过既有 PR 流程交付,最终同一提交的测试通过后才创建标签并发布。 +4. 六种更新 ZIP、双架构 macOS 签名公证 PKG 及完整 18 项下载产物均须存在并校验。更新 manifest 保留 Linux 能力版本目标,PKG 不混入自动更新项。 +5. 发布完成后读取 Claude 的运行/任务记录核实终态;只有明确结束且无在途工作才执行用户授权的关机。 + +## Risks / Trade-offs + +- 多处补丁已部分合入:三方合并及逐文件比较防止丢失较新行为。 +- 本地构建目录的可执行文件仍被桌面占用:优先构建独立测试目标,构建发布可执行文件前核对进程任务状态,仅停止明确需要释放的实例。 +- macOS 凭据或跨平台 CI 仍可能失败:保留标签/资产,修复具体原因,不把部分发布报告为完整。 + +## Migration Plan + +无配置结构迁移。本轮未新增种子资源,仍需比较最近正式版到最终提交的 seed 内容和协调逻辑,并按实际差异执行升级检查。 diff --git a/openspec/changes/integrate-recent-work-release-0-9-33/proposal.md b/openspec/changes/integrate-recent-work-release-0-9-33/proposal.md new file mode 100644 index 000000000..85ec86bc5 --- /dev/null +++ b/openspec/changes/integrate-recent-work-release-0-9-33/proposal.md @@ -0,0 +1,23 @@ +## Why + +用户要求整合 2026-10-03 00:38:52 至发布开始时近 24 小时内的主工作区及 worktree 改动,发布正式版,并在发布完成且 Claude 工作结束后关机。盘点发现升级 UTF-8 错误处理和诊断去重仍有未合入部分;当前 master 的 TUI 内置命令文件超过尺寸棘轮,阻断原生 CI。 + +## What Changes + +- 保留已集成的代码,仅三方合入两处 worktree 的升级修复及回归测试,连同主工作区的新对话思考深度、中文 Markdown 强调、蜂群耗时摘要修复一起交付。 +- 将 `/tasks` 命令实现移到同层独立模块,保留注册顺序及行为,恢复严格分层与尺寸检查。 +- 升级版本至 0.9.33,完成本机、CI、签名、公证、完整产物及更新服务验证。 + +## Capabilities + +### New Capabilities + +- `recent-work-release-integrity`: 最近改动整合和完整正式发布的可追溯验证。 + +### Modified Capabilities + +无。 + +## Impact + +涉及升级组件、升级 HTTP 接口和测试、TUI 命令组织、既有前端修复以及发布版本和记录。保持旧分支/worktree 不变,不删除旧引用;不覆盖并发编辑。 diff --git a/openspec/changes/integrate-recent-work-release-0-9-33/specs/recent-work-release-integrity/spec.md b/openspec/changes/integrate-recent-work-release-0-9-33/specs/recent-work-release-integrity/spec.md new file mode 100644 index 000000000..93db795d8 --- /dev/null +++ b/openspec/changes/integrate-recent-work-release-0-9-33/specs/recent-work-release-integrity/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: 最近工作完整整合 +系统 SHALL 将用户指定时间窗口内有效且未整合的代码合入最终 master,同时保留其后的主线行为及全部相关回归测试。 + +#### Scenario: 同一修复已部分合入 +- **WHEN** worktree 中部分修改已在 master,另有后续补充 +- **THEN** 仅集成缺失修改,合并重叠测试,并记录来源与校验结果 + +### Requirement: 命令拆分保持行为 +TUI SHALL 在同一注册顺序下提供既有 `/tasks` 列出和中止行为,并通过严格尺寸和所有权检查。 + +#### Scenario: 内置命令文件超过基线 +- **WHEN** `/tasks` 被拆入独立命令模块 +- **THEN** 用户可见命令及行为不变,文件低于既有基线且无需新增豁免 + +### Requirement: 完整发布之后关机 +正式版 SHALL 从验证过的最终 master 生成并完整镜像到更新服务;关机 MUST 等待发布完成且 Claude 任务确认结束。 + +#### Scenario: 产物或任务尚未完成 +- **WHEN** 任一必需产物缺失、验证失败或 Claude 仍在工作 +- **THEN** 不报告完整发布成功,不执行关机 diff --git a/openspec/changes/integrate-recent-work-release-0-9-33/tasks.md b/openspec/changes/integrate-recent-work-release-0-9-33/tasks.md new file mode 100644 index 000000000..ec7a681f8 --- /dev/null +++ b/openspec/changes/integrate-recent-work-release-0-9-33/tasks.md @@ -0,0 +1,16 @@ +## 1. 整合 + +- [x] 1.1 盘点近 24 小时 master、worktree、远端和 reflog,定位 CI 阻断。 +- [x] 1.2 整合升级 UTF-8、诊断去重及主工作区修复,核对来源覆盖及冲突。 +- [ ] 1.3 拆出 TUI tasks 命令并恢复严格检查,完成对应本机测试。 + +## 2. 发布 + +- [ ] 2.1 审核 seed 差异、执行必要升级兼容验证,完成前端及本机测试。 +- [ ] 2.2 更新版本及说明,提交全部有效改动并通过 PR 合入受保护 master。 +- [ ] 2.3 确认最终提交 CI 通过,发布 0.9.33,校验完整 18 项下载与六种更新 ZIP、两个 PKG 的镜像及公网哈希。 + +## 3. 收尾 + +- [ ] 3.1 核对 master 同步、近期 worktree 覆盖及发布证据。 +- [ ] 3.2 检查 Claude 工作状态,完成后执行用户授权的关机。 diff --git a/openspec/changes/integrate-recent-work-release-0-9-33/verification.md b/openspec/changes/integrate-recent-work-release-0-9-33/verification.md new file mode 100644 index 000000000..763d7a62c --- /dev/null +++ b/openspec/changes/integrate-recent-work-release-0-9-33/verification.md @@ -0,0 +1,24 @@ +# 0.9.33 发布验证 + +## 整合范围 + +时间窗口:2026-10-03 00:38:52 至 2026-10-04 发布执行期间(Asia/Taipei)。初始 master / origin/master 同为 119de292,0 个落后或超前提交;45 个注册 worktree 中,近期脏改动来自主工作区及两个 Claude worktree。 + +- `cranky-jemison-14c349`:HEAD 12f31951 已在 master。网络错误 UTF-8、清单解析和检查用例已被 329faf92 集成;补入剩余安装、解压、路径、任务接口的 UTF-8 处理和对应测试。 +- `ecstatic-dirac-7e2088`:HEAD 329faf92 已在 master。补入升级诊断不可用说明去重及单元/HTTP 测试。 +- 主工作区:新对话思考深度声明恢复、中文 Markdown 强调、蜂群通知不重复生成已处理耗时摘要及相关文档测试。 +- 近期 worktree 提交均已在 master。reflog 快照 `7b17f3d73` 的斜杠命令及会话 UI 改动由 329faf92 和后续蜂群提交覆盖;较新 AGENTS.md 命名和蜂群命令行为保留,不重新应用旧快照。 +- 源 worktree 保留,合并前核对源文件 SHA-256;三方合并冲突位于升级响应序列化和 HTTP 测试追加位置,保留公共 UTF-8 安全序列化及双方所有测试。 +- 原先严格 CI 失败:`builtin_commands.cpp` 2069 行,超过既有上限 2060。`/tasks` 实现迁到同层 `tasks_command.cpp`,注册顺序与行为保持,未放宽上限或所有权规则。 + +## 已完成检查 + +- 前端全部修复在同一工作区通过 `pnpm test`、`pnpm build`;真实 ChatView 浏览器 fixture 验证宽屏/窄屏思考深度显示、选择传递、后台同步失败及草稿保留。 +- 分层/尺寸和最终所有权严格检查通过;OpenSpec 三个相关变更严格验证通过。 +- `tests/scripts/verify_release_assets_test.py`:6 项通过。 +- `assets/models_dev/MANIFEST.json` 快照为 2026-09-29,满足 30 天要求;未改模型目录或图标输入。 +- 对比最近正式版 v0.9.30 到整合前 HEAD:`assets/seed`、`src/domain/skills/default_skill_seeder.cpp`、`tests/skills` 无差异;seed 与 manifest 均为 2026-09-28.1,无新种子迁移。 + +## 待完成发布验证 + +本机原生测试、最终提交 CI、完整签名公证产物、更新镜像及公网校验结果在后续发布执行记录中补齐;本文件不预先声明发布成功。 diff --git a/openspec/changes/sync-saved-model-reasoning/design.md b/openspec/changes/sync-saved-model-reasoning/design.md new file mode 100644 index 000000000..f009f3ca1 --- /dev/null +++ b/openspec/changes/sync-saved-model-reasoning/design.md @@ -0,0 +1,29 @@ +## Context + +见 proposal.md。当前 ProviderCatalogPicker 仅把探测结果应用到编辑草稿,刷新按钮调用本地 GET /api/models;聊天控件依赖保存的 supported_efforts。配置已有 mutate_config 事务与 saved-model revision,会话具备安全重载机制。 + +## Goals / Non-Goals + +**Goals:** 三个入口复用有生命周期的后台同步器,连接去重、可取消、静默失败并保证并发写入一致性。 + +**Non-Goals:** 不推测模型名称对应的档位,不替换 models.dev 目录刷新,不新增自动重试或定时轮询,不为不支持现有 OpenAI 兼容探测协议的受管 Provider 构造新协议。 + +## Decisions + +1. Web 服务持有单一工作线程,启动、保存、认证的 POST /api/models/reasoning/refresh 只排队,立即返回。相同连接以端点、凭据、请求头和目录身份去重;并发请求合并,后续保存可排下一轮。使用网络超时与取消回调,退出时 join,不使用捕获服务对象的 detached 线程。 +2. 复用现有模型声明解析和请求头/代理逻辑。仅接受有效且非空的 supported_efforts,缺失或无效保留本地声明。OpenAI 兼容完整聊天 URL 不推导 /models;保留原配置。受管 Provider 和 Anthropic 不使用 OpenAI 探测协议。 +3. 请求前复制模型快照,IO 期间不持有配置锁。返回后在配置锁和 mutate_config 事务内重读最新磁盘配置,逐项结构比较快照,仅合并未变化项的 reasoning 与 capability 标记。保留 enabled、仍有效的 effort 和兼容的预算;其他字段不变。 +4. 写入成功后发布模型 revision,复用会话重载机制刷新空闲会话。WebSocket 发出不含密钥的 model_profiles_updated 事件,模型设置静默重读列表,App 更新现有 modelProfileRevision,使聊天输入框刷新。 +5. 保持当前按钮布局,采用既有 ACECode 前端样式。自动同步不显示进度或失败 toast;手动刷新也在后台执行,不清空列表。 +6. 新对话已有思考深度控件和创建会话参数传递;缺口是模型列表可能缺少声明,而已有会话仍保留自己的声明快照。进入新对话时独立调度一次后台同步,effect 只依赖 API 及会话身份,不依赖模型版本通知,避免通知反复触发远端请求。输入框刷新按钮同时调度同步并立即重读本地列表;同步成功仍复用既有通知刷新控件,网络失败不阻塞模型显示、输入或发送。选择的档位仅传给新会话,不修改模型默认档位。 + +## Risks / Trade-offs + +- 远端列表不完整 → 缺失声明不撤销本地能力,只有有效新声明参与同步。 +- 并发编辑覆盖 → 对磁盘最新配置逐项检查请求快照,变化项跳过,由保存入口的新请求补齐。 +- 多个连接串行慢请求 → 每次请求有超时且可取消;同一连接只请求一次,不阻塞界面。 +- 已在生成的会话 → 遵守现有会话控制门限,配置版本更新于安全边界应用。 + +## Migration Plan + +无需配置格式迁移。下次服务启动或点击刷新即可补齐旧模型档位;回滚代码后已写入的标准 reasoning 字段仍可被旧版本读取。 diff --git a/openspec/changes/sync-saved-model-reasoning/proposal.md b/openspec/changes/sync-saved-model-reasoning/proposal.md new file mode 100644 index 000000000..556f00424 --- /dev/null +++ b/openspec/changes/sync-saved-model-reasoning/proposal.md @@ -0,0 +1,25 @@ +## Why + +已保存的 ACEModel 档位可能长期停留在旧配置,刷新按钮仅重新读取本地列表;用户必须进入探测弹窗逐个重新选择才能恢复思维深度。需要在刷新、保存和启动时自动同步远端声明,且网络失败不影响正常使用。 + +## What Changes + +- 已保存模型的刷新按钮一次触发全部可探测连接的远端 effort 同步。 +- Web/Desktop 模型新增或编辑保存后、服务启动后异步同步已保存模型,立即返回,不展示自动同步失败。 +- 进入新对话及点击聊天输入框的模型刷新按钮时也触发后台同步,补齐启动时未成功获取的思考深度声明。 +- 同一连接合并请求;有效远端声明写回配置并通知当前页面与会话,缺失或无效声明保留原值。 +- 保留用户显式启停和仍有效的档位选择;并发编辑、删除、连接变更时丢弃旧结果。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `model-selection`: 增加已保存模型远端推理档位的后台同步与一致性约束。 + +## Impact + +涉及模型探测/配置持久化、Web 服务生命周期和模型管理路由、前端模型设置与聊天模型状态通知。新增认证的异步同步端点和 WebSocket 变更事件;复用现有 OpenAI 兼容 `/models` 声明与会话模型版本刷新,不新增依赖。 diff --git a/openspec/changes/sync-saved-model-reasoning/specs/model-selection/spec.md b/openspec/changes/sync-saved-model-reasoning/specs/model-selection/spec.md new file mode 100644 index 000000000..d603f2797 --- /dev/null +++ b/openspec/changes/sync-saved-model-reasoning/specs/model-selection/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: 已保存模型推理档位后台同步 + +系统 SHALL 在 Web/Desktop 服务启动后、模型新增或编辑保存后以及用户点击已保存模型刷新按钮时异步同步可探测模型的远端 effort 声明。远端请求 MUST 不阻塞启动或保存;自动同步失败 MUST 无界面提示并保留已有配置。 + +系统 SHALL 在进入新对话及点击聊天输入框的模型刷新按钮时复用后台同步,且 MUST 不等待远端结果才展示本地模型或允许输入、发送。 + +#### Scenario: 新对话补齐缺失的思考深度 +- **WHEN** 用户进入新对话,已保存模型缺少思考深度声明 +- **THEN** 系统自动调度一次后台同步,并立即显示本地模型及输入框 +- **AND** 有效声明同步成功后,新对话显示模型声明的思考深度,可选择并传入新建会话 +- **AND** 同步通知仅重读模型状态,不再次调度远端同步;显式关闭或不支持思考深度的模型仍隐藏控件 + +#### Scenario: 输入框刷新修复缺失声明 +- **WHEN** 用户点击新对话或已有会话输入框中的模型刷新按钮 +- **THEN** 系统在刷新本地模型状态的同时调度远端声明同步 +- **AND** 网络失败时保留当前档位及输入内容,既有会话模型重载行为保持不变 + +#### Scenario: 一次刷新全部保存模型 +- **WHEN** 用户点击模型设置中的刷新按钮 +- **THEN** 系统后台拉取全部已保存可探测连接的模型声明,同一连接合并请求,并更新对应已保存模型的档位 +- **AND** 用户仍能使用现有模型和编辑设置 + +#### Scenario: 保存后与启动后静默同步 +- **WHEN** 服务启动完成或模型保存成功 +- **THEN** 系统自动调度后台同步,无需用户打开探测选择弹窗 +- **AND** 网络失败、无对应模型、缺失或非法 effort 声明时保留原值且无提示 + +### Requirement: 推理同步保留用户设置及并发一致性 + +同步 SHALL 仅更新有效声明和相应推理能力标记,保留用户显式启停、仍受支持的 effort 与其他模型字段。同步 MUST 丢弃请求开始后被编辑或删除的模型的旧结果,不得恢复已删除模型或覆盖新连接;成功后 SHALL 更新模型列表及空闲会话,运行中的生成安全延后应用。 + +#### Scenario: 填补旧模型空档位 +- **WHEN** Moonlight、Starrylight 或 Aurora 当前缺少档位而远端返回有效声明 +- **THEN** 对应已保存模型获得声明的档位,未显式关闭推理时聊天输入区可选择思维深度 + +#### Scenario: 用户覆盖与过期响应 +- **WHEN** 用户已明确关闭推理或选择仍有效的档位 +- **THEN** 同步保留这些覆盖值 +- **WHEN** 请求过程中用户修改、重命名或删除模型 +- **THEN** 旧响应不覆盖用户的新配置 + +#### Scenario: 无变化与安全退出 +- **WHEN** 远端声明与本地相同或服务正在退出 +- **THEN** 不重复写配置、不发送无意义变更通知,并取消网络请求后安全结束后台任务 diff --git a/openspec/changes/sync-saved-model-reasoning/tasks.md b/openspec/changes/sync-saved-model-reasoning/tasks.md new file mode 100644 index 000000000..2cf09e5f0 --- /dev/null +++ b/openspec/changes/sync-saved-model-reasoning/tasks.md @@ -0,0 +1,37 @@ +## 1. 后台同步核心 + +- [x] 1.1 实现连接分组、有效声明合并、用户覆盖与过期快照保护,并通过定向 C++ 单测。 +- [x] 1.2 实现可取消的后台队列及网络边界,以注入探测器验证非阻塞、合并请求、失败保留和退出。 + +## 2. 触发及界面联动 + +- [x] 2.1 接入服务启动、模型保存和认证刷新端点,持久化后发布版本与通知;通过路由/集成验证。 +- [x] 2.2 刷新按钮接入远端同步,设置及聊天消费通知且静默处理失败;通过定向前端检查。 +- [x] 2.3 更新 daemon API 文档,核对事件和接口与实现一致。 + +## 3. 完整验证 + +- [x] 3.1 完成相关 C++ 测试、pnpm test、pnpm build、OpenSpec 严格验证及 git diff --check,复核无关改动保留。 + +## 4. 新对话思考深度修复(2026-10-04) + +- [x] 4.1 进入新对话和输入框刷新时非阻塞调度声明同步,通知不形成重复请求,保留现有选择及创建参数链路。 +- [x] 4.2 补充缺失声明恢复、静默失败及新会话选择传递回归验证,完成浏览器交互检查。 +- [x] 4.3 完成 pnpm test、pnpm build、严格 OpenSpec 验证及差异检查,记录运行中桌面与源代码验证范围。 + +## 2026-10-04 修复验证 + +- 当前桌面 daemon 的已保存 Starrylight / Moonlight 列表没有 reasoning,部分已有会话仍保留有效声明。通过既有后台刷新端点后,列表及配置恢复 low / medium / high / xhigh / max,默认 medium;未手工推断或编写档位。 +- modelReasoningSync、modelReasoning、sessionModel 定向测试通过,覆盖缺失声明恢复、同步失败、通知不重复发起同步、支持范围及创建参数传递。 +- Headless Chromium 加载真实 ChatView,使用隔离 API fixture 验证新对话自动同步、通知恢复控件且不循环请求、宽屏 1331px / 窄屏 390px 档位选择、输入保留、同步失败静默、输入框刷新及新会话请求 reasoning_effort。浏览器无 pageerror;截图在本机临时目录 ace-home-reasoning-desktop.png / ace-home-reasoning-narrow.png。 +- pnpm test、pnpm build、openspec validate sync-saved-model-reasoning --strict 和 git diff --check 全部通过;Impeccable 检测无新增问题。 +- 本次构建更新 web/dist;当前桌面通过 static-dir 使用该目录,下次页面加载读取新前端。未重启桌面、未替换原生可执行文件;自动补齐链路的交互验收来自当前源码浏览器 fixture,不宣称已在正在显示的旧桌面页面实测。 + +## 原验证记录 + +- C++ Release 单测目标构建成功;SavedModelReasoningSync、ModelsHandler、SavedModels、会话模型绑定及相关 HTTP 探测测试共 157 项通过。 +- 真实本地 HTTP 集成测试验证启动与保存不等待远端、同连接合并请求、刷新全部模型、档位落盘及失败保留。 +- modelReasoningSync、modelReasoning、modelSettings、sessionModel、customCompatibilityModelFormArchitecture 定向前端测试通过。 +- pnpm build 通过;OpenSpec 严格验证与 git diff --check 通过。 +- pnpm test 已执行,受现有 sidebarAlignmentArchitecture.test.js:22 断言失败阻塞。该断言在本次未修改的已暂存 Sidebar.jsx 上同样不匹配;保留原有侧栏工作。 +- 本次验证使用新构建单测中的 WebServer 与临时配置;未替换或重启用户正在运行的桌面实例。 diff --git a/src/adapters/upgrade/apply.cpp b/src/adapters/upgrade/apply.cpp index 3a04feb80..4cb69b9e0 100644 --- a/src/adapters/upgrade/apply.cpp +++ b/src/adapters/upgrade/apply.cpp @@ -4,6 +4,7 @@ #include "platform/terminal/console.hpp" #include "diagnostics.hpp" #include "package.hpp" +#include "utils/encoding.hpp" #include "utils/paths.hpp" #include "utils/utf8_path.hpp" @@ -39,6 +40,10 @@ namespace { constexpr const char* kUpdateRunnerDirName = ".acecode-update-runner"; +// 这里的错误文本会经 run_upgrade_command 成为 GUI 升级任务的 job.error。MSVC 的 +// ec.message() 与 path::string() 走 ANSI 代码页(中文 Windows 为 GBK):OS 文本 +// 单独 ensure_utf8 后再拼接,路径一律 path_to_utf8。不要对拼好的整串 ensure_utf8, +// 串里已是 UTF-8 的中文路径会被按 GBK 重新解码成乱码。 void record_file_operation(DiagnosticLog* diagnostics, const char* operation, const fs::path& source, const fs::path& destination, const std::error_code& error) { @@ -46,7 +51,7 @@ void record_file_operation(DiagnosticLog* diagnostics, const char* operation, diagnostics->record(operation, {{"source", path_to_utf8(source)}, {"destination", path_to_utf8(destination)}, {"error_code", error.value()}, {"error_category", error.category().name()}, - {"error", error ? error.message() : std::string{}}}); + {"error", error ? ensure_utf8(error.message()) : std::string{}}}); } bool is_same_or_inside(const fs::path& maybe_child, const fs::path& maybe_parent) { @@ -93,7 +98,7 @@ bool staged_paths_avoid_user_data(const fs::path& install_dir, } if (error) { *error = "refusing to update a package path that overlaps " - "ACECode user data: " + target.string(); + "ACECode user data: " + path_to_utf8(target); } return false; }; @@ -108,12 +113,15 @@ bool collect_staged_paths(const fs::path& content_root, std::error_code ec; for (const auto& entry : fs::recursive_directory_iterator(content_root, ec)) { if (ec) { - if (error) *error = "failed to walk staged files: " + ec.message(); + if (error) *error = "failed to walk staged files: " + ensure_utf8(ec.message()); return false; } fs::path rel = fs::relative(entry.path(), content_root, ec); if (ec || rel.empty()) { - if (error) *error = "failed to compute staged relative path: " + ec.message(); + if (error) { + *error = "failed to compute staged relative path: " + + ensure_utf8(ec.message()); + } return false; } if (entry.is_directory()) { @@ -142,21 +150,22 @@ bool backup_existing_path(const fs::path& install_dir, record_file_operation(diagnostics, "backup_directory", {}, dest.parent_path(), ec); if (ec) { if (error) *error = "failed to create backup directory " + - dest.parent_path().string() + ": " + ec.message(); + path_to_utf8(dest.parent_path()) + ": " + + ensure_utf8(ec.message()); return false; } fs::remove_all(dest, ec); record_file_operation(diagnostics, "backup_clear", {}, dest, ec); if (ec) { - if (error) *error = "failed to clear backup path " + dest.string() + ": " + - ec.message(); + if (error) *error = "failed to clear backup path " + path_to_utf8(dest) + ": " + + ensure_utf8(ec.message()); return false; } fs::rename(src, dest, ec); record_file_operation(diagnostics, "backup_move", src, dest, ec); if (ec) { - if (error) *error = "failed to move " + src.string() + " to " + - dest.string() + ": " + ec.message(); + if (error) *error = "failed to move " + path_to_utf8(src) + " to " + + path_to_utf8(dest) + ": " + ensure_utf8(ec.message()); return false; } backed_up.push_back(rel); @@ -180,8 +189,8 @@ bool prepare_package_directories(const fs::path& install_dir, fs::create_directories(dest, ec); record_file_operation(diagnostics, "install_directory", {}, dest, ec); if (ec) { - if (error) *error = "failed to create directory " + dest.string() + ": " + - ec.message(); + if (error) *error = "failed to create directory " + path_to_utf8(dest) + ": " + + ensure_utf8(ec.message()); return false; } } @@ -206,14 +215,15 @@ bool copy_package_files(const fs::path& content_root, record_file_operation(diagnostics, "install_parent_directory", {}, dest.parent_path(), ec); if (ec) { if (error) *error = "failed to create directory " + - dest.parent_path().string() + ": " + ec.message(); + path_to_utf8(dest.parent_path()) + ": " + + ensure_utf8(ec.message()); return false; } fs::copy_file(src, dest, fs::copy_options::overwrite_existing, ec); record_file_operation(diagnostics, "install_copy", src, dest, ec); if (ec) { - if (error) *error = "failed to copy " + src.string() + " to " + - dest.string() + ": " + ec.message(); + if (error) *error = "failed to copy " + path_to_utf8(src) + " to " + + path_to_utf8(dest) + ": " + ensure_utf8(ec.message()); return false; } } @@ -415,12 +425,12 @@ bool prepare_update_runner(const fs::path& current_exe, std::error_code ec; fs::create_directories(runner_path.parent_path(), ec); if (ec) { - if (error) *error = "failed to create runner directory: " + ec.message(); + if (error) *error = "failed to create runner directory: " + ensure_utf8(ec.message()); return false; } fs::copy_file(current_exe, runner_path, fs::copy_options::overwrite_existing, ec); if (ec) { - if (error) *error = "failed to copy update runner: " + ec.message(); + if (error) *error = "failed to copy update runner: " + ensure_utf8(ec.message()); return false; } return true; @@ -513,13 +523,13 @@ bool apply_staged_update(const fs::path& staging_dir, fs::create_directories(backup_dir, ec); record_file_operation(diagnostics, "install_backup_directory", {}, backup_dir, ec); if (ec) { - if (error) *error = "failed to create backup directory: " + ec.message(); + if (error) *error = "failed to create backup directory: " + ensure_utf8(ec.message()); return false; } fs::create_directories(install_dir, ec); record_file_operation(diagnostics, "install_root_directory", {}, install_dir, ec); if (ec) { - if (error) *error = "failed to create install directory: " + ec.message(); + if (error) *error = "failed to create install directory: " + ensure_utf8(ec.message()); return false; } @@ -577,20 +587,21 @@ int run_apply_update_command(const std::vector& args, opts->backup_dir, target, &apply_error, &diagnostics)) { diagnostics.record("apply_finished", {{"exit_code", 1}, {"error", apply_error}}); err << diagnostics.with_location("acecode update apply failed: " + apply_error) << "\n" - << "Backup directory: " << opts->backup_dir.string() << "\n"; + << "Backup directory: " << path_to_utf8(opts->backup_dir) << "\n"; prompt_press_any_key_if_interactive(out); return 1; } out << styled(out, ConsoleStyle::Cyan, "[3/3] Finalizing") << "\n" << styled(out, ConsoleStyle::Green, "ACECode update applied successfully.") << "\n" - << "Backup directory: " << opts->backup_dir.string() << "\n"; + << "Backup directory: " << path_to_utf8(opts->backup_dir) << "\n"; diagnostics.record("apply_finished", {{"exit_code", 0}, {"backup", path_to_utf8(opts->backup_dir)}}); prompt_press_any_key_if_interactive(out); return 0; } catch (const std::exception& e) { - diagnostics.record("apply_finished", {{"exit_code", 1}, {"error", e.what()}}); - err << diagnostics.with_location(std::string("acecode update apply exception: ") + e.what()) << "\n"; + const std::string exception_text = ensure_utf8(e.what()); + diagnostics.record("apply_finished", {{"exit_code", 1}, {"error", exception_text}}); + err << diagnostics.with_location("acecode update apply exception: " + exception_text) << "\n"; } catch (...) { diagnostics.record("apply_finished", {{"exit_code", 1}, {"error", "unknown exception"}}); err << diagnostics.with_location("acecode update apply: unknown exception") << "\n"; diff --git a/src/adapters/upgrade/diagnostics.cpp b/src/adapters/upgrade/diagnostics.cpp index 27a66acf7..e24f1f029 100644 --- a/src/adapters/upgrade/diagnostics.cpp +++ b/src/adapters/upgrade/diagnostics.cpp @@ -51,6 +51,18 @@ void sanitize(nlohmann::json& value) { } } +// True when `line` is a whole line of `text`, not part of a longer line. +bool contains_line(const std::string& text, const std::string& line) { + for (auto pos = text.find(line); pos != std::string::npos; pos = text.find(line, pos + 1)) { + const auto end = pos + line.size(); + if ((pos == 0 || text[pos - 1] == '\n') && + (end == text.size() || text[end] == '\n' || text[end] == '\r')) { + return true; + } + } + return false; +} + } // namespace std::string redact_upgrade_diagnostic(std::string text) { @@ -163,7 +175,10 @@ std::string DiagnosticLog::with_location(const std::string& message) const { result += "Upgrade log: " + log_path; } const auto log_error = error(); - if (!log_error.empty()) result += "\nUpgrade diagnostics unavailable or incomplete: " + log_error; + if (!log_error.empty()) { + const auto unavailable = "Upgrade diagnostics unavailable or incomplete: " + log_error; + if (!contains_line(result, unavailable)) result += "\n" + unavailable; + } return result; } diff --git a/src/adapters/upgrade/diagnostics.hpp b/src/adapters/upgrade/diagnostics.hpp index b0687a044..bd861de19 100644 --- a/src/adapters/upgrade/diagnostics.hpp +++ b/src/adapters/upgrade/diagnostics.hpp @@ -20,6 +20,9 @@ class DiagnosticLog { nlohmann::json details = nlohmann::json::object()) noexcept; std::string path() const; std::string error() const; + // Appends the log path and the diagnostics failure line unless the message + // already has them: the GUI update job wraps run_upgrade_command's error + // text, which has been through this once. std::string with_location(const std::string& message) const; private: diff --git a/src/adapters/upgrade/macos_bundle.cpp b/src/adapters/upgrade/macos_bundle.cpp index ff19d6d41..e192a3108 100644 --- a/src/adapters/upgrade/macos_bundle.cpp +++ b/src/adapters/upgrade/macos_bundle.cpp @@ -1,5 +1,7 @@ #include "macos_bundle.hpp" +#include "utils/encoding.hpp" + #include #include #include @@ -91,7 +93,10 @@ std::optional find_staged_macos_app_bundle( top_level.push_back(it->path()); } if (ec) { - if (error) *error = "failed to inspect macOS update staging directory: " + ec.message(); + if (error) { + *error = "failed to inspect macOS update staging directory: " + + ensure_utf8(ec.message()); + } return std::nullopt; } if (top_level.size() == 1 && is_real_directory(top_level.front())) { diff --git a/src/adapters/upgrade/package.cpp b/src/adapters/upgrade/package.cpp index 32ad6c881..0142baaf4 100644 --- a/src/adapters/upgrade/package.cpp +++ b/src/adapters/upgrade/package.cpp @@ -1,5 +1,6 @@ #include "package.hpp" +#include "utils/encoding.hpp" #include "utils/utf8_path.hpp" #include @@ -94,7 +95,7 @@ bool apply_archive_permissions(const fs::path& path, if (ec) { if (error) { *error = "failed to restore zip entry permissions for " + - path_to_utf8(path) + ": " + ec.message(); + path_to_utf8(path) + ": " + ensure_utf8(ec.message()); } return false; } @@ -148,7 +149,9 @@ bool extract_zip_to_staging(const fs::path& zip_path, std::error_code ec; fs::create_directories(staging_dir, ec); if (ec) { - if (error) *error = "failed to create staging directory: " + ec.message(); + // 错误文本会成为 GUI 升级任务的 job.error;MSVC 的 ec.message() 走 ANSI + // 代码页(中文 Windows 为 GBK),先单独转 UTF-8 再拼接。 + if (error) *error = "failed to create staging directory: " + ensure_utf8(ec.message()); return false; } @@ -196,7 +199,10 @@ bool extract_zip_to_staging(const fs::path& zip_path, if (is_dir) { fs::create_directories(dest, ec); if (ec) { - if (error) *error = "failed to create directory from zip: " + ec.message(); + if (error) { + *error = "failed to create directory from zip: " + + ensure_utf8(ec.message()); + } zip_close(archive); return false; } @@ -208,7 +214,10 @@ bool extract_zip_to_staging(const fs::path& zip_path, fs::create_directories(dest.parent_path(), ec); if (ec) { - if (error) *error = "failed to create parent directory from zip: " + ec.message(); + if (error) { + *error = "failed to create parent directory from zip: " + + ensure_utf8(ec.message()); + } zip_close(archive); return false; } @@ -283,7 +292,7 @@ std::optional validate_staged_package(const fs::path& staging_dir } } if (ec) { - if (error) *error = "failed to inspect staged package: " + ec.message(); + if (error) *error = "failed to inspect staged package: " + ensure_utf8(ec.message()); return std::nullopt; } if (top_dirs.size() == 1) { diff --git a/src/adapters/upgrade/upgrade.cpp b/src/adapters/upgrade/upgrade.cpp index e483ebaf9..dd44cf82e 100644 --- a/src/adapters/upgrade/upgrade.cpp +++ b/src/adapters/upgrade/upgrade.cpp @@ -58,7 +58,7 @@ int report_upgrade_cancelled(std::ostream& err, diagnostics.record("cancel_cleanup", { {"workspace", path_to_utf8(workspace_dir)}, {"error_code", cleanup_error.value()}, - {"error", cleanup_error ? cleanup_error.message() : std::string{}}, + {"error", cleanup_error ? ensure_utf8(cleanup_error.message()) : std::string{}}, }); } err << "acecode upgrade: update cancelled\n"; @@ -529,8 +529,10 @@ static int run_upgrade_command_impl(const AppConfig& config, std::error_code ec; fs::create_directories(package_path.parent_path(), ec); if (ec) { + // 错误流会成为 GUI 任务的 job.error;MSVC 的 ec.message() 走 ANSI 代码页 + // (中文 Windows 为 GBK),先单独转 UTF-8,路径同理一律 path_to_utf8。 err << "acecode upgrade: failed to create update workspace: " - << ec.message() << "\n"; + << ensure_utf8(ec.message()) << "\n"; return 1; } @@ -671,7 +673,7 @@ static int run_upgrade_command_impl(const AppConfig& config, if (!apply_staged_update(staging_dir, install_dir, backup_dir, target, &apply_error, &diagnostics, selected.version)) { err << "acecode upgrade: failed to apply update: " << apply_error << "\n" - << "Backup directory: " << backup_dir.string() << "\n"; + << "Backup directory: " << path_to_utf8(backup_dir) << "\n"; return 1; } @@ -683,7 +685,7 @@ static int run_upgrade_command_impl(const AppConfig& config, out << " Install : " << styled(out, ConsoleStyle::Green, "OK") << "\n\n" << styled(out, ConsoleStyle::Green, "ACECode update applied successfully.") << "\n" << " Version : v" << selected.version << "\n" - << " Backup : " << backup_dir.string() << "\n"; + << " Backup : " << path_to_utf8(backup_dir) << "\n"; return 0; } @@ -711,7 +713,7 @@ int run_upgrade_command(const AppConfig& config, code = run_upgrade_command_impl(config, argv0, current_version, out, errors, force, std::move(progress_callback), std::move(cancel_check), log); } catch (const std::exception& e) { - errors << "acecode upgrade: exception: " << e.what() << "\n"; + errors << "acecode upgrade: exception: " << ensure_utf8(e.what()) << "\n"; } catch (...) { errors << "acecode upgrade: unknown exception\n"; } diff --git a/src/apps/tui/commands/builtin_commands.cpp b/src/apps/tui/commands/builtin_commands.cpp index 9737d1dd9..512c8dd6d 100644 --- a/src/apps/tui/commands/builtin_commands.cpp +++ b/src/apps/tui/commands/builtin_commands.cpp @@ -11,6 +11,7 @@ #include "proxy_command.hpp" #include "remote_control_command.hpp" #include "resume_state_sync.hpp" +#include "tasks_command.hpp" #include "websearch_command.hpp" #include "config/config.hpp" #include "config/mcp_config.hpp" @@ -1921,70 +1922,6 @@ static void cmd_theme(CommandContext& ctx, const std::string& args) { ctx.state.chat_follow_tail = true; } -// /tasks — 子代理(spawn_subagent)后台任务的操作入口。右侧栏只展示 -// 运行中任务且不可交互;list/abort/clear 由本命令承担(与 Web 的后台 -// 任务面板同一数据:parent_session_id 归属当前主会话)。 -static void cmd_tasks(CommandContext& ctx, const std::string& args) { - auto push_system = [&](const std::string& text) { - std::lock_guard lk(ctx.state.mu); - ctx.state.conversation.push_back({"system", text, false}); - ctx.state.chat_follow_tail = true; - }; - if (!ctx.subagent_host) { - push_system("/tasks is unavailable in this context."); - return; - } - const std::string project_dir = SessionStorage::get_project_dir(ctx.cwd); - std::istringstream iss(args); - std::string sub; - iss >> sub; - - if (sub.empty() || sub == "list") { - auto entries = ctx.subagent_host->list_tasks(project_dir); - if (entries.empty()) { - push_system("No subagent tasks for this session."); - return; - } - std::ostringstream oss; - oss << "Subagent tasks (" << entries.size() << "):"; - for (const auto& e : entries) { - oss << "\n " << (e.running ? "\xE2\x97\x8F running " : "\xE2\x9C\x93 settled ") - << e.id; - if (!e.title.empty()) oss << " " << e.title; - } - oss << "\n\nUse /tasks abort to stop a running task. Subagent tasks " - "stay with this session and are deleted only together with it."; - push_system(oss.str()); - return; - } - if (sub == "abort") { - std::string id; - iss >> id; - if (id.empty()) { - push_system("Usage: /tasks abort "); - return; - } - // 支持 id 前缀:唯一命中时展开。 - if (!ctx.subagent_host->abort_task(id)) { - std::string matched; - for (const auto& t : ctx.subagent_host->running_tasks()) { - if (t.id.rfind(id, 0) == 0) { - if (!matched.empty()) { matched.clear(); break; } - matched = t.id; - } - } - if (matched.empty() || !ctx.subagent_host->abort_task(matched)) { - push_system("No running subagent task matches: " + id); - return; - } - id = matched; - } - push_system("Abort requested for subagent task " + id + "."); - return; - } - push_system("Usage: /tasks [list|abort ]"); -} - void register_builtin_commands(CommandRegistry& registry) { registry.register_command({"help", "Show available commands", cmd_help}); registry.register_command({"clear", "Clear conversation history", cmd_clear, {"new"}}); @@ -2058,7 +1995,7 @@ void register_builtin_commands(CommandRegistry& registry) { register_remote_control_command(registry); register_desktop_command(registry); registry.register_command({"feedback", "Upload current session and runtime logs to the configured upgrade service", cmd_feedback}); - registry.register_command({"tasks", "List or abort subagent background tasks", cmd_tasks}); + register_tasks_command(registry); registry.register_command({"title", "Set or show the window title for this session", cmd_title}); registry.register_command({"page-step", "Toggle single-line PgUp/PgDn scrolling (for terminals that swallow Alt+Arrow)", cmd_page_step}); registry.register_command({"theme", "Switch TUI color theme (dark/light/auto)", cmd_theme}); diff --git a/src/apps/tui/commands/tasks_command.cpp b/src/apps/tui/commands/tasks_command.cpp new file mode 100644 index 000000000..b3253f901 --- /dev/null +++ b/src/apps/tui/commands/tasks_command.cpp @@ -0,0 +1,83 @@ +#include "tasks_command.hpp" + +#include "command_registry.hpp" +#include "session/session_storage.hpp" +#include "tui/subagent_host.hpp" + +#include +#include + +namespace acecode { +namespace { + +// /tasks — 子代理(spawn_subagent)后台任务的操作入口。右侧栏只展示 +// 运行中任务且不可交互;list/abort 由本命令承担(与 Web 的后台 +// 任务面板同一数据:parent_session_id 归属当前主会话)。 +static void cmd_tasks(CommandContext& ctx, const std::string& args) { + auto push_system = [&](const std::string& text) { + std::lock_guard lk(ctx.state.mu); + ctx.state.conversation.push_back({"system", text, false}); + ctx.state.chat_follow_tail = true; + }; + if (!ctx.subagent_host) { + push_system("/tasks is unavailable in this context."); + return; + } + const std::string project_dir = SessionStorage::get_project_dir(ctx.cwd); + std::istringstream iss(args); + std::string sub; + iss >> sub; + + if (sub.empty() || sub == "list") { + auto entries = ctx.subagent_host->list_tasks(project_dir); + if (entries.empty()) { + push_system("No subagent tasks for this session."); + return; + } + std::ostringstream oss; + oss << "Subagent tasks (" << entries.size() << "):"; + for (const auto& e : entries) { + oss << "\n " << (e.running ? "\xE2\x97\x8F running " : "\xE2\x9C\x93 settled ") + << e.id; + if (!e.title.empty()) oss << " " << e.title; + } + oss << "\n\nUse /tasks abort to stop a running task. Subagent tasks " + "stay with this session and are deleted only together with it."; + push_system(oss.str()); + return; + } + if (sub == "abort") { + std::string id; + iss >> id; + if (id.empty()) { + push_system("Usage: /tasks abort "); + return; + } + // 支持 id 前缀:唯一命中时展开。 + if (!ctx.subagent_host->abort_task(id)) { + std::string matched; + for (const auto& t : ctx.subagent_host->running_tasks()) { + if (t.id.rfind(id, 0) == 0) { + if (!matched.empty()) { matched.clear(); break; } + matched = t.id; + } + } + if (matched.empty() || !ctx.subagent_host->abort_task(matched)) { + push_system("No running subagent task matches: " + id); + return; + } + id = matched; + } + push_system("Abort requested for subagent task " + id + "."); + return; + } + push_system("Usage: /tasks [list|abort ]"); +} + +} // namespace + +void register_tasks_command(CommandRegistry& registry) { + registry.register_command({"tasks", "List or abort subagent background tasks", cmd_tasks}); +} + +} // namespace acecode diff --git a/src/apps/tui/commands/tasks_command.hpp b/src/apps/tui/commands/tasks_command.hpp new file mode 100644 index 000000000..8d5650032 --- /dev/null +++ b/src/apps/tui/commands/tasks_command.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace acecode { + +class CommandRegistry; +void register_tasks_command(CommandRegistry& registry); + +} // namespace acecode diff --git a/src/apps/web/routes/routes_misc.cpp b/src/apps/web/routes/routes_misc.cpp index 3c271d4cd..3bfd0c431 100644 --- a/src/apps/web/routes/routes_misc.cpp +++ b/src/apps/web/routes/routes_misc.cpp @@ -8,6 +8,7 @@ #include "utils/state_file.hpp" #include "provider/model_probe_cache.hpp" #include "upgrade/diagnostics.hpp" +#include "utils/encoding.hpp" #include #include @@ -111,6 +112,13 @@ std::string trim_update_error(std::string value) { return value; } +// 升级接口的出口兜底:检查结果与任务错误里拼着网络 / OS 错误文本和本地路径, +// 已在产生处转成 UTF-8;漏网的非法字节换成 U+FFFD,不让默认严格模式的 dump 抛 +// type_error.316 —— 失败的任务会一直留在内存里,否则每次轮询都是 500。 +std::string dump_update_json(const json& body) { + return body.dump(-1, ' ', false, json::error_handler_t::replace); +} + int hex_value(char ch) { if (ch >= '0' && ch <= '9') return ch - '0'; if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; @@ -1272,10 +1280,7 @@ void WebServer::Impl::register_ui_preferences() { ACECODE_VERSION, &diagnostics); crow::response r(200); r.add_header("Content-Type", "application/json"); - // 出口兜底:网络 / OS 错误文本已在产生处转 UTF-8,漏网的非法字节换成 - // U+FFFD,不让 dump 抛 type_error.316 把整个请求变成 500。 - r.body = update_check_to_json(result).dump( - -1, ' ', false, json::error_handler_t::replace); + r.body = dump_update_json(update_check_to_json(result)); return with_cors(req, std::move(r)); }); @@ -1293,7 +1298,7 @@ void WebServer::Impl::register_ui_preferences() { } crow::response r(200); r.add_header("Content-Type", "application/json"); - r.body = update_job_to_json(*update_job_runtime->current).dump(); + r.body = dump_update_json(update_job_to_json(*update_job_runtime->current)); return with_cors(req, std::move(r)); }); @@ -1312,7 +1317,7 @@ void WebServer::Impl::register_ui_preferences() { } crow::response r(200); r.add_header("Content-Type", "application/json"); - r.body = update_job_to_json(*update_job_runtime->current).dump(); + r.body = dump_update_json(update_job_to_json(*update_job_runtime->current)); return with_cors(req, std::move(r)); }); @@ -1335,15 +1340,15 @@ void WebServer::Impl::register_ui_preferences() { if (job.state == "cancelled") { crow::response r(200); r.add_header("Content-Type", "application/json"); - r.body = update_job_to_json(job).dump(); + r.body = dump_update_json(update_job_to_json(job)); return with_cors(req, std::move(r)); } if (!update_job_can_cancel(job)) { crow::response r(409); r.add_header("Content-Type", "application/json"); - r.body = json{{"error", "UPDATE_NOT_CANCELLABLE"}, - {"message", "update can no longer be cancelled"}, - {"job", update_job_to_json(job)}}.dump(); + r.body = dump_update_json(json{{"error", "UPDATE_NOT_CANCELLABLE"}, + {"message", "update can no longer be cancelled"}, + {"job", update_job_to_json(job)}}); return with_cors(req, std::move(r)); } @@ -1354,7 +1359,7 @@ void WebServer::Impl::register_ui_preferences() { } crow::response r(202); r.add_header("Content-Type", "application/json"); - r.body = update_job_to_json(job).dump(); + r.body = dump_update_json(update_job_to_json(job)); return with_cors(req, std::move(r)); }); @@ -1375,7 +1380,7 @@ void WebServer::Impl::register_ui_preferences() { body["message"] = "update is already installed; restart ACECode to finish"; crow::response response(202); response.add_header("Content-Type", "application/json"); - response.body = body.dump(); + response.body = dump_update_json(body); return with_cors(req, std::move(response)); }; @@ -1386,9 +1391,10 @@ void WebServer::Impl::register_ui_preferences() { update_job_is_active(*update_job_runtime->current)) { crow::response r(409); r.add_header("Content-Type", "application/json"); - r.body = json{{"error", "UPDATE_IN_PROGRESS"}, - {"message", "an update job is already running"}, - {"job", update_job_to_json(*update_job_runtime->current)}}.dump(); + r.body = dump_update_json(json{ + {"error", "UPDATE_IN_PROGRESS"}, + {"message", "an update job is already running"}, + {"job", update_job_to_json(*update_job_runtime->current)}}); return with_cors(req, std::move(r)); } } @@ -1409,15 +1415,13 @@ void WebServer::Impl::register_ui_preferences() { acecode::upgrade::UpdateCheckStatus::NoCompatiblePackage; crow::response r(409); r.add_header("Content-Type", "application/json"); - r.body = json{{"error", no_compatible_package - ? "NO_COMPATIBLE_PACKAGE" - : "NO_UPDATE"}, - {"message", !result.error.empty() - ? result.error - : "no compatible update is available"}, - {"log_path", result.log_path}, - {"status", update_check_to_json(result)}} - .dump(-1, ' ', false, json::error_handler_t::replace); + r.body = dump_update_json(json{ + {"error", no_compatible_package ? "NO_COMPATIBLE_PACKAGE" : "NO_UPDATE"}, + {"message", !result.error.empty() + ? result.error + : "no compatible update is available"}, + {"log_path", result.log_path}, + {"status", update_check_to_json(result)}}); return with_cors(req, std::move(r)); } @@ -1435,9 +1439,10 @@ void WebServer::Impl::register_ui_preferences() { update_job_is_active(*update_job_runtime->current)) { crow::response r(409); r.add_header("Content-Type", "application/json"); - r.body = json{{"error", "UPDATE_IN_PROGRESS"}, - {"message", "an update job is already running"}, - {"job", update_job_to_json(*update_job_runtime->current)}}.dump(); + r.body = dump_update_json(json{ + {"error", "UPDATE_IN_PROGRESS"}, + {"message", "an update job is already running"}, + {"job", update_job_to_json(*update_job_runtime->current)}}); return with_cors(req, std::move(r)); } update_job_runtime->current = initial; @@ -1500,7 +1505,11 @@ void WebServer::Impl::register_ui_preferences() { } } } catch (const std::exception& e) { - error = e.what(); + // MSVC 的 filesystem_error / system_error 文本走 ANSI 代码页(中文 + // Windows 为 GBK),在这里单独转码。上面 errors 流的文本已在各产生处 + // 转好,其中的 UTF-8 路径经不起整串 ensure_utf8;漏网字节交给 + // dump_update_json 兜底。 + error = ensure_utf8(e.what()); code = 1; } catch (...) { error = "unknown update failure"; @@ -1534,16 +1543,18 @@ void WebServer::Impl::register_ui_preferences() { try { std::thread(std::move(run_job)).detach(); } catch (const std::exception& e) { - diagnostics->record("job_start_failed", {{"job_id", initial.job_id}, {"error", e.what()}}); + const std::string start_error = ensure_utf8(e.what()); + diagnostics->record("job_start_failed", {{"job_id", initial.job_id}, {"error", start_error}}); std::lock_guard lock(runtime->mu); auto& job = *runtime->current; job.state = "failed"; - job.error = diagnostics->with_location(e.what()); + job.error = diagnostics->with_location(start_error); job.log_error = diagnostics->error(); crow::response r(500); r.add_header("Content-Type", "application/json"); - r.body = json{{"error", "UPDATE_START_FAILED"}, {"message", job.error}, - {"job", update_job_to_json(job)}}.dump(); + r.body = dump_update_json(json{{"error", "UPDATE_START_FAILED"}, + {"message", job.error}, + {"job", update_job_to_json(job)}}); return with_cors(req, std::move(r)); } @@ -1553,7 +1564,7 @@ void WebServer::Impl::register_ui_preferences() { body["started"] = true; body["latest_version"] = result.latest_version; body["message"] = "acecode update job started"; - r.body = body.dump(); + r.body = dump_update_json(body); return with_cors(req, std::move(r)); }); diff --git a/tests/upgrade/update_job_utf8_test.cpp b/tests/upgrade/update_job_utf8_test.cpp new file mode 100644 index 000000000..53789786f --- /dev/null +++ b/tests/upgrade/update_job_utf8_test.cpp @@ -0,0 +1,468 @@ +// 本文件覆盖 GUI 升级任务链路(真正下载 / 解压 / 安装的那条)在失败时产出的错误文本编码。 +// run_upgrade_command 把错误写进 errors 流,/api/update/start 的任务线程把它整理成 job.error, +// 随后 /api/update/job、/api/update/jobs/、/api/update/jobs//cancel 都会序列化它。 +// nlohmann::json::dump() 默认是严格模式,任何一个非 UTF-8 字节都会抛 type_error.316, +// 失败的任务一直留在内存里,前端每次轮询都是 500。所以这些文本必须在产生处就是合法 UTF-8: +// OS 错误文本(ec.message())单独经 ensure_utf8 再拼接,路径一律 path_to_utf8。 +// +// 场景: +// 1. 安装时备份移动失败(文件被占用),路径在中文目录下(仅 Windows):错误里的两条路径 +// 必须是 UTF-8 原文,OS 文本转码后拼接;诊断日志里 backup_move 的 error 字段不能是 U+FFFD。 +// 2. 安装时备份目录建不出来(父路径被同名普通文件占住):"failed to create backup directory: " +// 后面的 OS 文本是合法 UTF-8。 +// 3. 安装目录位于用户数据目录内、且目录名是中文:拒绝信息里的路径是 UTF-8 原文。 +// 4. 解压时 staging 目录建不出来:"failed to create staging directory: " 的 OS 文本是合法 UTF-8。 +// 5. 解压时条目的父目录建不出来(staging 里有同名普通文件):OS 文本是合法 UTF-8。 +// 6. 校验 staging 时目录不存在:"failed to inspect staged package: " 的 OS 文本是合法 UTF-8。 +// 7. run_upgrade_command 端到端:update workspace 建不出来,errors 流(即 GUI 的 job.error 来源) +// 是合法 UTF-8,诊断日志里 upgrade_finished 的 error 不带 U+FFFD。 +// +// MSVC 的 ec.message() 与 path::string() 走 ANSI 代码页:只有中文 Windows(ACP 936)上它们才是 +// GBK,能复现修复前的失败;英文 Windows 上中文路径的 path::string() 会直接抛 system_error +// (同样是修复前的失败);Linux / macOS 上两者本来就是 UTF-8,用例照常通过。 + +#include "config/config.hpp" +#include "upgrade/apply.hpp" +#include "upgrade/diagnostics.hpp" +#include "upgrade/manifest.hpp" +#include "upgrade/package.hpp" +#include "upgrade/upgrade.hpp" +#include "utils/encoding.hpp" +#include "utils/paths.hpp" +#include "utils/utf8_path.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace fs = std::filesystem; +using namespace std::chrono_literals; +using namespace acecode::upgrade; + +namespace { + +// U+FFFD 的 UTF-8 编码。诊断日志按 error_handler_t::replace 写盘,非法字节会变成它: +// 日志里出现它,说明 OS 文本没有在产生处转码。 +const std::string kReplacementChar = "\xEF\xBF\xBD"; + +// 失败信息里可能带着修复前的原始 GBK 字节;原样输出会让 gtest 的 XML 报告损坏, +// 不是合法 UTF-8 时把高位字节转义成 \xHH。 +std::string printable(const std::string& text) { + if (acecode::is_valid_utf8(text)) return text; + static const char* const kHex = "0123456789ABCDEF"; + std::string out; + for (const unsigned char byte : text) { + if (byte < 0x80) { + out.push_back(static_cast(byte)); + } else { + out += "\\x"; + out.push_back(kHex[byte >> 4]); + out.push_back(kHex[byte & 0x0F]); + } + } + return out; +} + +struct TempDir { + fs::path path; + + explicit TempDir(const std::string& label) { + std::random_device rd; + path = fs::temp_directory_path() / (label + "-" + std::to_string(rd())); + std::error_code ec; + fs::remove_all(path, ec); + fs::create_directories(path); + } + + ~TempDir() { + std::error_code ec; + fs::remove_all(path, ec); + } +}; + +void write_file(const fs::path& path, const std::string& body) { + fs::create_directories(path.parent_path()); + std::ofstream ofs(path, std::ios::binary); + ofs << body; +} + +std::string read_file(const fs::path& path) { + std::ifstream ifs(path, std::ios::binary); + return std::string((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); +} + +// 把 HOME / USERPROFILE 指到临时目录,让 get_acecode_dir()(用户数据目录、updates 工作区) +// 落在里面;Windows 上用宽字符接口设置,临时目录本身含中文时也不会被按 ANSI 代码页解错。 +class ScopedHomeOverride { +public: + explicit ScopedHomeOverride(const fs::path& home) { + fs::create_directories(home); + acecode::reset_run_mode_for_test(); + std::string current; + if (acecode::getenv_utf8(kHomeEnvName, current)) previous_ = current; + set_home(acecode::path_to_utf8(home)); + } + + ~ScopedHomeOverride() { + if (previous_) { + set_home(*previous_); + } else { +#ifdef _WIN32 + _wputenv_s(L"USERPROFILE", L""); +#else + unsetenv(kHomeEnvName); +#endif + } + acecode::reset_run_mode_for_test(); + } + + ScopedHomeOverride(const ScopedHomeOverride&) = delete; + ScopedHomeOverride& operator=(const ScopedHomeOverride&) = delete; + +private: +#ifdef _WIN32 + static constexpr const char* kHomeEnvName = "USERPROFILE"; + static void set_home(const std::string& utf8) { + _wputenv_s(L"USERPROFILE", acecode::utf8_to_wide(utf8).c_str()); + } +#else + static constexpr const char* kHomeEnvName = "HOME"; + static void set_home(const std::string& utf8) { setenv(kHomeEnvName, utf8.c_str(), 1); } +#endif + + std::optional previous_; +}; + +// 只服务 /aceupdate.json 的本地清单服务器。 +struct LocalManifestServer { + httplib::Server svr; + int port = 0; + std::thread th; + + explicit LocalManifestServer(std::string body) { + svr.Get("/aceupdate.json", [body](const httplib::Request&, httplib::Response& res) { + res.set_content(body, "application/json"); + }); + port = svr.bind_to_any_port("127.0.0.1"); + th = std::thread([this] { svr.listen_after_bind(); }); + for (int i = 0; i < 50 && !svr.is_running(); ++i) { + std::this_thread::sleep_for(10ms); + } + } + + ~LocalManifestServer() { + svr.stop(); + if (th.joinable()) th.join(); + } + + std::string base_url() const { + return "http://127.0.0.1:" + std::to_string(port) + "/"; + } +}; + +// 当前平台有一个 9.9.9 可用包的清单;sha256 是占位值,用例在下载之前就失败。 +std::string available_update_manifest() { + const std::string target = manifest_target_for_platform(current_target()); + return R"({ + "schema_version": 1, + "latest": "9.9.9", + "releases": [ + {"version": "9.9.9", "packages": [ + {"target": ")" + target + R"(", "file": "acecode.zip", "sha256": ")" + + std::string(64, 'a') + R"("} + ]} + ] + })"; +} + +// 写一个只含一个普通文件条目的 zip(unix 模式 0100644,与发布包的条目类型一致)。 +bool write_zip_with_entry(const fs::path& archive_path, + const std::string& entry_name, + const std::string& body) { + int error = 0; + zip_t* archive = zip_open(acecode::path_to_utf8(archive_path).c_str(), + ZIP_CREATE | ZIP_TRUNCATE, &error); + if (!archive) return false; + zip_source_t* source = zip_source_buffer(archive, body.data(), body.size(), 0); + if (!source) { + zip_discard(archive); + return false; + } + const zip_int64_t index = zip_file_add(archive, entry_name.c_str(), source, ZIP_FL_ENC_UTF_8); + if (index < 0) { + zip_source_free(source); + zip_discard(archive); + return false; + } + if (zip_file_set_external_attributes(archive, static_cast(index), 0, + ZIP_OPSYS_UNIX, 0100644u << 16u) != 0) { + zip_discard(archive); + return false; + } + return zip_close(archive) == 0; +} + +// 诊断日志每行一条 JSON 记录(按 replace 写盘,每行都能严格解析);返回第一条 event 相同、 +// 且满足 match(为空则不额外筛选)的记录。 +std::optional find_log_record( + const std::string& log_path, const std::string& event, + const std::function& match = {}) { + std::istringstream lines(read_file(acecode::path_from_utf8(log_path))); + std::string line; + while (std::getline(lines, line)) { + if (line.empty()) continue; + auto record = nlohmann::json::parse(line); + if (record.value("event", std::string{}) == event && (!match || match(record))) { + return record; + } + } + return std::nullopt; +} + +} // namespace + +#ifdef _WIN32 +// 场景 1:安装替换时旧文件被别的进程占用,升级目录在中文路径下(例如 C:\Users\张三\...)。 +// 触发:install / staging / backup 都在「张三-升级」目录下;CreateFileW 只给 FILE_SHARE_READ +// 占住 install\z-blocked.txt,backup_existing_path 的 rename 需要 DELETE 访问,失败于 +// ERROR_SHARING_VIOLATION(32),错误 = "failed to move to : " + ec.message()。 +// 期望:apply_staged_update 返回 false;error 与 +// "failed to move " + path_to_utf8(src) + " to " + path_to_utf8(dest) + ": " + +// ensure_utf8(错误码 32 的系统文本) 逐字节相同,是合法 UTF-8,严格 dump 不抛异常; +// 诊断日志里 backup_move 记录(error_code=32)的 error 字段非空且不含 U+FFFD。 +// 回归:修复前 src.string() / dest.string() 与 ec.message() 在中文 Windows 上都是 GBK, +// error 经 run_upgrade_command 进 job.error,任务轮询接口 500;日志里系统文本则全变成 +// U+FFFD。英文 Windows 上「张三」无法映射到 ANSI 代码页,path::string() 直接抛 system_error。 +TEST(UpdateJobUtf8, ApplyMoveFailureUnderChinesePathIsUtf8) { + TempDir tmp("acecode-update-job-move"); + const fs::path root = tmp.path / acecode::path_from_utf8("张三-升级"); + const fs::path install = root / "install"; + const fs::path staging = root / "staging"; + const fs::path backup = root / "backup"; + write_file(install / "acecode.exe", "old exe"); + write_file(staging / "acecode.exe", "new exe"); + write_file(install / "z-blocked.txt", "old locked file"); + write_file(staging / "z-blocked.txt", "replacement"); + DiagnosticLog diagnostics("apply_utf8_test", tmp.path / "logs"); + + struct FileHandle { + HANDLE value; + ~FileHandle() { + if (value != INVALID_HANDLE_VALUE) ::CloseHandle(value); + } + } lock{::CreateFileW((install / "z-blocked.txt").c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)}; + ASSERT_NE(lock.value, INVALID_HANDLE_VALUE); + + std::string error; + EXPECT_FALSE(apply_staged_update(staging, install, backup, "windows-x64", &error, + &diagnostics)); + + const std::string os_text = acecode::ensure_utf8( + std::error_code(ERROR_SHARING_VIOLATION, std::system_category()).message()); + EXPECT_EQ(error, "failed to move " + acecode::path_to_utf8(install / "z-blocked.txt") + + " to " + acecode::path_to_utf8(backup / "z-blocked.txt") + ": " + + os_text) + << printable(error); + EXPECT_TRUE(acecode::is_valid_utf8(error)) << printable(error); + EXPECT_NO_THROW(nlohmann::json({{"error", error}}).dump()); + + // acecode.exe 先被成功移走(error_code=0),要找的是 z-blocked.txt 那条失败记录。 + const auto record = find_log_record( + diagnostics.path(), "backup_move", [](const nlohmann::json& item) { + return item.at("details").value("error_code", 0) != 0; + }); + ASSERT_TRUE(record.has_value()); + EXPECT_EQ((*record)["details"]["error_code"].get(), + static_cast(ERROR_SHARING_VIOLATION)); + const std::string logged = (*record)["details"]["error"].get(); + EXPECT_FALSE(logged.empty()); + EXPECT_EQ(logged.find(kReplacementChar), std::string::npos) << logged; + EXPECT_EQ(read_file(install / "acecode.exe"), "old exe"); +} +#endif + +// 场景 2:安装时备份目录建不出来 —— backup 的父路径被一个同名普通文件占住。 +// 触发:backup = /占位文件/backup,「占位文件」是普通文件;apply_staged_update 的 +// create_directories(backup_dir) 失败,错误 = "failed to create backup directory: " + +// ec.message()。 +// 期望:返回 false;error 以该前缀开头且前缀后还有系统文本,是合法 UTF-8,严格 dump 不抛异常; +// 失败发生在任何替换之前,安装目录里的旧文件原样保留。 +// 回归:中文 Windows 上 ec.message() 是 GBK(如「当文件已存在时,无法创建该文件。」),修复前原样 +// 进 job.error,任务轮询接口 500。英文系统与 Linux / macOS 上该文本是 ASCII,用例照常通过。 +TEST(UpdateJobUtf8, ApplyBackupDirectoryFailureTextIsUtf8) { + TempDir tmp("acecode-update-job-backup"); + const fs::path install = tmp.path / "install"; + const fs::path staging = tmp.path / "staging"; + const fs::path blocker = tmp.path / acecode::path_from_utf8("占位文件"); + write_file(install / "acecode.exe", "old exe"); + write_file(staging / "acecode.exe", "new exe"); + write_file(blocker, "occupies the backup parent directory"); + + std::string error; + EXPECT_FALSE(apply_staged_update(staging, install, blocker / "backup", "windows-x64", + &error)); + + const std::string prefix = "failed to create backup directory: "; + EXPECT_EQ(error.rfind(prefix, 0), 0u) << printable(error); + EXPECT_GT(error.size(), prefix.size()) << printable(error); + EXPECT_TRUE(acecode::is_valid_utf8(error)) << printable(error); + EXPECT_NO_THROW(nlohmann::json({{"error", error}}).dump()); + EXPECT_EQ(read_file(install / "acecode.exe"), "old exe"); +} + +// 场景 3:安装目录在用户数据目录(/.acecode)里面,且目录名是中文。 +// 触发:install = /.acecode/安装目录;staged 包只有 acecode.exe, +// staged_paths_avoid_user_data 发现 /acecode.exe 落在数据目录内,拒绝更新。 +// 期望:返回 false;error 与 "refusing to update a package path that overlaps ACECode user data: " +// + path_to_utf8(/acecode.exe) 逐字节相同;安装目录不变,备份目录没有被创建。 +// 回归:修复前用 target.string() 拼接,中文 Windows 上「安装目录」是 GBK,job.error 序列化 500; +// 英文 Windows 上 path::string() 无法映射中文直接抛 system_error。 +TEST(UpdateJobUtf8, ApplyUserDataOverlapReportsUtf8Path) { + TempDir tmp("acecode-update-job-user-data"); + ScopedHomeOverride scoped_home(tmp.path / "home"); + const fs::path install = + tmp.path / "home" / ".acecode" / acecode::path_from_utf8("安装目录"); + const fs::path staging = tmp.path / "staging"; + const fs::path backup = tmp.path / "backup"; + write_file(install / "acecode.exe", "old exe"); + write_file(staging / "acecode.exe", "new exe"); + + std::string error; + EXPECT_FALSE(apply_staged_update(staging, install, backup, "windows-x64", &error)); + + EXPECT_EQ(error, "refusing to update a package path that overlaps ACECode user data: " + + acecode::path_to_utf8(install / "acecode.exe")) + << printable(error); + EXPECT_TRUE(acecode::is_valid_utf8(error)) << printable(error); + EXPECT_EQ(read_file(install / "acecode.exe"), "old exe"); + EXPECT_FALSE(fs::exists(backup)); +} + +// 场景 4:解压前 staging 目录建不出来 —— staging 的父路径被同名普通文件占住。 +// 触发:staging = /占位文件/staging;extract_zip_to_staging 第一步 create_directories 失败, +// 错误 = "failed to create staging directory: " + ec.message()(此时还没打开 zip)。 +// 期望:返回 false;error 以该前缀开头、前缀后有系统文本,是合法 UTF-8,严格 dump 不抛异常。 +// 回归:中文 Windows 上 ec.message() 是 GBK,修复前经 "invalid package: ..." 进 job.error, +// 任务轮询接口 500。英文系统与 Linux / macOS 上该文本是 ASCII,用例照常通过。 +TEST(UpdateJobUtf8, ExtractStagingDirectoryFailureTextIsUtf8) { + TempDir tmp("acecode-update-job-staging"); + const fs::path blocker = tmp.path / acecode::path_from_utf8("占位文件"); + write_file(blocker, "occupies the staging parent directory"); + + std::string error; + EXPECT_FALSE(extract_zip_to_staging(tmp.path / "update.zip", blocker / "staging", &error)); + + const std::string prefix = "failed to create staging directory: "; + EXPECT_EQ(error.rfind(prefix, 0), 0u) << printable(error); + EXPECT_GT(error.size(), prefix.size()) << printable(error); + EXPECT_TRUE(acecode::is_valid_utf8(error)) << printable(error); + EXPECT_NO_THROW(nlohmann::json({{"error", error}}).dump()); +} + +// 场景 5:解压条目时父目录建不出来 —— staging 里已经有一个与目录同名的普通文件。 +// 触发:zip 里只有 share/asset.txt;解压前在 staging 下写一个名为 share 的普通文件, +// create_directories(/share) 失败,错误 = +// "failed to create parent directory from zip: " + ec.message()。 +// 期望:返回 false;error 以该前缀开头、前缀后有系统文本,是合法 UTF-8,严格 dump 不抛异常。 +// 回归:中文 Windows 上 ec.message() 是 GBK(「当文件已存在时,无法创建该文件。」),修复前 +// 原样进 job.error,任务轮询接口 500。英文系统与 Linux / macOS 上用例照常通过。 +TEST(UpdateJobUtf8, ExtractParentDirectoryFailureTextIsUtf8) { + TempDir tmp("acecode-update-job-extract"); + const fs::path archive = tmp.path / "update.zip"; + const fs::path staging = tmp.path / "staging"; + ASSERT_TRUE(write_zip_with_entry(archive, "share/asset.txt", "asset")); + write_file(staging / "share", "occupies the share directory"); + + std::string error; + EXPECT_FALSE(extract_zip_to_staging(archive, staging, &error)); + + const std::string prefix = "failed to create parent directory from zip: "; + EXPECT_EQ(error.rfind(prefix, 0), 0u) << printable(error); + EXPECT_GT(error.size(), prefix.size()) << printable(error); + EXPECT_TRUE(acecode::is_valid_utf8(error)) << printable(error); + EXPECT_NO_THROW(nlohmann::json({{"error", error}}).dump()); +} + +// 场景 6:校验 staging 时目录根本不存在(解压目录被安全软件清掉等)。 +// 触发:validate_staged_package(/不存在的目录) —— 根下没有 acecode.exe,接着 +// directory_iterator 打不开目录,错误 = "failed to inspect staged package: " + ec.message()。 +// 期望:返回 nullopt;error 以该前缀开头、前缀后有系统文本,是合法 UTF-8,严格 dump 不抛异常。 +// 回归:中文 Windows 上 ec.message() 是 GBK(「系统找不到指定的路径。」),修复前原样进 +// job.error,任务轮询接口 500。英文系统与 Linux / macOS 上用例照常通过。 +TEST(UpdateJobUtf8, ValidateMissingStagingDirectoryTextIsUtf8) { + TempDir tmp("acecode-update-job-validate"); + + std::string error; + const auto staged = validate_staged_package( + tmp.path / acecode::path_from_utf8("不存在的目录"), "windows-x64", &error); + + EXPECT_FALSE(staged.has_value()); + const std::string prefix = "failed to inspect staged package: "; + EXPECT_EQ(error.rfind(prefix, 0), 0u) << printable(error); + EXPECT_GT(error.size(), prefix.size()) << printable(error); + EXPECT_TRUE(acecode::is_valid_utf8(error)) << printable(error); + EXPECT_NO_THROW(nlohmann::json({{"error", error}}).dump()); +} + +// 场景 7:run_upgrade_command 端到端 —— 清单显示有更新,但 update workspace 建不出来。 +// 触发:HOME 指向临时目录,/.acecode/updates 被同名普通文件占住;本地清单服务器返回 +// 当前平台的 9.9.9。run_upgrade_command 选中包后 create_directories(/acecode-update-*) +// 失败,errors 流写入 "acecode upgrade: failed to create update workspace: " + ec.message(), +// GUI 任务线程正是把这个流整理成 job.error。 +// 期望:返回 1;err 带该前缀,是合法 UTF-8,严格 dump 不抛异常;诊断日志里 upgrade_finished +// 记录的 error 字段带同一前缀且不含 U+FFFD。失败发生在下载之前,不会碰测试程序所在目录。 +// 回归:中文 Windows 上 ec.message() 是 GBK,修复前 job.error 序列化 500,日志里则是一串 U+FFFD。 +// 英文系统与 Linux / macOS 上该文本是 ASCII,用例照常通过。 +TEST(UpdateJobUtf8, UpgradeWorkspaceFailureTextIsUtf8) { + TempDir tmp("acecode-update-job-workspace"); + const fs::path home = tmp.path / "home"; + ScopedHomeOverride scoped_home(home); + write_file(home / ".acecode" / "updates", "occupies the update workspace directory"); + LocalManifestServer server(available_update_manifest()); + acecode::AppConfig cfg; + cfg.network.proxy_mode = "off"; + cfg.upgrade.base_url = server.base_url(); + DiagnosticLog diagnostics("upgrade", tmp.path / "logs"); + + std::ostringstream out; + std::ostringstream err; + const int code = run_upgrade_command(cfg, "", "0.1.2", out, err, false, {}, {}, &diagnostics); + + EXPECT_EQ(code, 1); + const std::string errors = err.str(); + EXPECT_NE(errors.find("acecode upgrade: failed to create update workspace: "), + std::string::npos) + << printable(errors); + EXPECT_TRUE(acecode::is_valid_utf8(errors)) << printable(errors); + EXPECT_NO_THROW(nlohmann::json({{"error", errors}}).dump()); + + const auto record = find_log_record(diagnostics.path(), "upgrade_finished"); + ASSERT_TRUE(record.has_value()); + const std::string logged = (*record)["details"]["error"].get(); + EXPECT_NE(logged.find("failed to create update workspace: "), std::string::npos) << logged; + EXPECT_EQ(logged.find(kReplacementChar), std::string::npos) << logged; +} diff --git a/tests/upgrade/upgrade_diagnostics_test.cpp b/tests/upgrade/upgrade_diagnostics_test.cpp index 941e84fef..03e69fb88 100644 --- a/tests/upgrade/upgrade_diagnostics_test.cpp +++ b/tests/upgrade/upgrade_diagnostics_test.cpp @@ -1,3 +1,15 @@ +// 本文件覆盖升级诊断日志 acecode::upgrade::DiagnosticLog(src/adapters/upgrade/diagnostics.cpp): +// 每次 check / upgrade 操作一个实例,把 JSONL 记录追加到 upgrade--.log, +// 并用 with_location() 给面向用户的错误文本补上日志位置或「诊断日志不可用」说明行。 +// +// 场景: +// 1. 记录立即落盘(不等析构),同一进程同一天的多次操作追加到同一个文件。 +// 2. 记录里嵌套的 URL 凭据 / 查询串 / 片段被脱敏,超长字符串被截断。 +// 3. 多个线程各持一个实例并发写同一个文件,每一行都是完整 JSON。 +// 4. 日志目录被同名普通文件占住:不抛异常、不报告不存在的日志文件,with_location 给出不可用说明。 +// 5. 同一段错误文本经过两次 with_location(GUI 升级任务的真实调用链),不可用说明行只出现一次。 +// 6. 去重只认整行完全相同:说明文字只是某一行的一部分时,照常追加本日志的说明行。 + #include "upgrade/diagnostics.hpp" #include "utils/utf8_path.hpp" @@ -30,6 +42,23 @@ std::vector read_records(const std::string& path) { return records; } +// 统计 needle 在 text 里不重叠出现的次数。 +size_t count_occurrences(const std::string& text, const std::string& needle) { + size_t count = 0; + for (auto pos = text.find(needle); pos != std::string::npos; + pos = text.find(needle, pos + needle.size())) { + ++count; + } + return count; +} + +// 场景 1:记录立即落盘,同一进程同一天的多次操作追加到同一个文件。 +// 触发:在中文名目录下建 first 并调用 phase("verifying", ...),趁 first 还活着读文件; +// 再在同一目录建 second。 +// 期望:first 存活期间文件里已有 2 条记录(operation_started + phase_started), +// 末条的 phase / details / elapsed_ms / UTC 时间('Z' 结尾)都正确; +// second 的 operation_started 追加为第 3 条,operation_id 与 first 不同; +// with_location 的结果包含日志路径。 TEST_F(UpgradeDiagnostics, RecordsAreFlushedBeforeDestructionAndAppendAcrossAttempts) { DiagnosticLog first("upgrade", root / fs::u8path(u8"日志")); first.phase("verifying", {{"expected_size", 123}, {"actual_size", 100}}); @@ -46,6 +75,11 @@ TEST_F(UpgradeDiagnostics, RecordsAreFlushedBeforeDestructionAndAppendAcrossAtte EXPECT_NE(first.with_location("failed").find(first.path()), std::string::npos); } +// 场景 2:记录内容脱敏与截断。 +// 触发:record 的 details 里有带 user:password@、?token=、#fragment 的 URL(含大写 scheme、 +// 数组里的 URL、IPv6 主机),以及一个 9000 字节的长字符串。 +// 期望:落盘记录里找不到任何凭据 / 查询值 / 片段,主机与路径("host/pkg")保留; +// 长字符串截到 8192 字节再加 "...[truncated]" 标记,所以断言 < 8300。 TEST_F(UpgradeDiagnostics, RedactsNestedUrlsAndBoundsErrorStrings) { DiagnosticLog log("upgrade", root); log.record("failure", {{"error", "GET HTTPS://user:password@host/pkg?token=secret#fragment failed"}, @@ -60,6 +94,10 @@ TEST_F(UpgradeDiagnostics, RedactsNestedUrlsAndBoundsErrorStrings) { EXPECT_LT(record["details"]["long_error"].get().size(), 8300U); } +// 场景 3:多线程并发写同一个文件。 +// 触发:4 个线程各建一个 DiagnosticLog(同一目录、同一进程,所以是同一个文件),各写 20 条。 +// 期望:文件恰好 84 行(4 × (1 条 operation_started + 20 条 sample)),每行都能解析成 JSON —— +// 进程级 file_mutex 保证整行写入,不会交错出半行。 TEST_F(UpgradeDiagnostics, ConcurrentWritersLeaveCompleteJsonRecords) { std::vector writers; for (int i = 0; i < 4; ++i) { @@ -73,6 +111,10 @@ TEST_F(UpgradeDiagnostics, ConcurrentWritersLeaveCompleteJsonRecords) { EXPECT_EQ(read_records(acecode::path_to_utf8(file)).size(), 84U); } +// 场景 4:日志目录被同名普通文件占住。 +// 触发:目录路径上是一个普通文件,DiagnosticLog 构造时 create_directories 失败。 +// 期望:构造与 record 都不抛异常;path() 为空(不能把不存在的文件当日志位置报给用户); +// error() 非空;with_location 保留原错误并追加「diagnostics unavailable」说明。 TEST_F(UpgradeDiagnostics, UnwritableDirectoryDoesNotThrowOrAdvertiseMissingFile) { const auto blocked = root / "blocked"; std::ofstream(blocked) << "file"; @@ -84,4 +126,46 @@ TEST_F(UpgradeDiagnostics, UnwritableDirectoryDoesNotThrowOrAdvertiseMissingFile EXPECT_NE(log.with_location("original failure").find("diagnostics unavailable"), std::string::npos); } +// 场景 5:日志不可用时,同一段错误文本经过两次 with_location。 +// 触发:日志目录被占住(同场景 4),还原 GUI 升级任务的调用链:run_upgrade_command 失败时把 +// with_location(错误) 写进 err;/api/update/start 的任务线程去掉末尾空白后,把这段文本 +// 交给同一个 DiagnosticLog 再调一次 with_location,结果作为 job.error 显示在界面上。 +// 期望:第一次调用的输出不变 —— 原文 + "\n" + 说明行;第二次调用原样返回第一次的结果, +// "Upgrade diagnostics unavailable or incomplete: " 恰好出现一次; +// 文本末尾还带换行(err 流不经 trim 直接传入)时同样不再追加。 +// 回归:修复前第二次调用无条件再追加一遍说明行,job.error 里这一行重复两次。 +TEST_F(UpgradeDiagnostics, RepeatedWithLocationKeepsSingleUnavailableLine) { + const auto blocked = root / "blocked"; + std::ofstream(blocked) << "file"; + DiagnosticLog log("gui_upgrade", blocked); + ASSERT_FALSE(log.error().empty()); + const std::string marker = "Upgrade diagnostics unavailable or incomplete: "; + + const auto once = log.with_location("acecode upgrade: download failed"); + EXPECT_EQ(once, "acecode upgrade: download failed\n" + marker + log.error()); + + const auto twice = log.with_location(once); + EXPECT_EQ(twice, once); + EXPECT_EQ(count_occurrences(twice, marker), 1U) << twice; + EXPECT_EQ(log.with_location(once + "\n"), once + "\n"); +} + +// 场景 6:去重只认整行完全相同。 +// 触发:日志目录被占住;消息里已有一行以说明行开头、后面还接着别的文字(如上一次尝试留下的 +// "... (previous attempt)"),或说明行前面还有别的文字("note: " + 说明行)。 +// 期望:两种都不是同一行,with_location 照常在末尾追加本日志的说明行 —— +// 不能因为子串命中就把本次的日志失败原因吞掉。 +TEST_F(UpgradeDiagnostics, WithLocationDedupesOnlyIdenticalUnavailableLine) { + const auto blocked = root / "blocked"; + std::ofstream(blocked) << "file"; + DiagnosticLog log("gui_upgrade", blocked); + ASSERT_FALSE(log.error().empty()); + const auto line = "Upgrade diagnostics unavailable or incomplete: " + log.error(); + + const auto longer = "failed\n" + line + " (previous attempt)"; + EXPECT_EQ(log.with_location(longer), longer + "\n" + line); + const auto embedded = "failed\nnote: " + line; + EXPECT_EQ(log.with_location(embedded), embedded + "\n" + line); +} + } // namespace diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 2a69b0bf5..ca6019299 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -65,6 +65,7 @@ #include "tool/mtime_tracker.hpp" #include "tool/tool_executor.hpp" #include "session_host/tools/task_suggestion_tools.hpp" +#include "upgrade/diagnostics.hpp" #include "upgrade/manifest.hpp" #include "test_support/agent/stub_provider.hpp" #include "utils/base64.hpp" @@ -9797,6 +9798,199 @@ TEST(WebServerHttp, FailedUpdateJobCanBeRetried) { EXPECT_NE(read_text(log_path).find(retry_id), std::string::npos); } +// 场景:GUI 升级任务失败,错误文本里既有已是 UTF-8 的中文路径,又混进一段 GBK 的系统错误文本 +// (中文 Windows 上 MSVC 的 ec.message() 走 ANSI 代码页,漏网到 job.error 的就是这种串)。 +// 触发:注入的 runner 返回 1,error = "failed to copy C:\Users\张三\...\acecode.exe: " + +// GBK「拒绝访问。」;任务落到 failed 后依次请求 GET /api/update/jobs/、 +// GET /api/update/job、POST /api/update/jobs//cancel(已失败的任务不能取消,409 也带 job)。 +// 期望:三个接口分别 200 / 200 / 409,响应都能被 json::parse 严格解析;error 里的 UTF-8 中文路径 +// 原样保留(出口兜底只替换非法字节,没有把整串按 GBK 重新解码),GBK 字节变成 U+FFFD。 +// 回归:修复前这三个接口用默认严格 dump(),遇到 0xBE 抛 type_error.316,全部返回 500 +// INTERNAL_ERROR;失败的任务一直留在内存里,前端每次轮询都 500。 +TEST(WebServerHttp, FailedUpdateJobWithGbkErrorStaysValidJson) { + LocalUpdateServer update_server([](httplib::Server& s) { + s.Get("/aceupdate.json", [](const httplib::Request&, httplib::Response& res) { + res.set_content(update_manifest_for("9.9.9"), "application/json"); + }); + }); + const std::string utf8_path = "C:\\Users\\张三\\AppData\\Local\\ACECode\\acecode.exe"; + // 「拒绝访问。」的 GBK 编码,首字节 0xBE 在 UTF-8 里是孤立的续字节。 + const std::string gbk_os_text = "\xBE\xDC\xBE\xF8\xB7\xC3\xCE\xCA\xA1\xA3"; + WebServerFixture fx( + true, + false, + {}, + true, + [utf8_path, gbk_os_text](const acecode::AppConfig&, + acecode::upgrade::UpgradeProgressCallback publish, + acecode::upgrade::UpgradeCancelCheck, + std::string* error) { + acecode::upgrade::UpgradeProgress progress; + progress.phase = acecode::upgrade::UpgradePhase::Installing; + progress.target_version = "9.9.9"; + publish(progress); + if (error) *error = "failed to copy " + utf8_path + ": " + gbk_os_text; + return 1; + }); + fx.cfg.upgrade.base_url = update_server.base_url(); + fx.cfg.upgrade.timeout_ms = 3000; + + auto start = cpr::Post(cpr::Url{fx.url("/api/update/start")}); + ASSERT_EQ(start.status_code, 202) << start.text; + const std::string job_id = json::parse(start.text)["job_id"]; + json failed; + for (int i = 0; i < 100; ++i) { + auto poll = cpr::Get(cpr::Url{fx.url("/api/update/jobs/" + job_id)}); + ASSERT_EQ(poll.status_code, 200) << poll.text; + failed = json::parse(poll.text); + if (failed["state"] == "failed") break; + std::this_thread::sleep_for(10ms); + } + ASSERT_EQ(failed["state"], "failed"); + const std::string error = failed["error"].get(); + EXPECT_EQ(error.rfind("failed to copy " + utf8_path + ": ", 0), 0u) << error; + EXPECT_NE(error.find("\xEF\xBF\xBD"), std::string::npos) << error; + + auto latest = cpr::Get(cpr::Url{fx.url("/api/update/job")}); + ASSERT_EQ(latest.status_code, 200) << latest.text; + EXPECT_EQ(json::parse(latest.text)["error"], error); + + auto cancel = cpr::Post(cpr::Url{fx.url("/api/update/jobs/" + job_id + "/cancel")}); + ASSERT_EQ(cancel.status_code, 409) << cancel.text; + const auto cancel_body = json::parse(cancel.text); + EXPECT_EQ(cancel_body["error"], "UPDATE_NOT_CANCELLABLE"); + EXPECT_EQ(cancel_body["job"]["error"], error); +} + +// 场景:升级 runner 抛出带 GBK 文本的异常(MSVC 的 filesystem_error / system_error 的 what() +// 走 ANSI 代码页,中文用户名路径与系统错误文本都会是 GBK)。 +// 触发:注入的 runner 抛 std::runtime_error("cannot open C:\Users\" + GBK「张三」 + +// "\AppData\Local\ACECode\acecode.exe"),任务线程在 catch 里把 what() 写进 job.error。 +// 期望:GET /api/update/jobs/ 返回 200 且可严格解析;error 以 +// "cannot open C:\Users\" + ensure_utf8(GBK「张三」) + "\AppData\" 开头(中文 Windows 上就是 +// 「张三」本身,其他代码页上是对应的合法字符),且不含 U+FFFD —— 异常文本在产生处转码, +// 而不是等出口兜底把用户名换成替换符。 +// 回归:修复前任务线程原样保存 e.what(),轮询接口 500;只有出口兜底而不转码时接口虽然 200, +// 用户名却变成一串 U+FFFD,看不出是哪个路径出了错。 +TEST(WebServerHttp, UpdateJobExceptionTextIsConvertedToUtf8) { + LocalUpdateServer update_server([](httplib::Server& s) { + s.Get("/aceupdate.json", [](const httplib::Request&, httplib::Response& res) { + res.set_content(update_manifest_for("9.9.9"), "application/json"); + }); + }); + // 「张三」的 GBK 编码:四个字节在 UTF-8 里都非法,出口兜底会把它们全换成 U+FFFD。 + const std::string gbk_user = "\xD5\xC5\xC8\xFD"; + WebServerFixture fx( + true, + false, + {}, + true, + [gbk_user](const acecode::AppConfig&, + acecode::upgrade::UpgradeProgressCallback publish, + acecode::upgrade::UpgradeCancelCheck, + std::string*) -> int { + acecode::upgrade::UpgradeProgress progress; + progress.phase = acecode::upgrade::UpgradePhase::Installing; + progress.target_version = "9.9.9"; + publish(progress); + throw std::runtime_error("cannot open C:\\Users\\" + gbk_user + + "\\AppData\\Local\\ACECode\\acecode.exe"); + }); + fx.cfg.upgrade.base_url = update_server.base_url(); + fx.cfg.upgrade.timeout_ms = 3000; + + auto start = cpr::Post(cpr::Url{fx.url("/api/update/start")}); + ASSERT_EQ(start.status_code, 202) << start.text; + const std::string job_id = json::parse(start.text)["job_id"]; + json failed; + for (int i = 0; i < 100; ++i) { + auto poll = cpr::Get(cpr::Url{fx.url("/api/update/jobs/" + job_id)}); + ASSERT_EQ(poll.status_code, 200) << poll.text; + failed = json::parse(poll.text); + if (failed["state"] == "failed") break; + std::this_thread::sleep_for(10ms); + } + ASSERT_EQ(failed["state"], "failed"); + const std::string error = failed["error"].get(); + const std::string expected_prefix = + "cannot open C:\\Users\\" + acecode::ensure_utf8(gbk_user) + "\\AppData\\"; + EXPECT_EQ(error.rfind(expected_prefix, 0), 0u) << error; + EXPECT_EQ(error.find("\xEF\xBF\xBD"), std::string::npos) << error; +} + +// 场景:GUI 升级任务失败,且升级诊断日志不可用(logs 目录被同名普通文件占住)。 +// 触发:fixture 建好后把 fx.logs_dir 换成普通文件,/api/update/start 的 DiagnosticLog 建不出目录; +// 注入的 runner 按 run_upgrade_command 的方式报错 —— 它写进 err 的文本已经过一次 +// with_location,末行就是 "Upgrade diagnostics unavailable or incomplete: <原因>"; +// 任务线程随后再对这段文本调用一次 with_location,写进 job.error。 +// 期望:job 终态 failed;error 含原始失败原因,说明行恰好出现一次且原因与 log_error 一致; +// 没有 log_path(日志文件不存在,不能报给用户)。 +// 回归:修复前任务线程无条件再追加一遍,界面显示的错误里这一行重复两次。 +TEST(WebServerHttp, FailedUpdateJobReportsUnavailableDiagnosticsOnce) { + LocalUpdateServer update_server([](httplib::Server& s) { + s.Get("/aceupdate.json", [](const httplib::Request&, httplib::Response& res) { + res.set_content(update_manifest_for("9.9.9"), "application/json"); + }); + }); + // fixture 构造前还不知道临时目录,runner 经 shared_ptr 拿到被占住的日志目录。 + auto blocked_logs_dir = std::make_shared(); + WebServerFixture fx( + true, + false, + {}, + true, + [blocked_logs_dir](const acecode::AppConfig&, + acecode::upgrade::UpgradeProgressCallback publish, + acecode::upgrade::UpgradeCancelCheck, + std::string* error) { + acecode::upgrade::UpgradeProgress progress; + progress.phase = acecode::upgrade::UpgradePhase::Downloading; + progress.target_version = "9.9.9"; + publish(progress); + // 同一个被占住的目录得到同样的失败原因,与 run_upgrade_command 写进 err 的文本一致。 + acecode::upgrade::DiagnosticLog runner_log("upgrade", *blocked_logs_dir); + if (error) *error = runner_log.with_location("acecode upgrade: download failed"); + return 1; + }); + fx.cfg.upgrade.base_url = update_server.base_url(); + fx.cfg.upgrade.timeout_ms = 3000; + std::filesystem::remove_all(fx.logs_dir); + write_text(fx.logs_dir, "blocks the upgrade log directory"); + *blocked_logs_dir = fx.logs_dir; + + auto start = cpr::Post(cpr::Url{fx.url("/api/update/start")}); + ASSERT_EQ(start.status_code, 202) << start.text; + const auto started = json::parse(start.text); + EXPECT_FALSE(started.contains("log_path")) << start.text; + EXPECT_TRUE(started.contains("log_error")) << start.text; + const auto job_id = started["job_id"].get(); + + json failed; + std::string failed_text; + for (int i = 0; i < 200; ++i) { + auto poll = cpr::Get(cpr::Url{fx.url("/api/update/jobs/" + job_id)}); + ASSERT_EQ(poll.status_code, 200) << poll.text; + failed_text = poll.text; + failed = json::parse(poll.text); + if (failed["state"] == "failed") break; + std::this_thread::sleep_for(10ms); + } + ASSERT_EQ(failed["state"], "failed") << failed_text; + EXPECT_FALSE(failed.contains("log_path")) << failed_text; + ASSERT_TRUE(failed.contains("log_error")) << failed_text; + const auto message = failed["error"].get(); + const std::string marker = "Upgrade diagnostics unavailable or incomplete: "; + EXPECT_NE(message.find("acecode upgrade: download failed"), std::string::npos) << message; + EXPECT_NE(message.find(marker + failed["log_error"].get()), std::string::npos) + << message; + size_t occurrences = 0; + for (auto pos = message.find(marker); pos != std::string::npos; + pos = message.find(marker, pos + marker.size())) { + ++occurrences; + } + EXPECT_EQ(occurrences, 1U) << message; +} + TEST(WebServerHttp, ComputerUseSettingsPersistAndRevokeToolsLive) { struct RevokeComputerUse { ~RevokeComputerUse() { acecode::computer_use::set_enabled(false); } diff --git a/web/README.md b/web/README.md index c074ea9e8..bc0df7285 100644 --- a/web/README.md +++ b/web/README.md @@ -132,6 +132,7 @@ Tailwind v4 + CSS 变量。`` 切主题,变量值 |---|---|---| | `react` / `react-dom` | ^18.3.1 | UI | | `markdown-it` | ^14 | GFM markdown 渲染(表格/任务清单/嵌套 list) | +| `markdown-it-cjk-friendly` | 3.0.0 | 兼容中日韩标点相邻的强调,如 `**建议:**共享`;完整、行内与流式分块渲染共用 | | `markdown-it-task-lists` | ^2 | task list 渲染 plugin | | `highlight.js` | ^11 | 代码高亮(core + 12 种语言:c/cpp/js/ts/python/bash/json/diff/markdown/rust/go/yaml) | | `diff2html` | ^3.4 | file_edit / file_write 工具的 hunks → 着色 diff | diff --git a/web/package.json b/web/package.json index f502d7cba..6eac105d9 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "i18next": "26.3.6", "katex": "^0.16.47", "markdown-it": "^14.1.1", + "markdown-it-cjk-friendly": "3.0.0", "markdown-it-task-lists": "^2.1.1", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9bc291e1b..4cb744261 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: markdown-it: specifier: ^14.1.1 version: 14.1.1 + markdown-it-cjk-friendly: + specifier: 3.0.0 + version: 3.0.0(markdown-it@14.1.1) markdown-it-task-lists: specifier: ^2.1.1 version: 2.1.1 @@ -1379,6 +1382,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1600,6 +1607,16 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it-cjk-friendly@3.0.0: + resolution: {integrity: sha512-y4OkqwenFXmb1i3w7qfh9E60qNX6U4KzECke8N/7io9SARFSvbR/wde0q/l6/Az9doieLOIw7ZsbDSgl27DR4g==} + engines: {node: '>=18'} + peerDependencies: + '@types/markdown-it': '>=14.2.0' + markdown-it: '*' + peerDependenciesMeta: + '@types/markdown-it': + optional: true + markdown-it-task-lists@2.1.1: resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} @@ -3378,6 +3395,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.7.0: {} + graceful-fs@4.2.11: {} hachure-fill@0.5.2: {} @@ -3564,6 +3583,11 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it-cjk-friendly@3.0.0(markdown-it@14.1.1): + dependencies: + get-east-asian-width: 1.7.0 + markdown-it: 14.1.1 + markdown-it-task-lists@2.1.1: {} markdown-it@14.1.1: diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 9764da665..ae63ebb96 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -208,6 +208,7 @@ import { withCreateSessionPreferences, } from '../lib/sessionModel.js'; import { composerReasoningOptions } from '../lib/modelReasoning.js'; +import { requestSavedModelReasoningSync } from '../lib/modelReasoningSync.js'; import { normalizePermissionMode, permissionModeOption } from '../lib/permissionMode.js'; import { ATTACHMENT_HARD_LIMIT_BYTES, normalizeImageFile } from '../lib/imageNormalize.js'; import { VsIcon } from './Icon.jsx'; @@ -2438,6 +2439,13 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses return () => clearTimeout(timer); }, [composerValue, composerContent, composerAttachments, draftReadyKey, draftSessionKey, draftWorkspaceHash, persistDraftValue, sid]); + useEffect(() => { + if (sid) return; + // Retry discovery on home entry even if startup discovery failed. Profile + // update notifications only reload local state and must not retrigger this. + void requestSavedModelReasoningSync(api); + }, [api, sid]); + useEffect(() => { let cancelled = false; setPendingModelName(''); @@ -2504,6 +2512,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (modelRefreshing || reasoningSwitching) return; const targetSid = sid; setModelRefreshing(true); + void requestSavedModelReasoningSync(api); try { const requests = targetSid ? [api.listModels(), api.reloadSessionModel(targetSid)] diff --git a/web/src/lib/markdown.js b/web/src/lib/markdown.js index dbbe2bb28..6b2e2813f 100644 --- a/web/src/lib/markdown.js +++ b/web/src/lib/markdown.js @@ -6,6 +6,7 @@ // 公开 API: renderMarkdown(src) -> string,签名跟旧版一致,Message.jsx 零改。 import MarkdownIt from 'markdown-it'; +import markdownItCjkFriendly from 'markdown-it-cjk-friendly'; import taskLists from 'markdown-it-task-lists'; import { classifyFileLink } from './fileLink.js'; import { createMarkdownHighlightCache } from './markdownHighlightCache.js'; @@ -137,6 +138,8 @@ const md = new MarkdownIt({ }, }); +// Preserve source text while allowing CJK emphasis such as **建议:**共享. +md.use(markdownItCjkFriendly); md.use(taskLists, { enabled: false, label: false }); md.use(markdownColorSwatches); diff --git a/web/src/lib/markdownCjkEmphasis.test.js b/web/src/lib/markdownCjkEmphasis.test.js new file mode 100644 index 000000000..2829da3bd --- /dev/null +++ b/web/src/lib/markdownCjkEmphasis.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { renderMarkdown, renderMarkdownBlocks, renderMarkdownInline } from './markdown.js'; + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +const emphasisCases = [ + ['**建议:**共享', '建议:共享'], + ['前文**(重点)**后文', '前文(重点)后文'], + ['*注意:*继续', '注意:继续'], + ['~~旧值:~~新值', '旧值:新值'], + ['**注意。**続き', '注意。続き'], + ['**주의。**다음', '주의。다음'], + ['提示:**(可选)**', '提示:(可选)'], + ['结果:**"通过"**', '结果:"通过"'], + ['中文,__重点__。', '中文,重点。'], + ['**建议:** 共享', '建议: 共享'], + ['**建议**:共享', '建议:共享'], + ['**bold** and *italic* and ~~old~~', 'bold and italic and old'], + ['**Note:**text', '**Note:**text'], + ['some_variable_name', 'some_variable_name'], + ['** 建议:**共享', '** 建议:**共享'], + ['**建议: **共享', '**建议: **共享'], +]; + +for (const [source, expected] of emphasisCases) { + run(`CJK emphasis keeps inline, document, and block output consistent: ${source}`, () => { + assert.equal(renderMarkdownInline(source), expected); + assert.equal(renderMarkdown(source), `

${expected}

\n`); + assert.equal(renderMarkdownBlocks(source).map((block) => block.html).join(''), `

${expected}

\n`); + }); +} + +run('CJK list labels render in bold beside inline code', () => { + const labels = ['同会话消息排序', '投递账本', '手机控制面', '桌面可观测性', '统一帮助', '第二个平台明确后再抽公共契约']; + const source = labels.map((label) => `- **${label}:**继续处理 \`status\``).join('\n'); + const html = renderMarkdown(source); + for (const label of labels) { + assert.ok(html.includes(`
  • ${label}:继续处理 status
  • `), html); + } + assert.equal(renderMarkdownBlocks(source).map((block) => block.html).join(''), html); +}); + +run('CJK emphasis preserves literal code, escaped markers, and HTML escaping', () => { + assert.equal(renderMarkdownInline('`**建议:**共享`'), '**建议:**共享'); + assert.equal(renderMarkdownInline(String.raw`\*\*建议:\*\*共享`), '**建议:**共享'); + const code = renderMarkdown('```\n**建议:**共享\n```'); + assert.ok(code.includes('>**建议:**共享\n'), code); + assert.ok(!code.includes(''), code); + assert.equal( + renderMarkdownInline('**建议:**'), + '建议:<script>alert(1)</script>', + ); + const unsafeLink = renderMarkdown('**建议:**[链接](javascript:alert(1))'); + assert.ok(!unsafeLink.includes(' { + const prefix = '前一段已完成。\n\n'; + const source = '**建议:**共享'; + const prefixHtml = renderMarkdown(prefix); + assert.ok(!renderMarkdownInline('**建议:').includes('')); + assert.equal(renderMarkdownInline('**建议:**'), '建议:'); + for (let length = 1; length <= source.length; length += 1) { + const streamed = prefix + source.slice(0, length); + const blocks = renderMarkdownBlocks(streamed); + assert.equal(blocks[0].html, prefixHtml); + assert.equal(blocks.map((block) => block.html).join(''), renderMarkdown(streamed)); + } + const finalBlocks = renderMarkdownBlocks(prefix + source); + assert.equal(finalBlocks.at(-1).html, '

    建议:共享

    \n'); +}); diff --git a/web/src/lib/modelReasoningSync.js b/web/src/lib/modelReasoningSync.js index 2364d8619..ce0741ada 100644 --- a/web/src/lib/modelReasoningSync.js +++ b/web/src/lib/modelReasoningSync.js @@ -13,11 +13,20 @@ export function subscribeModelProfileUpdates(connection, onChange) { }; } -// The POST only queues work; both failures are deliberately silent. +// The POST only queues work. Callers can start it without waiting for discovery. +export async function requestSavedModelReasoningSync(apiClient) { + try { + await apiClient.refreshModelReasoning(); + } catch { + // Missing metadata must not block the composer, including on older daemons. + } +} + +// Both failures are deliberately silent. // Reload in parallel so an unreachable provider never delays the local list. export async function refreshSavedModelReasoning(apiClient, reload) { await Promise.allSettled([ - Promise.resolve().then(() => apiClient.refreshModelReasoning()), + requestSavedModelReasoningSync(apiClient), Promise.resolve().then(reload), ]); } diff --git a/web/src/lib/modelReasoningSync.test.js b/web/src/lib/modelReasoningSync.test.js index afa1b64fa..072f33812 100644 --- a/web/src/lib/modelReasoningSync.test.js +++ b/web/src/lib/modelReasoningSync.test.js @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { refreshSavedModelReasoning, subscribeModelProfileUpdates } from './modelReasoningSync.js'; +import { refreshSavedModelReasoning, requestSavedModelReasoningSync, subscribeModelProfileUpdates } from './modelReasoningSync.js'; +import { composerReasoningOptions } from './modelReasoning.js'; +import { normalizeModelOptions, withCreateSessionPreferences } from './sessionModel.js'; const connection = new EventTarget(); let updates = 0; @@ -20,6 +22,38 @@ message('model_profiles_updated'); connection.dispatchEvent(new Event('open')); assert.equal(updates, 2); +await requestSavedModelReasoningSync({ refreshModelReasoning: async () => { throw new Error('offline'); } }); +await requestSavedModelReasoningSync({ refreshModelReasoning() { throw new Error('unavailable'); } }); +await requestSavedModelReasoningSync({}); + +// A new conversation reads saved profiles, which can lack a declaration even +// when existing sessions still have one. Discovery restores the home control. +let savedModels = [{ name: 'ACEModel-starrylight', provider: 'openai' }]; +let homeModels = normalizeModelOptions(savedModels); +let syncRequests = 0; +const offHome = subscribeModelProfileUpdates(connection, () => { + homeModels = normalizeModelOptions(savedModels); +}); +assert.equal(composerReasoningOptions(homeModels[0]), null); +await requestSavedModelReasoningSync({ refreshModelReasoning: async () => { + syncRequests += 1; + savedModels = [{ ...savedModels[0], reasoning: { + supported: true, default_enabled: true, + supported_efforts: ['low', 'medium', 'high'], default_effort: 'medium', + } }]; + message('model_profiles_updated'); +} }); +assert.equal(composerReasoningOptions(homeModels[0]).label, '中'); +assert.equal(composerReasoningOptions(homeModels[0], 'high').label, '高'); +const creation = withCreateSessionPreferences({}, { + modelName: homeModels[0].name, reasoningEffort: 'high', +}); +assert.equal(creation.reasoning_effort, 'high'); +assert.equal(savedModels[0].reasoning.default_effort, 'medium'); +message('model_profiles_updated'); +assert.equal(syncRequests, 1, 'profile notifications must not queue more remote syncs'); +offHome(); + let release; const blocked = new Promise((resolve) => { release = resolve; }); let localReloads = 0; @@ -44,4 +78,7 @@ assert.match(settings, /subscribeModelProfileUpdates\(connection,/); assert.match(settings, /loadSavedModels\(\{ quiet: true, silent: true \}\)/); const app = readFileSync(new URL('../App.jsx', import.meta.url), 'utf8'); assert.match(app, /subscribeModelProfileUpdates\(connection,[\s\S]*?setModelProfileRevision/); +const chat = readFileSync(new URL('../components/ChatView.jsx', import.meta.url), 'utf8'); +assert.match(chat, /useEffect\(\(\) => \{\s*if \(sid\) return;[\s\S]*?requestSavedModelReasoningSync\(api\);\s*\}, \[api, sid\]\)/); +assert.match(chat, /const refreshSessionModels = useCallback\(async \(\) => \{[\s\S]*?requestSavedModelReasoningSync\(api\)/); console.log('modelReasoningSync.test.js: all tests passed'); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 86301c7fa..671779597 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -46,6 +46,7 @@ import './createdFileSource.test.js'; import './shellCommandPresentation.test.js'; import './sourceCodeHighlight.test.js'; import './markdownBlocks.test.js'; +import './markdownCjkEmphasis.test.js'; import './markdownColorSwatches.test.js'; import './markdownHighlightCache.test.js'; import './markdownWysiwyg.test.js'; diff --git a/web/src/lib/sessionTranscript.test.js b/web/src/lib/sessionTranscript.test.js index 593d50378..a537b52ec 100644 --- a/web/src/lib/sessionTranscript.test.js +++ b/web/src/lib/sessionTranscript.test.js @@ -2020,6 +2020,40 @@ run('history load 消费 turn_timing 并用持久 duration 渲染 processed summ assert.equal(projectLoadedItems(reloaded.items)[1].title, '已处理 1m 5s'); }); +run('蜂群通知在实时完成、历史加载和 transcript_replace 中只生成一条持久耗时摘要', () => { + const mail = (id, type, status = '') => ({ + id, role: 'user', content: `report ${id}`, timestamp: '2026-10-03T00:01:00Z', + metadata: { inter_agent: { type, status, sender: `/root/${id}`, recipient: '/root' } }, + }); + const messages = [ + { id: 'u-mesh', role: 'user', content: 'review', timestamp: '2026-10-03T00:00:00Z' }, + { id: 'a-mesh-1', role: 'assistant', content: 'inspect', timestamp: '2026-10-03T00:00:01Z' }, + mail('security_review', 'MESSAGE'), + { id: 'a-mesh-2', role: 'assistant', content: 'check findings', timestamp: '2026-10-03T00:02:00Z' }, + mail('delivery_review', 'FINAL_ANSWER', 'completed'), + { id: 'a-mesh-3', role: 'assistant', content: 'verify', timestamp: '2026-10-03T00:03:00Z' }, + mail('ux_review', 'FINAL_ANSWER', 'errored'), + { id: 'a-mesh-4', role: 'assistant', content: 'prepare summary', timestamp: '2026-10-03T00:04:00Z' }, + { id: 'a-mesh-final', role: 'assistant', content: 'final answer', timestamp: '2026-10-03T00:22:00Z' }, + turnTimingMessage('u-mesh', 1320000), + ]; + const live = reduceMany(messages.map((payload) => ({ type: 'message', payload }))); + const history = loadTranscriptHistory(createTranscriptState(), { messages, events: [] }).state; + const replaced = reduceTranscriptEvent(live, { type: 'transcript_replace', payload: { messages } }).state; + for (const state of [live, history, replaced]) { + const projected = projectCollapsedTranscriptItems(state.items); + const summaries = projected.filter((item) => item.mode === 'processed'); + assert.equal(summaries.length, 1); + assert.equal(summaries[0].title, '已处理 22m 0s'); + assert.deepEqual(summaries[0].collapsedItems.map((item) => item.content), ['inspect', 'check findings', 'verify', 'prepare summary']); + assert.deepEqual(projected.filter((item) => item.metadata?.inter_agent).map((item) => [item.role, item.content]), [ + ['system', 'report security_review'], ['system', 'report delivery_review'], ['system', 'report ux_review'], + ]); + assert.equal(projected.filter((item) => item.role === 'user').length, 1); + assert.equal(projected.at(-1).content, 'final answer'); + } +}); + run('history load 从 meta 消息恢复 turn_net_diff 并关联用户 item', () => { const loaded = loadTranscriptHistory(createTranscriptState({ title: 's1' }), { messages: [ diff --git a/web/src/lib/transcriptProjection.js b/web/src/lib/transcriptProjection.js index cc0e52915..81bc1ff8a 100644 --- a/web/src/lib/transcriptProjection.js +++ b/web/src/lib/transcriptProjection.js @@ -9,6 +9,7 @@ import { isImageAttachment, normalizeAttachmentList } from './messageAttachments import { questionFeedbackForItem } from './questionFeedback.js'; import { isShellCommand } from './shellCommandPresentation.js'; import { mergeLegacyGoalNotices } from './systemNotice.js'; +import { interAgentEnvelope } from './interAgentMessage.js'; function isUserMessage(item) { return item?.kind === 'msg' && item.role === 'user'; @@ -954,22 +955,29 @@ function preservedTurnPrefix(items) { return items.filter((item) => isUserMessage(item)); } -function appendFinalProcessedItems(out, items, beforeIndex, endItem) { +function appendProcessedItems(out, items, endItem) { const processed = []; + let summaryIndex = -1; const flushProcessed = () => { - pushProcessedSummary(out, processed, endItem); + if (summaryIndex < 0) return; + out[summaryIndex] = makeProcessedItem(processed, endItem); processed.length = 0; + summaryIndex = -1; }; - for (let i = 0; i < beforeIndex; i += 1) { - const item = items[i]; - if (isUserMessage(item)) continue; - if (isEmptyAssistantMessage(item)) continue; - if (isTaskCompleteTool(item) || isTaskCompleteToolCallMessage(item)) continue; + for (const item of items) { if (isProcessedActivityItem(item)) { + if (summaryIndex < 0) { + summaryIndex = out.length; + out.push(null); + } processed.push(item); } else { - flushProcessed(); + // 跨 agent 通知保留为独立行,但不是已处理活动的分段边界。 + // 摘要锚定首个活动,避免每封邮件都重复显示整回合的持久耗时。 + const interAgentNotice = item?.kind === 'msg' && item.role === 'system' + && interAgentEnvelope(item.metadata) !== null; + if (!interAgentNotice) flushProcessed(); out.push(item); } } @@ -977,10 +985,14 @@ function appendFinalProcessedItems(out, items, beforeIndex, endItem) { flushProcessed(); } -function pushProcessedSummary(out, processed, endItem) { - if (processed.length > 0) { - out.push(makeProcessedItem(processed, endItem)); - } +function appendFinalProcessedItems(out, items, beforeIndex, endItem) { + const activityItems = items.slice(0, beforeIndex).filter((item) => ( + !isUserMessage(item) + && !isEmptyAssistantMessage(item) + && !isTaskCompleteTool(item) + && !isTaskCompleteToolCallMessage(item) + )); + appendProcessedItems(out, activityItems, endItem); } function projectFinalCollapsedTurn(items, options = {}) { @@ -1101,26 +1113,7 @@ function projectCompletionTurn(items, options = {}) { if (finalAssistantIndex < 0) return projectGenericTurn(items, options); const out = []; - const processed = []; - - const flushProcessed = () => { - if (processed.length > 0) { - out.push(makeProcessedItem(processed, items[taskIndex])); - processed.length = 0; - } - }; - - for (let i = 0; i < finalAssistantIndex; i += 1) { - const item = items[i]; - if (isProcessedActivityItem(item)) { - processed.push(item); - } else { - flushProcessed(); - out.push(item); - } - } - - flushProcessed(); + appendProcessedItems(out, items.slice(0, finalAssistantIndex), items[taskIndex]); out.push(items[finalAssistantIndex]); const tools = []; diff --git a/web/src/lib/transcriptProjection.test.js b/web/src/lib/transcriptProjection.test.js index 6947c5b29..7a6e8faa4 100644 --- a/web/src/lib/transcriptProjection.test.js +++ b/web/src/lib/transcriptProjection.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { __test__, projectCollapsedTranscriptItems } from './transcriptProjection.js'; import { fallbackToolSummary } from './toolSummaryFallback.js'; +import { presentInterAgentMessage } from './interAgentMessage.js'; function run(name, fn) { try { @@ -21,6 +22,13 @@ function assistant(id, content, ts = id * 1000, extra = {}) { return { kind: 'msg', id, role: 'assistant', content, ts, ...extra }; } +function interAgentNotice(id, type = 'MESSAGE', status = '') { + return presentInterAgentMessage({ + ...user(id, `report ${id}`), + metadata: { inter_agent: { type, status, sender: `/root/worker_${id}`, recipient: '/root' } }, + }); +} + function tool(id, { name = 'file_read', verb = 'Read', @@ -969,6 +977,66 @@ run('final assistant text 加 task_complete 时保留 final 并折叠前序活 assert.equal(projected.some((item) => item.kind === 'tool' && item.tool?.isTaskComplete), false); }); +run('蜂群通知穿插完成活动时总耗时只显示一次且通知正文保持可见', () => { + const activity = [assistant(2, 'inspect'), tool(4), assistant(7, 'checking'), tool(9)] + .map((item) => ({ ...item, turnDurationMs: 1320000 })); + const notices = [ + interAgentNotice(3), interAgentNotice(5, 'NEW_TASK'), + interAgentNotice(6, 'FINAL_ANSWER', 'completed'), interAgentNotice(8, 'FINAL_ANSWER', 'errored'), + ]; + const prefix = [user(1), activity[0], notices[0], activity[1], notices[1], notices[2], activity[2], notices[3], activity[3]]; + // 普通最终答复、task_complete、有后续系统提示的 completion 分支共用此约束。 + for (const ending of [ + [assistant(10, 'final')], + [assistant(10, 'final'), taskComplete(11)], + [taskComplete(11)], + [assistant(10, 'final'), taskComplete(11), { kind: 'msg', id: 12, role: 'system', content: 'notice' }], + ]) { + const input = [...prefix, ...ending]; + const before = JSON.stringify(input); + const projected = projectCollapsedTranscriptItems(input); + const summaries = projected.filter((item) => item.mode === 'processed'); + assert.equal(summaries.length, 1); + assert.equal(summaries[0].title, '已处理 22m 0s'); + assert.deepEqual(summaries[0].coveredItemIds, [2, 4, 7, 9]); + assert.deepEqual(summaries[0].collapsedItems, activity); + assert.equal(projected[1], summaries[0]); + assert.deepEqual(projected.filter((item) => item.metadata?.inter_agent), notices); + assert.equal(JSON.stringify(input), before); + } +}); + +run('蜂群通知不跨越提问、图片、普通系统行或真实用户回合合并摘要', () => { + const imageTool = tool(6); + imageTool.tool.attachments = [{ kind: 'image', mime_type: 'image/png', url: '/image.png' }]; + for (const boundary of [ + askQuestionTool(6), imageTool, + { kind: 'msg', id: 6, role: 'system', content: '/root/worker 发来消息' }, + user(6, 'next turn'), + ]) { + const projected = projectCollapsedTranscriptItems([ + user(1), tool(2), interAgentNotice(3), tool(4), assistant(5, 'first part'), + boundary, tool(7), interAgentNotice(8), tool(9), assistant(10, 'final'), + ]); + const summaries = projected.filter((item) => item.mode === 'processed'); + assert.equal(summaries.length, 2); + assert.deepEqual(summaries[0].coveredItemIds, boundary.role === 'user' ? [2, 4] : [2, 4, 5]); + assert.deepEqual(summaries[1].coveredItemIds, [7, 9]); + assert.equal(projected.some((item) => item.id === 6 || item.kind === 'media_group'), true); + } +}); + +run('运行中及关闭自动折叠时蜂群通知保留原有活动顺序', () => { + const items = [user(1), tool(2), interAgentNotice(3), tool(4), interAgentNotice(5), tool(6, { isDone: false })]; + const live = projectCollapsedTranscriptItems(items, { deferTrailingToolSummary: true }); + assert.equal(live.some((item) => item.mode === 'processed'), false); + assert.equal(live.at(-1).mode, 'live'); + const expanded = projectCollapsedTranscriptItems(items, { messageAutoCollapse: false }); + assert.deepEqual(expanded, items); + const noticesOnly = [user(1), interAgentNotice(2), interAgentNotice(3), assistant(4, 'final')]; + assert.deepEqual(projectCollapsedTranscriptItems(noticesOnly), noticesOnly); +}); + run('final assistant text 前的 AskUserQuestion 卡片不折叠进已处理', () => { const projected = projectCollapsedTranscriptItems([ user(1, 'implement'), From acf9381b7c6b98fb93c9cb8ac1d3fb5e06728072 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sun, 4 Oct 2026 01:04:33 +0800 Subject: [PATCH 2/3] Release ACECode v0.9.33 --- CMakeLists.txt | 2 +- docs/releases/v0.9.33.md | 11 +++++++++++ .../integrate-recent-work-release-0-9-33/tasks.md | 4 ++-- .../verification.md | 3 +++ vcpkg.json | 2 +- 5 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 docs/releases/v0.9.33.md diff --git a/CMakeLists.txt b/CMakeLists.txt index e7e077b3e..99cbaa64b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ if(CMAKE_HOST_WIN32 AND DEFINED CMAKE_TOOLCHAIN_FILE AND CMAKE_TOOLCHAIN_FILE MA set(VCPKG_TARGET_TRIPLET "x64-windows-static" CACHE STRING "Vcpkg target triplet" FORCE) endif() -project(acecode VERSION 0.9.32 LANGUAGES C CXX) +project(acecode VERSION 0.9.33 LANGUAGES C CXX) include(${CMAKE_SOURCE_DIR}/cmake/acecode_source_guards.cmake) diff --git a/docs/releases/v0.9.33.md b/docs/releases/v0.9.33.md new file mode 100644 index 000000000..a5b1d70c9 --- /dev/null +++ b/docs/releases/v0.9.33.md @@ -0,0 +1,11 @@ +# ACECode 0.9.33 + +- 新增网状蜂群协作:子代理可继续分工并互相传递消息,后台任务可打开对应子会话;子会话与主会话一同保存和删除。 +- 新对话也可查看和选择思考深度,后台补齐模型声明;选择会带入新建会话。 +- 修复蜂群通知穿插时重复显示「已处理」总耗时,保留各条通知和完整活动详情。 +- 改善中文 Markdown 的粗体、斜体及中英文混排显示,保留原始消息文本。 +- 修复 Windows 中文路径及系统错误导致升级接口返回异常的问题;升级失败信息可正常显示,诊断说明不再重复追加。 +- 统一 TUI、桌面、网页与无界面运行的全局/工作区记忆,支持查看、编辑和可选的空闲摘要;长会话压缩保留最近摘要和用户关键指令。 +- 加快长会话打开和侧栏刷新,修复重复工具调用 ID 导致的历史丢失、结果串用和旧输出覆盖,改善侧边对话及文件附件体验。 + +提供 Windows、macOS、Linux 和 UOS / Deepin 的完整平台下载包。安装新版后请重启 ACECode;macOS 同时提供 Intel 与 Apple Silicon 安装器及自动更新包。 diff --git a/openspec/changes/integrate-recent-work-release-0-9-33/tasks.md b/openspec/changes/integrate-recent-work-release-0-9-33/tasks.md index ec7a681f8..93177acc0 100644 --- a/openspec/changes/integrate-recent-work-release-0-9-33/tasks.md +++ b/openspec/changes/integrate-recent-work-release-0-9-33/tasks.md @@ -2,11 +2,11 @@ - [x] 1.1 盘点近 24 小时 master、worktree、远端和 reflog,定位 CI 阻断。 - [x] 1.2 整合升级 UTF-8、诊断去重及主工作区修复,核对来源覆盖及冲突。 -- [ ] 1.3 拆出 TUI tasks 命令并恢复严格检查,完成对应本机测试。 +- [x] 1.3 拆出 TUI tasks 命令并恢复严格检查,完成对应本机测试。 ## 2. 发布 -- [ ] 2.1 审核 seed 差异、执行必要升级兼容验证,完成前端及本机测试。 +- [x] 2.1 审核 seed 差异、执行必要升级兼容验证,完成前端及本机测试。 - [ ] 2.2 更新版本及说明,提交全部有效改动并通过 PR 合入受保护 master。 - [ ] 2.3 确认最终提交 CI 通过,发布 0.9.33,校验完整 18 项下载与六种更新 ZIP、两个 PKG 的镜像及公网哈希。 diff --git a/openspec/changes/integrate-recent-work-release-0-9-33/verification.md b/openspec/changes/integrate-recent-work-release-0-9-33/verification.md index 763d7a62c..aa23f83dd 100644 --- a/openspec/changes/integrate-recent-work-release-0-9-33/verification.md +++ b/openspec/changes/integrate-recent-work-release-0-9-33/verification.md @@ -16,6 +16,9 @@ - 前端全部修复在同一工作区通过 `pnpm test`、`pnpm build`;真实 ChatView 浏览器 fixture 验证宽屏/窄屏思考深度显示、选择传递、后台同步失败及草稿保留。 - 分层/尺寸和最终所有权严格检查通过;OpenSpec 三个相关变更严格验证通过。 - `tests/scripts/verify_release_assets_test.py`:6 项通过。 +- 重构工具自测 99 项通过;include、文档路径和迁移映射严格检查通过。 +- Windows Release 单测目标构建成功;旧 MSVC 结构化诊断曾输出 MSB8084 编码错误,关闭 `UseStructuredOutput` 后增量复核构建通过,无编译错误。285 项升级、UTF-8、蜂群/子会话、内置命令、种子及请求前缀守护测试通过,0 跳过、0 失败;使用短路径隔离 profile,未改变用户运行中的桌面实例。 +- 整合提交 e470bfde 已推送到 `codex/release-v0.9.33` 并建立 PR #101;该提交的分层、前端与 macOS 安装器 CI 已通过。最终版本提交另由 CI 验证,不把前一提交结果冒充最终结果。 - `assets/models_dev/MANIFEST.json` 快照为 2026-09-29,满足 30 天要求;未改模型目录或图标输入。 - 对比最近正式版 v0.9.30 到整合前 HEAD:`assets/seed`、`src/domain/skills/default_skill_seeder.cpp`、`tests/skills` 无差异;seed 与 manifest 均为 2026-09-28.1,无新种子迁移。 diff --git a/vcpkg.json b/vcpkg.json index 4aeff3ba3..a56c8449d 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "name": "acecode", - "version-semver": "0.9.32", + "version-semver": "0.9.33", "dependencies": [ "cpr", "crow", From 202eb34d823cc115543ce761a916d56b9589058f Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sun, 4 Oct 2026 01:19:11 +0800 Subject: [PATCH 3/3] Document Chinese Markdown compatibility fix --- .../fix-cjk-markdown-emphasis/.openspec.yaml | 2 ++ .../fix-cjk-markdown-emphasis/design.md | 26 +++++++++++++++++++ .../fix-cjk-markdown-emphasis/proposal.md | 25 ++++++++++++++++++ .../specs/webui-rich-rendering/spec.md | 25 ++++++++++++++++++ .../fix-cjk-markdown-emphasis/tasks.md | 18 +++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 openspec/changes/fix-cjk-markdown-emphasis/.openspec.yaml create mode 100644 openspec/changes/fix-cjk-markdown-emphasis/design.md create mode 100644 openspec/changes/fix-cjk-markdown-emphasis/proposal.md create mode 100644 openspec/changes/fix-cjk-markdown-emphasis/specs/webui-rich-rendering/spec.md create mode 100644 openspec/changes/fix-cjk-markdown-emphasis/tasks.md diff --git a/openspec/changes/fix-cjk-markdown-emphasis/.openspec.yaml b/openspec/changes/fix-cjk-markdown-emphasis/.openspec.yaml new file mode 100644 index 000000000..6a87d2b26 --- /dev/null +++ b/openspec/changes/fix-cjk-markdown-emphasis/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/fix-cjk-markdown-emphasis/design.md b/openspec/changes/fix-cjk-markdown-emphasis/design.md new file mode 100644 index 000000000..09fc990f3 --- /dev/null +++ b/openspec/changes/fix-cjk-markdown-emphasis/design.md @@ -0,0 +1,26 @@ +## Context + +问题与范围见 proposal.md。`web/src/lib/markdown.js` 维护单个 markdown-it 实例,完整、行内和分块渲染共用该实例。当前 Node 为 22,前端使用 ESM;插件 3.0.0 要求 Node >=18,提供默认导出,与现有调用方式兼容。 + +## Goals / Non-Goals + +- 目标:直接解析现有回复中的中日韩强调,不预处理或改写原文。 +- 非目标:修改 TUI、独立可视化编辑器或模型提示词;本次不发布或替换运行中的 Desktop 二进制。 + +## Decisions + +- 固定 `markdown-it-cjk-friendly` 为 3.0.0,通过 `md.use` 接入共享实例,保留任务列表、代码高亮、HTML 转义及链接处理。 +- 不全局移除 Unicode 标点判断。实测该方案会让 `提示:**(可选)**` 和 `中文,__重点__。` 由正常加粗退化为字面文本。 +- 不用正则替换原文。该做法容易误改代码、转义和流式中间状态,也会让显示与复制内容不一致。 +- 通过独立的渲染回归测试覆盖全角标点、中英混排、日文/韩文、删除线和流式状态;纳入现有 `runTests.js`。 +- 按上游 [使用说明](https://github.com/tats-u/markdown-cjk-friendly/tree/main/packages/markdown-it-cjk-friendly) 接入,实际兼容性以本仓库测试及构建结果为准。 + +## Risks / Trade-offs + +- 新增插件调整中日韩边界,包括删除线 → 同时覆盖已有正常写法与字面代码,执行全量 Web 测试。 +- 新依赖影响浏览器打包 → 执行 `pnpm build` 及现有旧版 WebKit 正则检查,并用浏览器验证生成的 HTML。 +- Web 构建成功不代表已安装 Desktop 生效 → 明确记录验收范围,实际桌面发布仍需后续嵌入资产并重建。 + +## Migration Plan + +更新依赖、渲染器与测试,完成 Windows 本机 Web 验证。现有会话数据无需迁移。回退时撤销插件注册和对应依赖即可恢复原有解析行为。 diff --git a/openspec/changes/fix-cjk-markdown-emphasis/proposal.md b/openspec/changes/fix-cjk-markdown-emphasis/proposal.md new file mode 100644 index 000000000..79cc8216e --- /dev/null +++ b/openspec/changes/fix-cjk-markdown-emphasis/proposal.md @@ -0,0 +1,25 @@ +## Why + +中文回复中的 `**建议:**共享` 等写法在现有 CommonMark 标点边界规则下直接显示星号。用户希望 ACECode 正常显示这类中文加粗,同时保留已有 Markdown 写法的解析结果。 + +## What Changes + +- 在 WebUI 共享 Markdown 渲染器接入 `markdown-it-cjk-friendly`,兼容中日韩文字与标点相邻的强调标记。 +- 覆盖完整、行内和流式分块渲染,并验证转义、代码与已有强调边界不受破坏。 +- 更新依赖锁文件及 Web 文档,记录 Windows 本机验证结果。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `webui-rich-rendering`:补充中日韩文字中的强调兼容规则和共享渲染入口一致性。 + +## Impact + +- 涉及 `web/src/lib/markdown.js`、相关测试、`web/package.json`、`web/pnpm-lock.yaml` 和 `web/README.md`。 +- 新增 `markdown-it-cjk-friendly` 依赖;沿用现有 `markdown-it`。 +- 作用于使用该共享渲染器的 Web/Desktop 内容,不修改会话原文、协议、TUI 解析器或独立的可视化编辑器。 diff --git a/openspec/changes/fix-cjk-markdown-emphasis/specs/webui-rich-rendering/spec.md b/openspec/changes/fix-cjk-markdown-emphasis/specs/webui-rich-rendering/spec.md new file mode 100644 index 000000000..358e92b64 --- /dev/null +++ b/openspec/changes/fix-cjk-markdown-emphasis/specs/webui-rich-rendering/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: 中日韩文字相邻的强调兼容 + +WebUI 的共享 Markdown 展示 SHALL 识别中日韩文字及标点相邻的星号加粗、星号斜体和删除线,无需用户在这些标记外额外插入空格。完整内容、行内内容与流式分块展示 MUST 使用一致的强调规则,保留已有可正确解析的标点边界及下划线强调。代码、显式转义的星号和原始会话内容 MUST 保持原有语义,HTML 转义与链接限制 MUST 继续生效。 + +#### Scenario: 全角冒号后紧接中文 +- **WHEN** 内容为 `**建议:**共享` 或列表项 `- **同会话消息排序:**异步处理` +- **THEN** 标签连同全角冒号显示为加粗,后续文字正常显示,强调星号不显示 + +#### Scenario: 保留已有混合标点写法 +- **WHEN** 内容为 `提示:**(可选)**`、`结果:**"通过"**` 或 `中文,__重点__。` +- **THEN** 原有加粗范围保持正确,不因中文兼容规则而退化为星号或下划线 + +#### Scenario: 中日韩强调与删除线 +- **WHEN** 内容为 `*注意:*继续`、`前文**(重点)**后文`、`**注意。**続き`、`**주의。**다음` 或 `~~旧值:~~新值` +- **THEN** 对应范围分别显示为斜体、加粗或删除线 + +#### Scenario: 原文与字面语义保持 +- **WHEN** 相同文字出现在行内代码、围栏代码、显式转义文本或含原始 HTML 的内容中 +- **THEN** 代码和转义星号保持字面显示,原始 HTML 仍转义,持久化与复制的源文本不变 + +#### Scenario: 流式补齐强调标记 +- **WHEN** 流式消息从未闭合强调逐步追加到 `**建议:**共享` +- **THEN** 已闭合强调正确显示,分块 HTML 拼接与完整渲染相同,先前完成段落不变 diff --git a/openspec/changes/fix-cjk-markdown-emphasis/tasks.md b/openspec/changes/fix-cjk-markdown-emphasis/tasks.md new file mode 100644 index 000000000..a8607203d --- /dev/null +++ b/openspec/changes/fix-cjk-markdown-emphasis/tasks.md @@ -0,0 +1,18 @@ +## 1. 接入与回归 + +- [x] 1.1 接入固定版本的插件、更新锁文件及 Web 文档,用共享渲染器确认 `**建议:**共享` 正常加粗。 +- [x] 1.2 增加中日韩强调、已有标点边界、字面代码、转义与流式回归用例,纳入测试入口并通过定向测试。 + +## 2. 综合验证 + +- [x] 2.1 完成全量 `pnpm test`、`pnpm build`、浏览器渲染验证、OpenSpec 严格验证及 `git diff --check`,在本文件记录范围与结果。 + +## 验证记录 + +- Windows 本机,2026-10-03;Node 22.22.0、pnpm 10.32.1。 +- 定向执行 `node src/lib/markdownCjkEmphasis.test.js` 与 `node src/lib/markdownBlocks.test.js`:19 项新增回归和 7 项既有分块测试通过。 +- `web/` 中执行 `pnpm test`:退出码 0,输出 2,977 条 `[pass]`。 +- `web/` 中执行 `pnpm build`:通过;产物扫描 4,498 个正则字面量,未发现旧版 WebKit 不兼容的后行断言。 +- Headless Edge 使用真实共享渲染器打包与构建 CSS,渲染用户提供会话的原始片段:11 项检查通过,包括六个列表标签与“建议:”加粗、无残留星号、代码/标题保留、分块一致性及中日韩兼容;截图已目视核对。 +- 仓库根目录执行 `openspec validate fix-cjk-markdown-emphasis --strict` 与 `git diff --check`:通过。 +- 本次是共享 Web 渲染器的隔离浏览器验证;未重建或替换运行中的 Desktop/daemon,也未执行 TUI 或跨端实机验收。