From 31e3dc057e3c0d34ea4c58889e321d117c6187df Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:29:43 +0800 Subject: [PATCH 01/23] =?UTF-8?q?docs:=20=E5=AE=A1=E6=9F=A5=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E8=A1=A5=20R25/R26=20=E4=B8=A4=E8=8A=82=EF=BC=88harne?= =?UTF-8?q?ss=20=E5=80=9F=E9=89=B4=E4=B8=8E=E6=8F=92=E4=BB=B6=E6=B2=BB?= =?UTF-8?q?=E7=90=86=E3=80=81=E6=96=87=E6=A1=88=E7=BA=AA=E5=BE=8B=E4=B8=8E?= =?UTF-8?q?=20MCP=20=E8=90=BD=E5=9C=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...245\345\217\260\350\264\246-2026-08-19.md" | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git "a/reports/\345\256\241\346\237\245\345\217\260\350\264\246-2026-08-19.md" "b/reports/\345\256\241\346\237\245\345\217\260\350\264\246-2026-08-19.md" index 5f6691a8..863832e8 100644 --- "a/reports/\345\256\241\346\237\245\345\217\260\350\264\246-2026-08-19.md" +++ "b/reports/\345\256\241\346\237\245\345\217\260\350\264\246-2026-08-19.md" @@ -650,3 +650,63 @@ - harness 借鉴 W1..W5:B1 golden → B8 沙箱 fail-closed + B5 i18n AST 门 → B6 先劝再断 → B10 两段式压缩 → B7/B12 → B2 无钥回放。 - 桌面:工作台自己的凭据门成功不广播 workhub-logged-in;桌宠离线卡放大搬家(L-06);聚焦盒灵动生长(BX-06);三窗共享连接状态。 - 首版发布:版本号升 0.2.0(`pnpm sync:desktop-version` / `release:desktop`),用 workflow_dispatch 非干跑出草稿 Release;签名/公证需用户 Apple 开发者账号。 + +## 十九、R25 harness 借鉴与插件治理(2026-09-05,全部已合 main) + +### 19.1 施工线 + +| 线 | 内容 | PR | +|---|---|---| +| O | 提示词 golden 门:47 份 expected 钉住所有可纯函数调用的模型可见文本组装点;两套引擎不写逐字节全等而写具名清单 `KNOWN_ENGINE_DELTA`;引擎 `system` 按行落盘;「盲目重生成后仍被拦」有分层证明 | #23 | +| fence | `FENCE_TAG_PATTERN` 补 `task_plan_objective`,改为从 `FENCE_TAG_NAMES` 注册表生成;跨 agent 评审 `fenced()` 接中和 | #23 | +| R | 聚焦盒顶边 + 水平中心锚定,180ms ease-out 生长,真机 35 样本顶边全 133 | #23 | +| P | dsh 插件治理阶段 1:迁移 0072、安装前静态体检(block×3 / warn×2)、按工作区宿主子进程(上限 4 LRU)、五端点仅管理员、桌面设置页插件分区、web 只读清单 | #24 | +| Q | 桌面三窗连接状态单一真相:`workhub-connection-changed` 三态机 + `get_connection_state`;桌宠离线卡不再撑窗(L-06 根治);登录广播 `source` 对称 | #25 | +| S | B8 Seatbelt 三档沙箱 + fail-closed + 完整度上报 + 拒绝话术进系统提示词;SAFETY.md;macOS 默认 run_command 在 Seatbelt 下可用 | #25 | +| T | B6 重复动作 3/5/8 先劝再断,两套引擎线上形状逐字节同,7 份 golden | #25 | +| V | 插件治理真 PG 门 `qa:r1-pg-plugin-smoke` 进 r1-pg-smoke job;插件工具 golden 4 份;两条真 PG 门共用 r1-pg-harness | #25 | +| M | MCP 客户端设计稿(reports/r25-侦察-2026-09-05/M-MCP客户端设计.md),11 个工包切分 | #25 | +| QA | 真机验收 W-Q:七场景截图留证,修桌宠文案截断与令牌代际 churn;三处小修(先订阅后拉快照、attempt≥1、细条显隐重算尺寸) | #25 | + +### 19.2 拍板与事实 + +- 插件工具一律 `external_effect` 会映成 riskCategory「external」,人工保留守卫只要它非空就开升级 409——阶段 1 的插件在真实 worker 里每次调用都转人,插件 smoke 靠假守卫跑通。定为阶段 2 第一优先(见二十)。 +- MCP:`npx`/`pnpm dlx`/`uvx` 类现下现跑启动器阶段 0 一律拦;读写分级(管理员断言 AND 服务器自述,只能降不能抬)与阶段 0 合并交付;客户端不引官方 SDK,自建 stdio JSON-RPC。 +- 真实内联文案存量 5732 处(立项写的 261 只数了一种写法)。 +- 三条七月旧 PR(#7/#4/#2)不完全包含在 main 里,留待用户处置。 + +## 二十、R26 文案纪律、MCP 落地与测试基建(2026-09-05 至 09-06,全部已合 main) + +### 20.1 施工线 + +| 线 | 内容 | PR | +|---|---|---| +| U | B5 文案 locale 独占 AST 门 + 棘轮基线 + 19 份 per-module 词典(桌面 -68%);禁词门扩到全部文案文件 | #26 | +| W | B10 两段式压缩(免费剪枝先于摘要)+ 超大工具结果落盘 `.spill/`,双引擎单实现;`.spill/` 排除出快照与内容哈希、还原时保留 | #26 | +| M0/M1/M1b | MCP 契约与迁移 0073(`trust_level`);`packages/mcp-client` 纯翻译包(命名不变式、分级真值表、引用式密钥限 `WORKHUB_MCP_SECRET_` 前缀、静态体检 8 条);`sanitizeModelFacingText` 共享中和 + 插件结果与错误路径中和封顶 | #26 | +| M2 | stdio 传输层 + 按服务器粒度的连接监督(版本协商、翻页死循环、反向请求拒绝、重连预算、空闲回收、每调用审计) | #27 | +| X | 插件阶段 2:`trust_level` AND 宿主 `readOnlyHint` → 只读档映 `none`(不转人、research/review 可见);每插件熔断;真守卫 smoke | #27 | +| M6 | MCP 工具 golden 5 份(分级按工具生效) | #27 | +| F1 | 桌面文案批 78/78:加载态统一、内部词清除、枚举人话兜底、`error.message` 次级化;术语裁决回写 glossary;禁词门补 15 组中文 + 13 组英文 + 英文字面量 | #27 | +| M4/M3 | 工具装配三来源合流(内置 > 插件 > MCP);治理七端点、八体检码、写后 reload 重读 | #28 | +| M5 | 真 stdio 夹具服务器(三档故意犯错)+ `qa:mcp-smoke` 六步进 CI;修 M2 两处 fail-open 未兑现 | #28 | +| infra | 测试不许整段替换 `process.stdout.write`(node --test 父进程解析子进程 TAP 行,吞行即掉数):`@workhub/tools/test-support` 透传捕获 + `audit:test-conventions`;web 冒烟轮询容忍整页导航中被丢弃的求值 + `WORKHUB_QA_TRACE` | #28 | +| F2 | 网页/共享 UI/服务端页面/交付物模板文案批 96 条(93 改 + 3 部分改);词表 13 组落 glossary §11;AI 融合正式稿只剩标题正文;提示词不再规定英文自述 | #29 | +| M7 | 桌面设置页「MCP 服务器」分区(四态清单、九字段表单、六动作、九条体检码人话、风险词双提示) | #29 | +| cost | 成本页「按任务分账」显示编号 · 标题(仓储批量取、失败回落中性词) | #29 | +| Z | r1 PG 门复跑假红三坑修(并发探针种子、交付物路径写死、崩溃后预留未释放);删死码;命令面板 36 条搬词典 | #29 | + +### 20.2 门禁状态 + +- 文案 locale 独占棘轮:5732 → 2808(只减不加);禁词棘轮 49(提示词与服务层为主)。 +- 新门:`audit:ui-i18n`、`audit:test-conventions` 进 lint 链;`qa:plugin-smoke`、`qa:mcp-smoke`、`test:scripts` 进 workspace job;`qa:r1-pg-plugin-smoke` 进 r1-pg-smoke job。 +- 用户文案铁律(2026-09-05):界面与交付物只服务产品目标,不写实现理由/过程/下一步,禁「我将 / 我们可以 / 本页面用于展示 / 这里会 / 用于说明 / 实现了」;审查报告已交(artifact 4b026c66)。 + +### 20.3 待办 + +- M8:网页只读 MCP 清单、契约补 `last_error_code`、系统诊断分区仅管理员、五个 service 剩余「事项/工单」收口、README MCP 小节。 +- B6 观测事件 `agent_run.reminded` 与两端时间线渲染。 +- 桌面 MCP 分区与插件信任级别入口的真机走查;密码模式下工作台凭据门登录 → 主窗只刷一次待人工确认。 +- 首版发布 0.2.0:`pnpm sync:desktop-version 0.2.0` 合入后 `gh workflow run desktop-release.yml -f dry_run=false -f draft=true -f tag_name=v0.2.0` 出草稿 Release;签名/公证需用户 Apple 开发者账号。 +- 测试脆性专项(reports/r25-侦察-2026-09-05/T-测试脆性分析.md,约 1400 处属性顺序/精确标签串/整句文案断言换成语义断言)。 +- 插件侧:`ctx.systemPrompt.section` 未接线;MCP 阶段 1 Streamable HTTP;B7 技能目录全量、B12 工具卡 presenter、B2 无钥回放。 From e8c0cb5c2c4452de74c3221444541794dbe6dedf Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:32:13 +0800 Subject: [PATCH 02/23] =?UTF-8?q?chore(release):=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=E7=89=88=E6=9C=AC=E5=8F=B7=E5=8D=87=E8=87=B3=200.2.0?= =?UTF-8?q?=EF=BC=88=E4=BA=94=E5=A4=84=E5=8D=95=E4=B8=80=E4=BA=8B=E5=AE=9E?= =?UTF-8?q?=E6=BA=90=E5=90=8C=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- apps/desktop-webview/package.json | 2 +- client-tauri/src-tauri/Cargo.lock | 2 +- client-tauri/src-tauri/Cargo.toml | 2 +- client-tauri/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop-webview/package.json b/apps/desktop-webview/package.json index 57be044d..7bf2341a 100644 --- a/apps/desktop-webview/package.json +++ b/apps/desktop-webview/package.json @@ -1,6 +1,6 @@ { "name": "@workhub/desktop-webview", - "version": "0.1.0", + "version": "0.2.0", "private": true, "license": "PolyForm-Noncommercial-1.0.0", "type": "module", diff --git a/client-tauri/src-tauri/Cargo.lock b/client-tauri/src-tauri/Cargo.lock index e6b0cef0..d86f842c 100644 --- a/client-tauri/src-tauri/Cargo.lock +++ b/client-tauri/src-tauri/Cargo.lock @@ -5483,7 +5483,7 @@ dependencies = [ [[package]] name = "workhub-client-tauri" -version = "0.1.0" +version = "0.2.0" dependencies = [ "futures-util", "objc2-app-kit 0.3.2", diff --git a/client-tauri/src-tauri/Cargo.toml b/client-tauri/src-tauri/Cargo.toml index decbd1a7..23511bd4 100644 --- a/client-tauri/src-tauri/Cargo.toml +++ b/client-tauri/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "workhub-client-tauri" -version = "0.1.0" +version = "0.2.0" edition = "2021" build = "build.rs" diff --git a/client-tauri/src-tauri/tauri.conf.json b/client-tauri/src-tauri/tauri.conf.json index 510d0f66..f0845467 100644 --- a/client-tauri/src-tauri/tauri.conf.json +++ b/client-tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WorkHub", - "version": "0.1.0", + "version": "0.2.0", "identifier": "com.mycyg.workhub", "build": { "beforeDevCommand": "pnpm --filter @workhub/desktop-webview dev -- --port 1420", diff --git a/package.json b/package.json index d75bd139..16e25b0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workhub", - "version": "0.1.0", + "version": "0.2.0", "private": true, "license": "PolyForm-Noncommercial-1.0.0", "type": "module", From b587e80a3118f2f87a5347a6d133d3fc90992f14 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:32:52 +0800 Subject: [PATCH 03/23] =?UTF-8?q?feat(contracts,api):=20MCP=20=E8=BF=9E?= =?UTF-8?q?=E4=B8=8D=E4=B8=8A=E6=97=B6=E7=BB=99=E7=A8=B3=E5=AE=9A=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=A0=81=EF=BC=8C=E7=95=8C=E9=9D=A2=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E8=8B=B1=E6=96=87=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpServerVM / McpServerConnectionVM / McpServerSummaryVM 各加可选 last_error_code (八条会话失败码 + mcp_connect_failed 兜底)。连接监督只在快照里回原因枚举,翻码固定 发生在治理服务的同一张表上,避免两份词表漂移,也避免反向 import 绕成循环。 表里没有存码的列,所以码只在本进程还记得那次失败时才有;重启后诊断文本仍在、码缺席, 界面回落到通用说法而不是编一个原因。 Co-Authored-By: Claude Fable 5.1 --- apps/api/src/openapi.ts | 33 ++++++++- apps/api/src/services/mcp-client.ts | 21 ++++++ apps/api/src/services/mcp-servers.test.ts | 90 +++++++++++++++++++++++ apps/api/src/services/mcp-servers.ts | 31 ++++++-- packages/contracts/src/domain/mcp.ts | 37 +++++++++- packages/contracts/src/mcp.test.ts | 57 ++++++++++++++ 6 files changed, 260 insertions(+), 9 deletions(-) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 43bc0b57..ae82b3d3 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -6372,6 +6372,22 @@ const mcpServerVmJsonSchema = { trust_level: { type: "string", enum: ["read_only", "external_effect"] }, precheck_report: mcpPrecheckReportJsonSchema, last_error: { type: "string", maxLength: 2000 }, + // last_error 的稳定码,两端界面按码出话而不解析上面那串英文诊断。表里没有存码的列, + // 所以只有当前进程还记得那次失败时才有——重启之后诊断还在、码缺席。 + last_error_code: { + type: "string", + enum: [ + "mcp_spawn_failed", + "mcp_handshake_timeout", + "mcp_protocol_version_unsupported", + "mcp_protocol_error", + "mcp_server_error", + "mcp_call_timeout", + "mcp_not_running", + "mcp_exited", + "mcp_connect_failed" + ] + }, tool_count: { type: "integer", minimum: 0 }, tools: { type: "array", items: { type: "string" } }, installed_by: uuidStringSchema, @@ -6389,7 +6405,22 @@ const mcpServerConnectionJsonSchema = { tool_count: { type: "integer", minimum: 0 }, tool_ids: { type: "array", items: { type: "string", maxLength: 64 } }, blocked_reason: { type: "string", maxLength: 2000 }, - last_error: { type: "string", maxLength: 2000 } + last_error: { type: "string", maxLength: 2000 }, + // 有 last_error 就一定有码(拿不到失败原因时是 mcp_connect_failed)。 + last_error_code: { + type: "string", + enum: [ + "mcp_spawn_failed", + "mcp_handshake_timeout", + "mcp_protocol_version_unsupported", + "mcp_protocol_error", + "mcp_server_error", + "mcp_call_timeout", + "mcp_not_running", + "mcp_exited", + "mcp_connect_failed" + ] + } }, additionalProperties: false } as const; diff --git a/apps/api/src/services/mcp-client.ts b/apps/api/src/services/mcp-client.ts index 29a4222f..eabeb08f 100644 --- a/apps/api/src/services/mcp-client.ts +++ b/apps/api/src/services/mcp-client.ts @@ -44,6 +44,7 @@ import { McpSessionError, spawnMcpServerProcess, type McpServerSpawn, + type McpSessionFailureReason, type McpStdioSession } from "@workhub/mcp-client/stdio"; import { errorToolResult, sanitizeModelFacingText, type AnyToolSpec, type ToolResult } from "@workhub/tools"; @@ -146,6 +147,14 @@ export type McpServerStatusSnapshot = { live: boolean; /** 最近一次失败的英文诊断。 */ lastError?: string; + /** + * 最近一次失败的**原因枚举**(会话级失败才有;工具清单本身不合法这类失败没有)。 + * + * 这一层刻意只回原因、不回错误码:`mcp_*` 码与它们的人话住在 `./mcp-servers.ts`(M3 的 + * `describeMcpSessionFailure`),而那个文件 import 本文件。让本文件反过来去取那份映射会绕成一个 + * 循环,复制一份则是第二份会漂移的词表——回原因、由上一层翻码,两边都只有一份事实源。 + */ + lastErrorReason?: McpSessionFailureReason; /** 重连预算耗尽的原因;有值表示在 `reload()` 之前不再重试这一台。 */ blockedReason?: string; /** 上一次成功发现的公开工具名。 */ @@ -276,6 +285,8 @@ type Connection = { descriptors: McpToolDescriptor[]; status: McpServerStatusSnapshot["status"]; lastError?: string; + /** `lastError` 那一次失败的原因枚举;非会话级失败(如工具清单不合法)没有。 */ + lastErrorReason?: McpSessionFailureReason; blockedReason?: string; /** 单调时钟上的最后一次使用时刻。 */ lastUsedAt: number; @@ -438,6 +449,7 @@ export function createMcpClient(options: McpClientOptions = {}): McpClient { const detail = `mcp server '${connection.config.serverName}' exited unexpectedly (code ${info.code ?? "null"}, signal ${info.signal ?? "null"})`; connection.status = "connect_failed"; connection.lastError = detail; + connection.lastErrorReason = "exited"; connection.descriptors = []; recordFailure(connection, detail); logger.warn("mcp_server_exited", { @@ -504,6 +516,7 @@ export function createMcpClient(options: McpClientOptions = {}): McpClient { connection.descriptors = translation.descriptors; connection.status = "connected"; delete connection.lastError; + delete connection.lastErrorReason; logger.info("mcp_server_connected", { server_name: config.serverName, workspace_id: config.workspaceId, @@ -522,6 +535,13 @@ export function createMcpClient(options: McpClientOptions = {}): McpClient { connection.descriptors = []; connection.status = "connect_failed"; connection.lastError = detail; + // 非会话级失败(工具清单坍缩、密钥引用解析不到)没有原因枚举——不硬塞一个, + // 让上一层如实回落到「连不上」那条兜底码。 + if (error instanceof McpSessionError) { + connection.lastErrorReason = error.reason; + } else { + delete connection.lastErrorReason; + } recordFailure(connection, detail); connection.session = undefined; // 握手失败时会话自己已经收过一次尾;这里再关一次是幂等的,防的是「握手成功但列工具失败」 @@ -769,6 +789,7 @@ export function createMcpClient(options: McpClientOptions = {}): McpClient { toolCount: connection.descriptors.length, live: connection.session?.isLive() === true, ...(connection.lastError ? { lastError: connection.lastError } : {}), + ...(connection.lastErrorReason ? { lastErrorReason: connection.lastErrorReason } : {}), ...(connection.blockedReason ? { blockedReason: connection.blockedReason } : {}), toolIds: connection.descriptors.map((descriptor) => descriptor.toolId) }; diff --git a/apps/api/src/services/mcp-servers.test.ts b/apps/api/src/services/mcp-servers.test.ts index f9454edf..952fec8a 100644 --- a/apps/api/src/services/mcp-servers.test.ts +++ b/apps/api/src/services/mcp-servers.test.ts @@ -9,6 +9,7 @@ import type { AuthActor } from "../middleware/auth.js"; import { createMcpServerService, describeMcpSessionFailure, + mcpSessionFailureCode, McpServiceError, resolveMcpCommandOnDisk, serverNameRiskTokens, @@ -249,3 +250,92 @@ test("行 → VM:缺席的可选字段是缺席,不是空串或空数组", ( assert.deepEqual(toMcpServerVm(row({ toolsJson: ["echo"] })).tools, ["echo"]); assert.equal(toMcpServerVm(row({ lastError: "spawn ENOENT" })).last_error, "spawn ENOENT"); }); + +test("M8 会话失败原因 → 稳定码:八条逐条对上,没有原因时是「连不上」", () => { + // 这一层与 describeMcpSessionFailure 共用同一张表——M2 的快照只回原因枚举(它不认识 mcp_* 码, + // 认识了就要反过来 import 治理服务,绕成循环),翻码固定发生在这里。 + assert.equal(mcpSessionFailureCode("spawn_failed"), "mcp_spawn_failed"); + assert.equal(mcpSessionFailureCode("handshake_timeout"), "mcp_handshake_timeout"); + assert.equal(mcpSessionFailureCode("protocol_version_unsupported"), "mcp_protocol_version_unsupported"); + assert.equal(mcpSessionFailureCode("protocol_error"), "mcp_protocol_error"); + assert.equal(mcpSessionFailureCode("server_error"), "mcp_server_error"); + assert.equal(mcpSessionFailureCode("call_timeout"), "mcp_call_timeout"); + assert.equal(mcpSessionFailureCode("not_running"), "mcp_not_running"); + assert.equal(mcpSessionFailureCode("exited"), "mcp_exited"); + assert.equal(mcpSessionFailureCode(undefined), "mcp_connect_failed"); + assert.equal( + mcpSessionFailureCode("handshake_timeout"), + describeMcpSessionFailure(new McpSessionError("handshake_timeout", "raw")).code, + "两个出口必须给同一个码,否则界面会按两套说法出话" + ); +}); + +test("M8 行 → VM 的 last_error_code 只在本进程还记得那次失败时才有", () => { + const failed = row({ status: "connect_failed", lastError: "handshake timed out after 10000ms" }); + const snapshot = { + id: serverId, + serverName: "gh", + status: "connect_failed" as const, + toolCount: 0, + live: false, + lastError: "handshake timed out after 10000ms", + lastErrorReason: "handshake_timeout" as const, + toolIds: [] + }; + assert.equal(toMcpServerVm(failed, snapshot).last_error_code, "mcp_handshake_timeout"); + // 重启 API 之后行还在、连接记录没了:诊断文本仍然如实给,码缺席——界面回落到通用的一句话, + // 而不是编一个它并不知道的原因。 + const restarted = toMcpServerVm(failed); + assert.equal(restarted.last_error, "handshake timed out after 10000ms"); + assert.equal("last_error_code" in restarted, false); + // 非会话级失败(工具清单坍缩这类)没有原因枚举 → 兜底码,而不是硬塞一个不成立的具体原因。 + const { lastErrorReason: _dropped, ...withoutReason } = snapshot; + assert.equal(toMcpServerVm(failed, withoutReason).last_error_code, "mcp_connect_failed"); +}); + +test("M8 清单里每台服务器的连接快照都带码,成功的那台一个字都不多说", async () => { + const { repository, rows } = addOnlyRepository(); + rows.push(row({ status: "connect_failed", lastError: "mcp server 'gh' exited unexpectedly (code 1, signal null)" })); + const instance = createMcpServerService({ + repository, + auditLog: { async createAuditLog(input) { return { id: "audit-1", ...input } as never; } }, + client: { + status: () => [ + { + id: serverId, + serverName: "gh", + status: "connect_failed", + toolCount: 0, + live: false, + lastError: "mcp server 'gh' exited unexpectedly (code 1, signal null)", + lastErrorReason: "exited", + toolIds: [] + } + ], + reload: async () => [] + }, + resolveCommand: async () => ({ found: true, executable: true, resolvedPath: "/usr/local/bin/mcp-server-github" }), + envSource: { PATH: "/usr/bin" }, + now: () => now + }); + const listed = await instance.list({ actor: admin }); + assert.equal(listed.connections[serverId]?.last_error_code, "mcp_exited"); + assert.equal(listed.servers[0]?.last_error_code, "mcp_exited"); + + const healthy = createMcpServerService({ + repository, + auditLog: { async createAuditLog(input) { return { id: "audit-1", ...input } as never; } }, + client: { + status: () => [ + { id: serverId, serverName: "gh", status: "connected", toolCount: 2, live: true, toolIds: ["mcp__gh__search"] } + ], + reload: async () => [] + }, + resolveCommand: async () => ({ found: true, executable: true, resolvedPath: "/usr/local/bin/mcp-server-github" }), + envSource: { PATH: "/usr/bin" }, + now: () => now + }); + const green = await healthy.list({ actor: admin }); + // 没出错就没有码——空串或一个「一切正常」的伪码都会让界面多渲一行本不存在的原因。 + assert.equal("last_error_code" in (green.connections[serverId] ?? {}), false); +}); diff --git a/apps/api/src/services/mcp-servers.ts b/apps/api/src/services/mcp-servers.ts index 3f3bb894..d490d3ec 100644 --- a/apps/api/src/services/mcp-servers.ts +++ b/apps/api/src/services/mcp-servers.ts @@ -37,6 +37,7 @@ import { type McpPrecheckReport, type McpServerActionResult, type McpServerConnectionVM, + type McpServerErrorCode, type McpServerListVM, type McpServerVM, type UpdateMcpServerRequest @@ -105,7 +106,7 @@ const PRECHECK_REFUSALS: Record = { +const SESSION_FAILURES: Record = { spawn_failed: { code: "mcp_spawn_failed", copy: "spawnFailed" }, handshake_timeout: { code: "mcp_handshake_timeout", copy: "handshakeTimeout" }, protocol_version_unsupported: { code: "mcp_protocol_version_unsupported", copy: "protocolVersionUnsupported" }, @@ -116,8 +117,18 @@ const SESSION_FAILURES: Record 0 ? { tool_ids: snapshot.toolIds } : {}), ...(snapshot.blockedReason ? { blocked_reason: snapshot.blockedReason } : {}), - ...(snapshot.lastError ? { last_error: snapshot.lastError } : {}) + // 有诊断文本就一定有码:界面永远拿得到一句按码写的话,诊断串只作为括号里的次级信息。 + ...(snapshot.lastError + ? { last_error: snapshot.lastError, last_error_code: mcpSessionFailureCode(snapshot.lastErrorReason) } + : {}) }; } @@ -240,7 +254,7 @@ function toConnectionVm(snapshot: McpServerStatusSnapshot): McpServerConnectionV * 写回这一行,所以重启 API 之后读到的仍然是上一次真实结论。内存快照只补一件行上没有的事—— * 「此刻还有没有活着的子进程」,那件事在 `connection` 里单独给。 */ -export function toMcpServerVm(row: McpServerRow): McpServerVM { +export function toMcpServerVm(row: McpServerRow, snapshot?: McpServerStatusSnapshot): McpServerVM { const tools = stringArray(row.toolsJson); // fail-closed 输出契约:VM 装配走样 → 500,而不是把半成品甩给客户端(同 pages/* 的既有口径)。 return parseOutputContract( @@ -261,6 +275,11 @@ export function toMcpServerVm(row: McpServerRow): McpServerVM { trust_level: row.trustLevel, precheck_report: row.precheckReport as unknown as McpPrecheckReport, ...(row.lastError ? { last_error: row.lastError } : {}), + // 码来自**本进程**的连接记录,因为 mcp_servers 表没有存码的列。行上有诊断而这个进程没连过 + // 它(重启之后、或从没启用过)时码就缺席——那是如实的「说不出这一次的原因」,不是没出过错。 + ...(row.lastError && snapshot?.lastError + ? { last_error_code: mcpSessionFailureCode(snapshot.lastErrorReason) } + : {}), tool_count: row.toolCount, ...(tools.length > 0 ? { tools } : {}), ...(row.installedBy ? { installed_by: row.installedBy } : {}), @@ -374,7 +393,7 @@ export function createMcpServerService(deps: McpServerServiceDependencies): McpS return parseOutputContract( mcpServerActionResultSchema, { - server: toMcpServerVm(input.row), + server: toMcpServerVm(input.row, input.snapshot), ...(connection ? { connection } : {}), risk_tokens: serverNameRiskTokens(input.row.serverName) }, @@ -422,7 +441,7 @@ export function createMcpServerService(deps: McpServerServiceDependencies): McpS return parseOutputContract( mcpServerListVmSchema, { - servers: rows.map(toMcpServerVm), + servers: rows.map((row) => toMcpServerVm(row, snapshotFor(snapshots, row.id))), connections, secret_ref_env_prefix: MCP_SECRET_REF_ENV_PREFIX, available_secret_refs: availableSecretRefs() diff --git a/packages/contracts/src/domain/mcp.ts b/packages/contracts/src/domain/mcp.ts index 9a653885..bedbccd0 100644 --- a/packages/contracts/src/domain/mcp.ts +++ b/packages/contracts/src/domain/mcp.ts @@ -37,6 +37,23 @@ export type McpServerTrustLevel = z.infer; export const mcpPrecheckCheckLevelSchema = z.enum(["pass", "warn", "block"]); export type McpPrecheckCheckLevel = z.infer; +// 连接/会话失败的**稳定错误码**。两端界面按码出话,绝不解析 `last_error` 里的英文诊断串—— +// 那是给人看的现场信息(stderr 尾巴、命令路径、失败次数),照着它切字符串会在下一次改措辞时 +// 把中文界面变成半截英文。前八条一一对应 `@workhub/mcp-client/stdio` 的会话失败原因, +// `mcp_connect_failed` 是「拿不到原因」的兜底(例如工具清单本身不合法这类非会话级失败)。 +export const mcpServerErrorCodeSchema = z.enum([ + "mcp_spawn_failed", + "mcp_handshake_timeout", + "mcp_protocol_version_unsupported", + "mcp_protocol_error", + "mcp_server_error", + "mcp_call_timeout", + "mcp_not_running", + "mcp_exited", + "mcp_connect_failed" +]); +export type McpServerErrorCode = z.infer; + // 体检项 id——服务端与两端 UI 共用的稳定键,文案由展示层按 locale 出(这里只存结构与英文诊断)。 // 不执行任何插件/服务器代码:只做字符串校验、PATH 查找、一次 access()。 export const mcpPrecheckCheckIdSchema = z.enum([ @@ -103,6 +120,14 @@ export const mcpServerVmSchema = z.object({ precheck_report: mcpPrecheckReportSchema, // 握手/调用失败的人话原因(连接失败时非空,成功后清空)。 last_error: z.string().max(2000).optional(), + /** + * `last_error` 对应的稳定码,界面据此出人话。 + * + * **只有本进程还记得那次失败时才有**:行上存的是诊断文本,`mcp_servers` 表没有存码的列, + * 所以重启 API 之后行还在、码没了。缺席不代表没出过错——它代表「这一次的原因这个进程说不出来」, + * 界面此时回落到通用的一句「连不上」加上诊断,与 `last_error` 一直以来的用法一致。 + */ + last_error_code: mcpServerErrorCodeSchema.optional(), // 最近一次发现的工具数。 tool_count: z.number().int().nonnegative(), // 最近一次发现的工具名清单,给设置页预览(最多 6 个 + 「还有 N 个」由展示层截断)。 @@ -125,7 +150,13 @@ export const mcpServerSummaryVmSchema = z.object({ status: mcpServerStatusSchema, trust_level: mcpServerTrustLevelSchema, tool_count: z.number().int().nonnegative(), - precheck_verdict: mcpPrecheckReportSchema.shape.verdict + precheck_verdict: mcpPrecheckReportSchema.shape.verdict, + /** + * 连不上时的稳定码。网页只读行不给 `last_error` 一个位置——那是宿主机的现场诊断(命令路径、 + * stderr 尾巴),和 `command` / `cwd` 属于同一类不该出现在网页上的事实;码本身不含宿主机信息, + * 却足以让这一行说出「握手超时」而不是笼统的「连不上」。 + */ + last_error_code: mcpServerErrorCodeSchema.optional() }); export type McpServerSummaryVM = z.infer; @@ -198,7 +229,9 @@ export const mcpServerConnectionVmSchema = z.object({ tool_ids: z.array(z.string().max(64)).optional(), /** 重连预算耗尽的原因;有值表示在下一次「测试连接」之前不再重试这一台。 */ blocked_reason: z.string().max(2000).optional(), - last_error: z.string().max(2000).optional() + last_error: z.string().max(2000).optional(), + /** `last_error` 对应的稳定码。有 `last_error` 就一定有码(拿不到原因时是 `mcp_connect_failed`)。 */ + last_error_code: mcpServerErrorCodeSchema.optional() }); export type McpServerConnectionVM = z.infer; diff --git a/packages/contracts/src/mcp.test.ts b/packages/contracts/src/mcp.test.ts index 841d38dd..ec7aad34 100644 --- a/packages/contracts/src/mcp.test.ts +++ b/packages/contracts/src/mcp.test.ts @@ -6,6 +6,8 @@ import { mcpPrecheckCheckIdSchema, mcpPrecheckReportSchema, mcpServerActionResultSchema, + mcpServerConnectionVmSchema, + mcpServerErrorCodeSchema, mcpServerListVmSchema, mcpServerStatusSchema, mcpServerSummaryVmSchema, @@ -163,6 +165,61 @@ test("the web-facing summary row never carries command, args, env, secret_refs o ); }); +// —— R26 M8(稳定错误码):连不上的原因有码可依 —— // + +test("M8 the failure code enum covers all eight session reasons plus the no-reason fallback", () => { + // 这九条是两端界面唯一允许 switch 的东西。少一条,桌面端/网页端就只能回去解析英文诊断串; + // 多一条没人认识的,界面会掉进 default 分支说一句笼统的话——所以整份词表在契约层钉死。 + assert.deepEqual(mcpServerErrorCodeSchema.options, [ + "mcp_spawn_failed", + "mcp_handshake_timeout", + "mcp_protocol_version_unsupported", + "mcp_protocol_error", + "mcp_server_error", + "mcp_call_timeout", + "mcp_not_running", + "mcp_exited", + "mcp_connect_failed" + ]); + assert.equal(mcpServerErrorCodeSchema.safeParse("mcp_admin_required").success, false, "治理面的 HTTP 错误码不属于这张表"); +}); + +test("M8 last_error_code is optional on all three read shapes and rejects free text", () => { + // 可选是刻意的:行上存的是诊断文本,表里没有存码的列,重启 API 之后码就没了。 + // 缺席 = 「这个进程说不出这一次的原因」,不是「没出过错」。 + assert.equal(mcpServerVmSchema.parse(serverVm()).last_error_code, undefined); + assert.equal( + mcpServerVmSchema.parse(serverVm({ last_error: "spawn ENOENT", last_error_code: "mcp_spawn_failed" })).last_error_code, + "mcp_spawn_failed" + ); + // 自由文本进不了这个字段——它存在的全部意义就是「界面不必解析自由文本」。 + assert.equal(mcpServerVmSchema.safeParse(serverVm({ last_error_code: "spawn ENOENT" })).success, false); + + const connection = mcpServerConnectionVmSchema.parse({ + live: false, + tool_count: 0, + last_error: "handshake timed out after 10000ms", + last_error_code: "mcp_handshake_timeout" + }); + assert.equal(connection.last_error_code, "mcp_handshake_timeout"); + assert.equal(mcpServerConnectionVmSchema.parse({ live: true, tool_count: 3 }).last_error_code, undefined); + + const summary = mcpServerSummaryVmSchema.parse({ + id: "22222222-2222-4222-8222-222222222222", + server_name: "gh", + transport: "stdio", + enabled: true, + status: "connect_failed", + trust_level: "external_effect", + tool_count: 0, + precheck_verdict: "ok", + last_error_code: "mcp_exited" + }); + assert.equal(summary.last_error_code, "mcp_exited"); + // 码不带宿主机信息,所以网页只读行收得下它;诊断文本仍然结构性没有位置。 + assert.equal("last_error" in summary, false); +}); + // —— R26 M3(治理服务与端点):请求与响应契约 —— // test("M3 add request is strict — an unknown field is a 422, never a silently dropped setting", () => { From 39108baa93f9c9436ed7783319de8c1fc477ff81 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:43:46 +0800 Subject: [PATCH 04/23] =?UTF-8?q?feat(b6):=20=E9=87=8D=E5=A4=8D=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E6=8F=90=E9=86=92=E8=A1=A5=20agent=5Frun.reminded=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=EF=BC=88=E4=B8=A4=E5=A5=97=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E5=90=8C=E7=82=B9=E5=90=8C=E5=BD=A2=E7=8A=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契约新增 agent_run.reminded:payload 只带结构化事实(档位/连续重复步数/重复形态/工具名), 不带任何用户可见句子——文案交给两端按 locale 组装。第三档仍走 agent_run.escalated,两者互斥。 loop.ts 与 loop2/config-builder.ts 在提醒真正注入的同一时点各发一条,equivalence 的事件投影 补上 tier/repeats/shape/tool_ids 逐条比对。运行 VM(live/trace/replay)加 additive optional 的 reminders 数组,供时间线补出这一行。 Co-Authored-By: Claude Fable 5.1 --- packages/agent/src/loop/doom-loop-reminder.ts | 25 +++++++ packages/agent/src/loop/loop.ts | 13 +++- packages/agent/src/loop2/config-builder.ts | 13 +++- packages/agent/src/loop2/equivalence.test.ts | 66 +++++++++++++++++++ packages/contracts/src/contracts.test.ts | 65 ++++++++++++++++++ packages/contracts/src/domain/agent.ts | 45 +++++++++++++ packages/contracts/src/enums.ts | 5 ++ packages/contracts/src/events.ts | 37 +++++++++++ packages/contracts/src/pages.ts | 9 +++ packages/events/src/toCuuState.ts | 3 + 10 files changed, 279 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/loop/doom-loop-reminder.ts b/packages/agent/src/loop/doom-loop-reminder.ts index ac2b347a..191bfcc8 100644 --- a/packages/agent/src/loop/doom-loop-reminder.ts +++ b/packages/agent/src/loop/doom-loop-reminder.ts @@ -185,3 +185,28 @@ export function buildDoomLoopReminder(signal: DoomLoopSignal): string { lines.push(SOURCE_NOTE); return lines.join("\n"); } + +/** + * B6 观测面:把信号压成 `agent_run.reminded` 事件的 data(除 run_id/work_item_id 之外的部分)。 + * 只有事实,没有句子——中英文都由前端按 payload 组装(见 `@workhub/contracts` 的 + * `agentRunReminderFactsSchema` 注释)。tier 3 走升级路径,不该走到这里;真传进来也照常返回, + * 由调用方负责不发(两套引擎都在 tier 3 分支提前 return)。 + */ +export function doomLoopReminderEventFacts(signal: DoomLoopSignal, stepNo: number): { + step_no: number; + tier: DoomLoopTier; + repeats: number; + shape: DoomLoopShape; + tool_id?: string; + tool_ids?: string[]; +} { + const names = mergedToolNames(signal.actions); + return { + step_no: stepNo, + tier: signal.tier, + repeats: signal.repeats, + shape: signal.shape, + ...(names[0] ? { tool_id: names[0] } : {}), + ...(names.length > 1 ? { tool_ids: names } : {}) + }; +} diff --git a/packages/agent/src/loop/loop.ts b/packages/agent/src/loop/loop.ts index b6c4025e..bdec11a9 100644 --- a/packages/agent/src/loop/loop.ts +++ b/packages/agent/src/loop/loop.ts @@ -22,7 +22,7 @@ import { projectWireContext, truncateForContext } from "./context-pruning.js"; -import { buildDoomLoopReminder, DOOM_LOOP_ESCALATION_REASON } from "./doom-loop-reminder.js"; +import { buildDoomLoopReminder, doomLoopReminderEventFacts, DOOM_LOOP_ESCALATION_REASON } from "./doom-loop-reminder.js"; import { nextRetryDecision } from "../providers/retry.js"; import { buildStructuredHandoff } from "./handoff.js"; import type { @@ -1531,6 +1531,17 @@ export class AgentLoop { // 再叠一条提醒既冗余又会打乱压缩收尾;tier 3 已在上面升级,走不到这里。 if (loopSignal) { messages.push({ role: "user", content: buildDoomLoopReminder(loopSignal) }); + // B6 观测面:提醒真的注入了才发事件——「劝过几次、劝的是什么」是前端时间线的唯一来源。 + // previewText 是机器串(同 escalated 的 "doom_loop"):句子由前端按 locale 组装, + // 事件里不放中文文案。tier 3 已在上面升级返回,本行只会看到第一、二档。 + await input.emit?.({ + type: eventTypes.agentRunReminded, + previewText: `repeat_reminder tier=${loopSignal.tier}`, + data: { + run_id: input.runId, + ...doomLoopReminderEventFacts(loopSignal, step.index) + } + }); } continue; } diff --git a/packages/agent/src/loop2/config-builder.ts b/packages/agent/src/loop2/config-builder.ts index 99371fb2..3f11dcd4 100644 --- a/packages/agent/src/loop2/config-builder.ts +++ b/packages/agent/src/loop2/config-builder.ts @@ -57,7 +57,7 @@ import { type ContextProjection, type ToolResultSlot, } from "../loop/context-pruning.js"; -import { buildDoomLoopReminder, DOOM_LOOP_ESCALATION_REASON } from "../loop/doom-loop-reminder.js"; +import { buildDoomLoopReminder, doomLoopReminderEventFacts, DOOM_LOOP_ESCALATION_REASON } from "../loop/doom-loop-reminder.js"; import { buildStructuredHandoff } from "../loop/handoff.js"; import { finalizeL3, @@ -726,6 +726,17 @@ async function runAgentLoop2Body( } if (loopSignal && workhubResults.length > 0) { pendingDoomLoopReminder = buildDoomLoopReminder(loopSignal); + // B6 观测面:与 loop.ts 同一时点、同一形状发 agent_run.reminded。shouldStopAfterTurn 跑在 + // turn_end 之后,而 turn_end 的 sink 刚发完这一步的 agentRunStep(control)——因此这条事件在 + // 两套引擎的事件序列里落在同一个位置(equivalence 的 projectEvents 逐条比对)。 + await input.emit?.({ + type: eventTypes.agentRunReminded, + previewText: `repeat_reminder tier=${loopSignal.tier}`, + data: { + run_id: input.runId, + ...doomLoopReminderEventFacts(loopSignal, step.index), + }, + }); } // Budget escalate (steps / timeout / tokens / cost) — same predicate + timing as loop.ts diff --git a/packages/agent/src/loop2/equivalence.test.ts b/packages/agent/src/loop2/equivalence.test.ts index f7404304..195ef092 100644 --- a/packages/agent/src/loop2/equivalence.test.ts +++ b/packages/agent/src/loop2/equivalence.test.ts @@ -232,6 +232,11 @@ const EVENT_DATA_KEYS = [ "attempt", "retry_reason", "delay_ms", + // B6 观测面: agent_run.reminded facts (tier/repeats/shape/tool ids are deterministic per engine). + "tier", + "repeats", + "shape", + "tool_ids", ] as const; function projectEvents(events: EmittedEvent[]): Record[] { @@ -682,6 +687,67 @@ test("equivalence: B6 repeat-tool reminder — nudge at 3 and 5, escalate at 8, assert.match(detailed ?? "", /echo\(\{"message":"same"\}\)/); }); +test("equivalence: B6 观测面 — 两套引擎在同一步发同一形状的 agent_run.reminded,tier 3 只发 escalated", async () => { + // runBoth 已逐条比过完整事件序列(projectEvents 现在也带上 tier/repeats/shape/tool_ids),这里再把 + // 「谁在第几步、发了什么」摊开断言一遍:一条重复链路只该有两条 reminded(第 3、5 步),第 8 步升级 + // 走 agent_run.escalated,不再补发第三条 reminded。 + const { legacyH, loop2H } = await runBoth(() => ({ + responses: Array.from({ length: 8 }, (_, index) => + toolResponse(`m${index + 1}`, [{ id: `call-${index + 1}`, name: "echo", input: { message: "same" } }]), + ), + toolSpecs: [ECHO_TOOL], + })); + + const reminded = (harness: Harness) => + harness.emittedEvents.filter((event) => event.type === "agent_run.reminded").map((event) => event.data); + const expected = [ + { run_id: "run-eqv", step_no: 3, tier: 1, repeats: 3, shape: "identical", tool_id: "echo" }, + { run_id: "run-eqv", step_no: 5, tier: 2, repeats: 5, shape: "identical", tool_id: "echo" }, + ]; + assert.deepEqual(reminded(legacyH), expected); + assert.deepEqual(reminded(loop2H), expected); + // 单工具重复不带 tool_ids(同一事实不存两份)。 + assert.equal(reminded(loop2H).every((data) => !("tool_ids" in data)), true); + // 第三档不重复发:升级只有 agent_run.escalated 一条。 + assert.equal(legacyH.escalatedEvents, 1); + assert.equal(loop2H.escalatedEvents, 1); + + // reminded 紧跟在该步的 agent_run.step(control) 之后——两套引擎的时间线插入位置相同。 + for (const harness of [legacyH, loop2H]) { + const types = harness.emittedEvents.map((event) => event.type); + for (const index of types.flatMap((type, i) => (type === "agent_run.reminded" ? [i] : []))) { + assert.equal(types[index - 1], "agent_run.step"); + assert.equal(harness.emittedEvents[index - 1]?.data["control"], "continue"); + } + } +}); + +test("equivalence: B6 观测面 — 交替重复(A-B-A-B)两侧都报 shape=alternating 并带上两个工具名", async () => { + // 交替形态需要 4 步才判定,第一档阈值 3 因此被越过 → 首条 reminded 直接是 tier 1、repeats 4。 + const alternating = () => + Array.from({ length: 6 }, (_, index) => + index % 2 === 0 + ? toolResponse(`m${index + 1}`, [{ id: `call-${index + 1}`, name: "echo", input: { message: "a" } }]) + : toolResponse(`m${index + 1}`, [{ id: `call-${index + 1}`, name: "boom", input: {} }]), + ); + const { loop2H, legacyH } = await runBoth(() => ({ + responses: alternating(), + toolSpecs: [ECHO_TOOL, FAIL_TOOL], + execute: (toolId: string) => okToolResult(`ran ${toolId}`), + })); + + const first = loop2H.emittedEvents.find((event) => event.type === "agent_run.reminded")?.data; + assert.equal(first?.["shape"], "alternating"); + assert.equal(first?.["tier"], 1); + assert.equal(first?.["repeats"], 4); + assert.deepEqual(first?.["tool_ids"], ["echo", "boom"]); + assert.equal(first?.["tool_id"], "echo"); + assert.deepEqual( + legacyH.emittedEvents.filter((event) => event.type === "agent_run.reminded").map((event) => event.data), + loop2H.emittedEvents.filter((event) => event.type === "agent_run.reminded").map((event) => event.data), + ); +}); + test("equivalence: B6 nudges never outrun the step budget — steps run out first, both engines", async () => { // The extra steps a nudge buys must not break the budget path: with maxSteps below the // escalation tier the run still ends on "步数预算已耗尽" (budgetHit "steps"), not doom_loop. diff --git a/packages/contracts/src/contracts.test.ts b/packages/contracts/src/contracts.test.ts index cf20304d..822cb92d 100644 --- a/packages/contracts/src/contracts.test.ts +++ b/packages/contracts/src/contracts.test.ts @@ -2,6 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + agentRunRemindedEventSchema, + agentRunReminderFactsSchema, + readAgentRunReminderFacts, allowedWorkItemTransitions, agentArmyDashboardVmSchema, agentRunStatuses, @@ -902,6 +905,7 @@ test("formal event names are the only exported implementation names", () => { const exportedEventTypes = Object.values(eventTypes) as string[]; assert.equal(eventTypes.agentRunStarted, "agent_run.started"); + assert.equal(eventTypes.agentRunReminded, "agent_run.reminded"); assert.equal(eventTypes.sessionQuestion, "session.question"); assert.equal(eventTypes.confidenceScored, "confidence.scored"); assert.equal(eventTypes.escalationOpened, "escalation.opened"); @@ -2200,3 +2204,64 @@ test("findings: meeting insight status enum no longer accepts the dead 'creating // 死值已移除。 assert.equal(meetingInsightVmSchema.safeParse({ ...base, status: "creating_requirement" }).success, false); }); + +// R26 批 B6 观测面:agent_run.reminded 的形状。前两档提醒(tier 1/2)走这条,第三档升级走 +// agent_run.escalated——tier 3 不是本事件的合法取值,schema 必须挡住。 +test("B6: agent_run.reminded 只收 tier 1/2 的结构化事实,topic 必须与 run_id 对上", () => { + const data = { + run_id: "40000000-0000-4000-8000-000000000031", + step_no: 3, + tier: 1 as const, + repeats: 3, + shape: "identical" as const, + tool_id: "run_command" + }; + const event = { + event_id: "40000000-0000-4000-8000-0000000000ff", + type: "agent_run.reminded" as const, + topic: `run:${data.run_id}`, + ts: "2026-09-06T00:00:00.000Z", + preview_text: "repeat_reminder tier=1", + data + }; + assert.equal(agentRunRemindedEventSchema.safeParse(event).success, true); + // 第三档不走这条事件。 + assert.equal(agentRunRemindedEventSchema.safeParse({ ...event, data: { ...data, tier: 3 } }).success, false); + // topic 与 run_id 不一致的事件不放行(同会话类事件的既有口径)。 + assert.equal(agentRunRemindedEventSchema.safeParse({ ...event, topic: "run:other" }).success, false); + // 事件里不许夹带渲染好的句子——文案由两端按 locale 组装。 + assert.equal( + agentRunRemindedEventSchema.safeParse({ ...event, data: { ...data, message: "连续 3 次" } }).success, + false + ); +}); + +test("B6: readAgentRunReminderFacts 剥掉事件专属字段,脏数据一律返回 undefined", () => { + const facts = readAgentRunReminderFacts({ + run_id: "40000000-0000-4000-8000-000000000031", + work_item_id: "50000000-0000-4000-8000-000000000031", + step_no: 5, + tier: 2, + repeats: 5, + shape: "alternating", + tool_id: "echo", + tool_ids: ["echo", "run_command"] + }); + assert.deepEqual(facts, { + step_no: 5, + tier: 2, + repeats: 5, + shape: "alternating", + tool_id: "echo", + tool_ids: ["echo", "run_command"] + }); + // 缺 tier / 形态不认识 / 根本不是对象——渲染层据此整行不渲。 + assert.equal(readAgentRunReminderFacts({ step_no: 3, repeats: 3, shape: "identical" }), undefined); + assert.equal(readAgentRunReminderFacts({ step_no: 3, tier: 1, repeats: 3, shape: "spiral" }), undefined); + assert.equal(readAgentRunReminderFacts(undefined), undefined); + // 单工具重复不带 tool_ids;facts 本身允许缺工具名(重复步没有工具调用)。 + assert.equal( + agentRunReminderFactsSchema.safeParse({ step_no: 3, tier: 1, repeats: 3, shape: "identical" }).success, + true + ); +}); diff --git a/packages/contracts/src/domain/agent.ts b/packages/contracts/src/domain/agent.ts index 4c5d99c9..152b68e0 100644 --- a/packages/contracts/src/domain/agent.ts +++ b/packages/contracts/src/domain/agent.ts @@ -62,6 +62,47 @@ export const agentStepSchema = z.object({ }); export type AgentStep = z.infer; +// R26 批 B6 观测面:一次「重复动作提醒」的结构化事实。同一份形状被两处引用: +// - SSE 事件 agent_run.reminded 的 data(events.ts 的 agentRunRemindedDataSchema 在此之上加 run_id); +// - 运行 VM 的 reminders 行(下方 agentRunReminderVmSchema),让回放/实时时间线能补上这一行。 +// 这里只有事实,没有句子:档位、连续重复步数、重复形态、工具名。中英文句子由两端按 locale 组装。 +// +// tool_ids 只在「一次重复涉及不止一个工具」(交替形态 A-B-A-B 且两边工具不同)时出现;单工具的常见 +// 情形只留 tool_id,同一事实不存两份。两者都可能缺席(重复的那一步没有工具调用),渲染层要能退回 +// 不提工具名的说法。 +export const doomLoopShapes = ["identical", "alternating"] as const; +export const doomLoopShapeSchema = z.enum(doomLoopShapes); +export type DoomLoopShapeKind = z.infer; + +export const agentRunReminderFactsSchema = z.object({ + /** 触发提醒的那一步(1 起)。 */ + step_no: z.number().int().positive(), + /** 1=温和提醒,2=详细提醒。第三档不走这条,升级发 agent_run.escalated。 */ + tier: z.union([z.literal(1), z.literal(2)]), + /** 连续重复的步数:全同形态=同一动作连续步数;交替形态=构成 A-B-A-B 的连续步数。 */ + repeats: z.number().int().positive(), + shape: doomLoopShapeSchema, + /** 参与重复的第一个工具名(原始工具 id,人话化交给前端)。重复步没有工具调用时缺席。 */ + tool_id: z.string().min(1).optional(), + /** 涉及多个工具时的完整列表(去重、按出现顺序);单工具时缺席。 */ + tool_ids: z.array(z.string().min(1)).min(2).optional() +}); +export type AgentRunReminderFacts = z.infer; + +/** 运行 VM 里的一行提醒。形状与事件 data 的公共部分逐字段相同,故渲染层一份代码通吃两个来源。 */ +export const agentRunReminderVmSchema = agentRunReminderFactsSchema.strict(); +export type AgentRunReminderVM = z.infer; + +/** + * 宽容读取:从任意来源(SSE envelope 的 data、VM 里的一行)解析出可渲染的提醒事实,多余字段 + * (run_id/work_item_id 等)自动剥掉。解析不出来返回 undefined——渲染层据此整行不渲, + * 绝不把半截数据编成一句话。两端共用同一份,免得中英文两套 UI 各写一遍容错分支后行为分叉。 + */ +export function readAgentRunReminderFacts(value: unknown): AgentRunReminderFacts | undefined { + const parsed = agentRunReminderFactsSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; +} + export const agentToolCallSchema = z.object({ id: z.string().min(1), tool_id: z.string().min(1), @@ -129,6 +170,10 @@ export const agentRunLiveVmSchema = z.object({ budget_decision: agentRunBudgetDecisionVmSchema, usage: agentRunLiveUsageSchema, trace: z.array(agentStepSchema), + // R26 批 B6 观测面:这次运行里「重复动作被劝过几次、劝的是什么」。additive optional——存量客户端 + // 不认识这个键读旧响应零回归,缺席与空数组同义(时间线不渲提醒行)。每一行对应一条 agent_run.reminded + // 事件,渲染层按 step_no 插进步骤时间线。 + reminders: z.array(agentRunReminderVmSchema).optional(), handoff: structuredHandoffSchema.optional(), stream_href: z.string().min(1), replay_href: z.string().min(1) diff --git a/packages/contracts/src/enums.ts b/packages/contracts/src/enums.ts index 021736e0..47d1fd02 100644 --- a/packages/contracts/src/enums.ts +++ b/packages/contracts/src/enums.ts @@ -184,6 +184,11 @@ export const eventTypes = { agentRunStep: "agent_run.step", stepToolResult: "step.tool_result", agentRunCompacting: "agent_run.compacting", + // R26 批 B6 观测面:重复动作「先劝再断」的前两档(默认连续 3 步、5 步)发这条——运行环境 + // 往对话里追加了一条提醒、让模型自己换做法,运行仍在继续。第三档才升级,走 agent_run.escalated, + // 因此这两类事件互斥、不会为同一次判定各发一条。payload 见 events.ts 的 + // agentRunRemindedDataSchema(档位/连续步数/重复形态/工具名),文案一律由前端按 payload 组装。 + agentRunReminded: "agent_run.reminded", agentRunFailed: "agent_run.failed", agentRunEscalated: "agent_run.escalated", sessionQuestion: "session.question", diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index a2ab98fb..4ea36f39 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { eventTypeSchema } from "./enums.js"; import { idSchema, isoDateTimeSchema } from "./domain/common.js"; +import { agentRunReminderFactsSchema } from "./domain/agent.js"; import { conversationMessageReactionVmSchema, conversationMessageVmSchema @@ -517,3 +518,39 @@ export const conversationTitleUpdatedEventSchema = z } }); export type ConversationTitleUpdatedEvent = z.infer; + +// R26 批 B6 观测面:agent_run.reminded——重复动作「先劝再断」的前两档提醒。运行环境往对话里追加了 +// 一条提醒(让模型自己换做法),运行**没有**中断;第三档才升级,那条走 agent_run.escalated,两者互斥, +// 同一次判定绝不会各发一条。 +// +// payload 只带结构化事实,**不带任何用户可见文案**:档位、连续重复步数、重复形态、参与重复的工具名。 +// 句子由两端各自按 locale 组装(packages/ui 的 agentRunReminderLine / 桌面端 spotlight labels)—— +// 事件里塞中文句子会让英文界面无法本地化,也会把模型可见的提醒正文和界面文案锁死在一起。 +export const agentRunRemindedDataSchema = agentRunReminderFactsSchema + .extend({ + run_id: idSchema, + work_item_id: idSchema.optional() + }) + .strict(); +export type AgentRunRemindedData = z.infer; + +export const agentRunRemindedEventSchema = z + .object({ + event_id: idSchema, + type: z.literal("agent_run.reminded"), + topic: z.string().min(1), + ts: isoDateTimeSchema, + preview_text: z.string().max(200).optional(), + data: agentRunRemindedDataSchema + }) + .strict() + .superRefine((event, ctx) => { + if (event.topic !== `run:${event.data.run_id}`) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["topic"], + message: "agent-run-reminded topic must match data.run_id" + }); + } + }); +export type AgentRunRemindedEvent = z.infer; diff --git a/packages/contracts/src/pages.ts b/packages/contracts/src/pages.ts index 3334f272..0ad77ed0 100644 --- a/packages/contracts/src/pages.ts +++ b/packages/contracts/src/pages.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { + agentRunReminderVmSchema, agentRunSchema, agentStepSchema, snapshotSchema, @@ -1428,6 +1429,10 @@ export type ProposalChangeDiffVM = z.infer; export const replayTraceVmSchema = z.object({ run: agentRunSchema, steps: z.array(agentStepSchema), + // R26 批 B6 观测面:这次运行里「重复动作被劝过几次、劝的是什么」。additive optional——存量客户端 + // 不认识这个键读旧响应零回归,缺席与空数组同义(时间线不渲提醒行)。每一行对应一条 agent_run.reminded + // 事件,渲染层按 step_no 插进步骤时间线。 + reminders: z.array(agentRunReminderVmSchema).optional(), evidence_refs: z.array(evidenceRefSchema), snapshots: z.array(snapshotSchema), audit_logs: z.array(auditLogFactSchema).optional(), @@ -1442,6 +1447,10 @@ export const agentRunTraceVmSchema = z.object({ run: agentRunSchema, steps: z.array(agentStepSchema), current_step: agentStepSchema.optional(), + // R26 批 B6 观测面:这次运行里「重复动作被劝过几次、劝的是什么」。additive optional——存量客户端 + // 不认识这个键读旧响应零回归,缺席与空数组同义(时间线不渲提醒行)。每一行对应一条 agent_run.reminded + // 事件,渲染层按 step_no 插进步骤时间线。 + reminders: z.array(agentRunReminderVmSchema).optional(), budget: z.record(z.string(), z.unknown()), snapshot_refs: z.array(snapshotSchema), handoff: structuredHandoffSchema.optional(), diff --git a/packages/events/src/toCuuState.ts b/packages/events/src/toCuuState.ts index 6059d825..3af703e9 100644 --- a/packages/events/src/toCuuState.ts +++ b/packages/events/src/toCuuState.ts @@ -4,6 +4,9 @@ const eventTypeToCuuState: Partial> = { [eventTypes.agentRunStarted]: "thinking", [eventTypes.agentRunStep]: "thinking", [eventTypes.agentRunCompacting]: "thinking", + // R26 批 B6:前两档提醒只是「换个做法」的自救提示,运行仍在继续——桌宠保持 thinking,不变 worried。 + // worried 是「需要人介入」的信号,那由第三档的 agent_run.escalated 负责。 + [eventTypes.agentRunReminded]: "thinking", [eventTypes.agentRunFailed]: "worried", [eventTypes.agentRunEscalated]: "worried", [eventTypes.confidenceScored]: "thinking", From cc22376f602cec0e21020b2a87a2f8d6e61ecd9b Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:44:07 +0800 Subject: [PATCH 05/23] =?UTF-8?q?feat(api,ui):=20=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=96=B0=E5=A2=9E=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E3=80=8CMCP=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E3=80=8D=E6=B8=85?= =?UTF-8?q?=E5=8D=95=EF=BC=88=E4=BB=85=E7=AE=A1=E7=90=86=E5=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页 VM 加 mcp_servers(McpServerSummaryVM,结构性不含命令/参数/环境变量/密钥引用/ 工作目录/诊断串),路由与插件清单同一道管理员门、取数失败降级为不渲这一区。 清单四种说法各说各的:已停用 / 已连接·N 个工具 / 已连接·它没有提供工具 / 连不上。 连不上那一行按稳定错误码出话,而不是把服务端的英文诊断渲上去;码拿不到就只说「连不上」。 添加、启停与移除的入口指向桌面客户端。 顺带补齐 OpenAPI 里设置页响应缺失的三段管理员字段(此前 additionalProperties:false 之下 permission_policies / plugins 结构性不合法)。 Co-Authored-By: Claude Fable 5.1 --- apps/api/src/openapi.ts | 5 ++ apps/api/src/pages/settings.ts | 6 ++ apps/api/src/routes/mcp-servers.test.ts | 39 ++++++++ apps/api/src/routes/pages.ts | 35 +++++++- packages/contracts/src/pages.ts | 6 ++ .../ui/src/gold-path/route-components-copy.ts | 57 ++++++++++++ .../ui/src/gold-path/route-components.test.ts | 89 ++++++++++++++++++ packages/ui/src/gold-path/route-components.ts | 90 +++++++++++++++++++ 8 files changed, 326 insertions(+), 1 deletion(-) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index ae82b3d3..2a5754d7 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -3537,6 +3537,11 @@ const settingsPageResponseSchema = { properties: { generated_at: dateTimeStringSchema, locale: { type: "string", enum: ["zh-CN", "en-US"] }, + // 下面三段只在请求者是管理员时才出现,非管理员是**结构性缺席**(不是空数组——空数组会被 + // 读成「一条都没有」)。additionalProperties: false 之下漏掉它们等于说这个响应不合法。 + permission_policies: { type: "array", items: { type: "object", additionalProperties: true } }, + plugins: { type: "array", items: { type: "object", additionalProperties: true } }, + mcp_servers: { type: "array", items: { type: "object", additionalProperties: true } }, runtime: { type: "object", additionalProperties: true }, llm_runtime: { type: "object", additionalProperties: true }, budgets: { type: "object", additionalProperties: true }, diff --git a/apps/api/src/pages/settings.ts b/apps/api/src/pages/settings.ts index 40f10b79..554fdd88 100644 --- a/apps/api/src/pages/settings.ts +++ b/apps/api/src/pages/settings.ts @@ -2,6 +2,7 @@ import { settingsPageVmSchema, workHubLocaleStorageKey, workHubLocales, + type McpServerSummaryVM, type PluginSummaryVM, type SettingsPageVM, type WorkHubLocale @@ -28,6 +29,10 @@ type SettingsPageInput = { // 这里刻意收窄成 PluginSummaryVM 而不是 PluginVM:source_path 是这台服务器上的绝对路径, // 网页只读列表不需要它。 plugins?: PluginSummaryVM[]; + // R26 M8:已登记 MCP 服务器的只读摘要(仅管理员——调用方负责这道门,与 plugins 同口径)。 + // 收窄成 McpServerSummaryVM 而不是 McpServerVM:命令、参数、环境变量、密钥引用与工作目录都是 + // 这台服务器上的事实与潜在凭据指针,网页只读清单结构性不该有它们的位置。 + mcpServers?: McpServerSummaryVM[]; generatedAt?: Date; }; @@ -55,6 +60,7 @@ export function buildSettingsPage(input: SettingsPageInput): SettingsPageVM { } : {}), ...(input.plugins ? { plugins: input.plugins } : {}), + ...(input.mcpServers ? { mcp_servers: input.mcpServers } : {}), runtime: { app_env: input.settings.appEnv, runtime_status: input.readiness.ready && brokerConfigured && databaseConfigured ? "ready" : "attention_needed", diff --git a/apps/api/src/routes/mcp-servers.test.ts b/apps/api/src/routes/mcp-servers.test.ts index ea3bc573..43973eea 100644 --- a/apps/api/src/routes/mcp-servers.test.ts +++ b/apps/api/src/routes/mcp-servers.test.ts @@ -21,6 +21,7 @@ import type { McpServerActionResult, McpServerListVM } from "@workhub/contracts" import { COOKIE_NAME, type AuthDependencies, type AuthEnv } from "../middleware/auth.js"; import { createMcpServerService, McpServiceError } from "../services/mcp-servers.js"; import type { McpServerStatusSnapshot } from "../services/mcp-client.js"; +import { buildSettingsPage } from "../pages/settings.js"; import { createMcpServerRoutes } from "./mcp-servers.js"; // R26 M3:MCP 服务器治理端点的端到端行为(真路由 + 真服务 + 内存仓储 + 假连接监督)。 @@ -739,3 +740,41 @@ test("六个写动作各落一条审计,动作名与插件那一系不共用 assert.ok(Array.isArray(entry.detailJson?.["secret_ref_keys"])); } }); + +// —— 网页设置页的只读清单(同一道管理员门;网页不做添加/启停) —— // + +test("M8 设置页 VM 只在调用方真的填了清单时才带 mcp_servers;摘要里没有命令、参数、环境变量、密钥引用、工作目录", () => { + const runtimeSettings = settings(); + const readiness = { ready: true, checks: { database: { ok: true }, broker: { ok: true } } } as const; + const base = buildSettingsPage({ settings: runtimeSettings, readiness, locale: "zh-CN", generatedAt: now }); + // 非管理员:路由不取不填 → 字段结构性缺席(不是空数组,空数组会被读成「一台都没接」)。 + assert.equal(base.mcp_servers, undefined); + + const withServers = buildSettingsPage({ + settings: runtimeSettings, + readiness, + locale: "zh-CN", + generatedAt: now, + mcpServers: [ + { + id: serverId, + server_name: "gh", + transport: "stdio", + enabled: true, + status: "connect_failed", + trust_level: "external_effect", + tool_count: 0, + precheck_verdict: "ok", + last_error_code: "mcp_handshake_timeout" + } + ] + }); + assert.equal(withServers.mcp_servers?.length, 1); + assert.equal(withServers.mcp_servers?.[0]?.last_error_code, "mcp_handshake_timeout"); + const summary = withServers.mcp_servers?.[0] ?? {}; + // 宿主机事实与潜在凭据指针结构性不进网页;诊断串(last_error)同理——它是命令路径与 stderr + // 尾巴,只有**码**能上网页。 + for (const hostFact of ["command", "args", "env", "secret_refs", "cwd", "last_error"]) { + assert.equal(hostFact in summary, false, `网页只读摘要不该带 ${hostFact}`); + } +}); diff --git a/apps/api/src/routes/pages.ts b/apps/api/src/routes/pages.ts index 792b0a23..fd864f4a 100644 --- a/apps/api/src/routes/pages.ts +++ b/apps/api/src/routes/pages.ts @@ -118,6 +118,7 @@ import { } from "../workers/agent-skill-curation.js"; import { getDefaultCostLedgerStore } from "../services/cost-ledger-store.js"; import { getDefaultPluginService, type PluginService } from "../services/plugins.js"; +import { getDefaultMcpServerService, type McpServerService } from "../services/mcp-servers.js"; import { getDefaultBudgetPolicyStore } from "../services/cost-policy-store.js"; export type PageRoutesDependencies = { @@ -126,6 +127,8 @@ export type PageRoutesDependencies = { // R24-P 阶段 1:设置页的只读插件清单(仅管理员)。懒解析——不注入时推迟到真有管理员请求 // 设置页才建默认服务,路由工厂本身不建 DB 连接。 plugins?: Pick; + // R26 M8:设置页的只读 MCP 服务器清单(仅管理员)。懒解析同 plugins——路由工厂本身不建连接。 + mcpServers?: Pick; escalations?: EscalationService; memoryConflicts?: Pick; proposals?: ProposalService; @@ -334,6 +337,8 @@ export function createPageRoutes(deps: PageRoutesDependencies = {}) { const approvals = deps.approvals ?? createApprovalService(); let pluginsService: Pick | undefined = deps.plugins; const plugins = () => (pluginsService ??= getDefaultPluginService()); + let mcpServersService: Pick | undefined = deps.mcpServers; + const mcpServers = () => (mcpServersService ??= getDefaultMcpServerService()); const escalations = deps.escalations ?? createEscalationService(); const memoryConflicts = deps.memoryConflicts ?? createMemoryConflictService(); const proposals = deps.proposals ?? getDefaultProposalService(); @@ -1121,6 +1126,33 @@ export function createPageRoutes(deps: PageRoutesDependencies = {}) { installedPlugins = undefined; } } + // R26 M8:网页设置页的**只读** MCP 服务器清单,同一道管理员门。列表里每一行都从完整 VM 裁剪成 + // summary(命令 / 参数 / 环境变量 / 密钥引用 / 工作目录一律不进网页),连不上的那一行再补上 + // 稳定错误码——它来自本进程的连接快照,行上没有存码的列,所以拿不到时就不给,界面回落到 + // 通用的一句「连不上」。取数失败降级为不渲这一区,与上面两区同款取舍。 + let registeredMcpServers: Parameters[0]["mcpServers"]; + if (c.var.currentUser.isAdmin) { + try { + const listed = await mcpServers().list({ actor: c.var.actor }); + registeredMcpServers = listed.servers.map((server) => { + const errorCode = server.last_error_code ?? listed.connections[server.id]?.last_error_code; + return { + id: server.id, + server_name: server.server_name, + ...(server.display_name ? { display_name: server.display_name } : {}), + transport: server.transport, + enabled: server.enabled, + status: server.status, + trust_level: server.trust_level, + tool_count: server.tool_count, + precheck_verdict: server.precheck_report.verdict, + ...(server.status === "connect_failed" && errorCode ? { last_error_code: errorCode } : {}) + }; + }); + } catch { + registeredMcpServers = undefined; + } + } const runtimeReadiness = await readiness(authSettings); return c.json(pageEnvelope(buildSettingsPage({ settings: authSettings, @@ -1129,7 +1161,8 @@ export function createPageRoutes(deps: PageRoutesDependencies = {}) { preferenceLocale, preferenceSource: "server", ...(permissionPolicies ? { permissionPolicies } : {}), - ...(installedPlugins ? { plugins: installedPlugins } : {}) + ...(installedPlugins ? { plugins: installedPlugins } : {}), + ...(registeredMcpServers ? { mcpServers: registeredMcpServers } : {}) }), locale)); }); diff --git a/packages/contracts/src/pages.ts b/packages/contracts/src/pages.ts index 3334f272..dd15c38c 100644 --- a/packages/contracts/src/pages.ts +++ b/packages/contracts/src/pages.ts @@ -45,6 +45,7 @@ import { import { userMemoryCategorySchema } from "./domain/user-memory.js"; import { skillEditOpSchema, teamSkillStatusSchema, TEAM_SKILL_MAX_EDIT_OPS } from "./domain/team-skill.js"; import { githubActivityVmSchema } from "./domain/github.js"; +import { mcpServerSummaryVmSchema } from "./domain/mcp.js"; import { pluginSummaryVmSchema } from "./domain/plugin.js"; export const actionSpecSchema = z.object({ @@ -1561,6 +1562,11 @@ export const settingsPageVmSchema = z.object({ // 网页端不做安装/启停:安装要给一个本机绝对路径,那是「这台服务器上的目录」,只在桌面端说得通; // 网页在这里只回答「这个部署上装了什么、还活着吗」,动作入口指向桌面客户端。 plugins: z.array(pluginSummaryVmSchema).optional(), + // R26 M8:已登记 MCP 服务器的**只读**清单(仅管理员——服务端只给管理员填,同 plugins)。 + // 网页端不做添加/启停:要填的是这台服务器上的一条启动命令,那是「跑着 API 的那台机器」上的事实, + // 只在桌面端说得通;网页在这里只回答「这个部署接了什么、还连得上吗」,动作入口指向桌面客户端。 + // 行的形状是裁剪过的 summary,结构性不含命令 / 参数 / 环境变量 / 密钥引用 / 工作目录。 + mcp_servers: z.array(mcpServerSummaryVmSchema).optional(), runtime: z.object({ app_env: z.enum(["development", "test", "production"]), runtime_status: z.enum(["ready", "attention_needed"]), diff --git a/packages/ui/src/gold-path/route-components-copy.ts b/packages/ui/src/gold-path/route-components-copy.ts index 9aa5b196..c30539d0 100644 --- a/packages/ui/src/gold-path/route-components-copy.ts +++ b/packages/ui/src/gold-path/route-components-copy.ts @@ -242,6 +242,25 @@ export type RouteCopyKey = | "projectHome.noFiles" | "projectHome.github" | "projectHome.githubEmpty" + | "settings.mcpServers" + | "settings.mcpIntro" + | "settings.mcpEmpty" + | "settings.mcpConnectedToolsOne" + | "settings.mcpConnectedToolsMany" + | "settings.mcpConnectedNoTools" + | "settings.mcpCantConnect" + | "settings.mcpTrustReadOnly" + | "settings.mcpTrustExternalEffect" + | "settings.mcpPrecheckBlocked" + | "settings.mcpPrecheckWarn" + | "settings.mcpErrSpawnFailed" + | "settings.mcpErrHandshakeTimeout" + | "settings.mcpErrProtocolVersion" + | "settings.mcpErrProtocol" + | "settings.mcpErrServerRefused" + | "settings.mcpErrCallTimeout" + | "settings.mcpErrNotRunning" + | "settings.mcpErrExited" | "settings.runtime" | "settings.llm" | "settings.language" @@ -552,6 +571,25 @@ export const routeCopy: Record> = { "skills.curateNowConfirm": "确认开始?再点一次", "skills.curateNowStarted": "已开始自学,跑完刷新这一页就能看到结果。", "skills.curateNowFailed": "没能开始,请稍后再试。", + "settings.mcpServers": "MCP 服务器", + "settings.mcpIntro": "这个部署上接入的 MCP 服务器。添加、启停和移除在桌面客户端的设置里操作。", + "settings.mcpEmpty": "还没有接入 MCP 服务器。", + "settings.mcpConnectedToolsOne": "已连接 · 1 个工具", + "settings.mcpConnectedToolsMany": "已连接 · {count} 个工具", + "settings.mcpConnectedNoTools": "已连接 · 它没有提供工具", + "settings.mcpCantConnect": "连不上", + "settings.mcpTrustReadOnly": "已断言为只读 · 自述只读的工具不再逐次转人", + "settings.mcpTrustExternalEffect": "按最高风险运行 · 每次调用都要人确认", + "settings.mcpPrecheckBlocked": " · 启动前检查拦下了它", + "settings.mcpPrecheckWarn": " · 启动前检查有提醒", + "settings.mcpErrSpawnFailed": "这台服务器没能启动。", + "settings.mcpErrHandshakeTimeout": "服务器没有在规定时间内应答。", + "settings.mcpErrProtocolVersion": "双方的协议版本对不上。", + "settings.mcpErrProtocol": "服务器的应答读不了。", + "settings.mcpErrServerRefused": "服务器拒绝了这次连接。", + "settings.mcpErrCallTimeout": "上一次调用等太久没有结果。", + "settings.mcpErrNotRunning": "这台服务器当前没在运行。", + "settings.mcpErrExited": "这台服务器自己退出了。", "settings.runtime": "服务状态", "settings.llm": "AI 运行配置", "settings.language": "语言", @@ -860,6 +898,25 @@ export const routeCopy: Record> = { "skills.curateNowConfirm": "Start now? Click again", "skills.curateNowStarted": "Started. Refresh this page once it finishes to see what changed.", "skills.curateNowFailed": "Could not start. Try again in a moment.", + "settings.mcpServers": "MCP servers", + "settings.mcpIntro": "MCP servers connected to this deployment. Add, enable, disable and remove them in the desktop app's settings.", + "settings.mcpEmpty": "No MCP servers yet.", + "settings.mcpConnectedToolsOne": "Connected · 1 tool", + "settings.mcpConnectedToolsMany": "Connected · {count} tools", + "settings.mcpConnectedNoTools": "Connected · it offers no tools", + "settings.mcpCantConnect": "Can't connect", + "settings.mcpTrustReadOnly": "Asserted read-only · tools that declare themselves read-only stop going to a person", + "settings.mcpTrustExternalEffect": "Runs at the highest risk tier · every call needs a person", + "settings.mcpPrecheckBlocked": " · the pre-start check refused it", + "settings.mcpPrecheckWarn": " · the pre-start check raised a warning", + "settings.mcpErrSpawnFailed": "The server could not start.", + "settings.mcpErrHandshakeTimeout": "The server did not answer in time.", + "settings.mcpErrProtocolVersion": "The two sides' protocol versions do not match.", + "settings.mcpErrProtocol": "The server's answer could not be read.", + "settings.mcpErrServerRefused": "The server refused the connection.", + "settings.mcpErrCallTimeout": "The last call took too long to come back.", + "settings.mcpErrNotRunning": "The server is not running right now.", + "settings.mcpErrExited": "The server exited on its own.", "settings.runtime": "Service status", "settings.llm": "AI runtime config", "settings.language": "Language", diff --git a/packages/ui/src/gold-path/route-components.test.ts b/packages/ui/src/gold-path/route-components.test.ts index a0385c3c..b4b97aac 100644 --- a/packages/ui/src/gold-path/route-components.test.ts +++ b/packages/ui/src/gold-path/route-components.test.ts @@ -2959,6 +2959,95 @@ test("R4.11 Settings route component uses a typed Settings Page VM without leaki assertNoMainWindowBoundaryLeak(settings.html); }); +test("R26 M8 settings MCP server list is admin-gated and read-only: members never see the section at all", () => { + const base = settingsVm("zh-CN"); + const withoutServers = renderWebRouteComponent({ key: "settings", settings: base, isAdmin: true }, { locale: "zh-CN" }); + // 非管理员:服务端结构性不填 vm.mcp_servers(不是空数组——空数组会被读成「一台都没接」),整区不渲。 + assert.equal(withoutServers.html.includes("data-r26-settings-mcp"), false); + + const empty = renderWebRouteComponent( + { key: "settings", settings: { ...base, mcp_servers: [] }, isAdmin: true }, + { locale: "zh-CN" } + ); + assert.equal(empty.html.includes('data-r26-settings-mcp="0"'), true); + assert.equal(empty.html.includes("还没有接入 MCP 服务器。"), true); + // 空态也要给出去处:网页不做添加,入口在桌面客户端。 + assert.equal(empty.html.includes("添加、启停和移除在桌面客户端的设置里操作。"), true); + + const withServers = renderWebRouteComponent( + { + key: "settings", + isAdmin: true, + settings: { + ...base, + mcp_servers: [ + { + id: "66666666-6666-4666-8666-666666666666", + server_name: "gh", + display_name: "GitHub", + transport: "stdio", + enabled: true, + status: "connected", + trust_level: "read_only", + tool_count: 3, + precheck_verdict: "ok" + }, + { + id: "77777777-7777-4777-8777-777777777777", + server_name: "empty", + transport: "stdio", + enabled: true, + status: "connected", + trust_level: "external_effect", + tool_count: 0, + precheck_verdict: "warn" + }, + { + id: "88888888-8888-4888-8888-888888888888", + server_name: "slow", + transport: "stdio", + enabled: true, + status: "connect_failed", + trust_level: "external_effect", + tool_count: 0, + precheck_verdict: "ok", + last_error_code: "mcp_handshake_timeout" + }, + { + id: "99999999-9999-4999-8999-999999999999", + server_name: "old", + transport: "stdio", + enabled: false, + status: "disabled", + trust_level: "external_effect", + tool_count: 0, + precheck_verdict: "ok" + } + ] + } + }, + { locale: "zh-CN" } + ); + assert.equal(withServers.html.includes('data-r26-settings-mcp="4"'), true); + assert.equal(withServers.html.includes("GitHub(gh)"), true); + // 四种状态各说各的话,不合并成一种。 + assert.equal(withServers.html.includes("已连接 · 3 个工具"), true); + assert.equal(withServers.html.includes("已连接 · 它没有提供工具"), true); + assert.equal(withServers.html.includes("连不上"), true); + assert.equal(withServers.html.includes("已停用"), true); + // 连不上的那一行按**稳定码**出话,而不是把服务端的英文诊断串渲上去。 + assert.equal(withServers.html.includes('data-r26-settings-mcp-error="mcp_handshake_timeout"'), true); + assert.equal(withServers.html.includes("服务器没有在规定时间内应答。"), true); + assert.equal(withServers.html.includes(" · 启动前检查有提醒"), true); + // 信任级别两档各说各的。 + assert.equal(withServers.html.includes('data-r26-settings-mcp-trust="read_only"'), true); + assert.equal(withServers.html.includes("已断言为只读"), true); + assert.equal(withServers.html.includes("按最高风险运行"), true); + // 网页只读:这一区不该出现任何添加/启停/测试连接的动作按钮。 + assert.equal(withServers.html.includes('data-action-id="add_mcp_server"'), false); + assert.equal(withServers.html.includes('data-action-id="disable_mcp_server"'), false); +}); + test("R24-P settings plugins list is admin-gated and read-only: members never see the section at all", () => { const base = settingsVm("zh-CN"); const withoutPlugins = renderWebRouteComponent({ key: "settings", settings: base }, { locale: "zh-CN" }); diff --git a/packages/ui/src/gold-path/route-components.ts b/packages/ui/src/gold-path/route-components.ts index cc66e4f4..85634c90 100644 --- a/packages/ui/src/gold-path/route-components.ts +++ b/packages/ui/src/gold-path/route-components.ts @@ -5163,6 +5163,95 @@ function renderSettingsPluginsSection(vm: SettingsPageVM, locale: WorkHubLocale) `; } +// R26 M8:网页设置页的**只读** MCP(模型上下文协议)服务器清单(仅管理员——服务端只给管理员填 +// vm.mcp_servers,非管理员时字段结构性缺席,整区不渲)。网页刻意不做添加/启停:要填的是一条在 +// 「跑着 API 的那台机器」上执行的启动命令,只在桌面端说得通;这里只回答「接了什么、还连得上吗」, +// 动作入口指向桌面客户端(与插件区同款分工)。 +type SettingsMcpServer = NonNullable[number]; + +/** 四种说法:已停用 / 已连接·N 个工具 / 已连接·没有工具 / 连不上。 */ +function settingsMcpStatusLabel(server: SettingsMcpServer, locale: WorkHubLocale): string { + if (!server.enabled || server.status === "disabled") { + return goldPathCopyT(locale, "disabled"); + } + if (server.status === "connect_failed") { + return routeT(locale, "settings.mcpCantConnect"); + } + if (server.tool_count === 0) { + // 「连上了但一个工具都没有」和「连不上」是两件事,下一步动作也不同,不合并成一种说法。 + return routeT(locale, "settings.mcpConnectedNoTools"); + } + return server.tool_count === 1 + ? routeT(locale, "settings.mcpConnectedToolsOne") + : routeTf(locale, "settings.mcpConnectedToolsMany", { count: server.tool_count }); +} + +/** + * 连不上的原因,按**稳定码**出话。 + * + * 网页这一行结构性拿不到 `last_error`(那是宿主机现场诊断,和命令、工作目录同属不该上网页的事实), + * 所以这里只有码;码缺席就不多说一句——回落到状态行那句「连不上」,而不是编一个并不知道的原因。 + */ +function settingsMcpReasonLine(server: SettingsMcpServer, locale: WorkHubLocale): string { + switch (server.last_error_code) { + case "mcp_spawn_failed": + return routeT(locale, "settings.mcpErrSpawnFailed"); + case "mcp_handshake_timeout": + return routeT(locale, "settings.mcpErrHandshakeTimeout"); + case "mcp_protocol_version_unsupported": + return routeT(locale, "settings.mcpErrProtocolVersion"); + case "mcp_protocol_error": + return routeT(locale, "settings.mcpErrProtocol"); + case "mcp_server_error": + return routeT(locale, "settings.mcpErrServerRefused"); + case "mcp_call_timeout": + return routeT(locale, "settings.mcpErrCallTimeout"); + case "mcp_not_running": + return routeT(locale, "settings.mcpErrNotRunning"); + case "mcp_exited": + return routeT(locale, "settings.mcpErrExited"); + default: + return ""; + } +} + +function settingsMcpPrecheckNote(server: SettingsMcpServer, locale: WorkHubLocale): string { + if (server.precheck_verdict === "blocked") { + return routeT(locale, "settings.mcpPrecheckBlocked"); + } + if (server.precheck_verdict === "warn") { + return routeT(locale, "settings.mcpPrecheckWarn"); + } + return ""; +} + +function renderSettingsMcpSection(vm: SettingsPageVM, locale: WorkHubLocale): string { + const servers = vm.mcp_servers; + if (!servers) { + return ""; + } + const body = servers.length === 0 + ? `

${escapeHtml(routeT(locale, "settings.mcpEmpty"))}

` + : servers + .map((server) => { + const reason = settingsMcpReasonLine(server, locale); + return `
+
+ ${escapeHtml(server.display_name ? `${server.display_name}(${server.server_name})` : server.server_name)} +

${escapeHtml(`${settingsMcpStatusLabel(server, locale)}${settingsMcpPrecheckNote(server, locale)}`)}

+ ${reason ? `

${escapeHtml(reason)}

` : ""} +

${escapeHtml(routeT(locale, server.trust_level === "read_only" ? "settings.mcpTrustReadOnly" : "settings.mcpTrustExternalEffect"))}

+
+
`; + }) + .join(""); + return `
+

${escapeHtml(routeT(locale, "settings.mcpServers"))}

+

${escapeHtml(routeT(locale, "settings.mcpIntro"))}

+
${body}
+
`; +} + function renderSettingsRouteComponent(vm: SettingsPageVM, locale: WorkHubLocale, isAdmin = false): WebRouteComponent { const membersSection = isAdmin ? renderSettingsMembersSection(locale) : ""; const budgetPolicySection = isAdmin ? renderSettingsBudgetPolicySection(locale) : ""; @@ -5225,6 +5314,7 @@ function renderSettingsRouteComponent(vm: SettingsPageVM, locale: WorkHubLocale, : `

${escapeHtml(goldPathCopyT(locale, "noStandingRulesYet"))}

`} ` : ""} ${renderSettingsPluginsSection(vm, locale)} + ${renderSettingsMcpSection(vm, locale)} ${membersSection} ${budgetPolicySection} ${workspaceAuditSection} From ae7f266bf9a24af704e2b3fad5cb6032be72d670 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:44:28 +0800 Subject: [PATCH 06/23] =?UTF-8?q?fix(ui):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E3=80=8C=E7=B3=BB=E7=BB=9F=E8=AF=8A=E6=96=AD=E3=80=8D=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E6=94=B9=E4=B8=BA=E4=BB=85=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=EF=BC=88A2-45=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 并发处理能力、消息通道、数据存储、用哪个模型、密钥配没配——这几行是部署的运行事实, 普通成员既判断不了也调不动,看得见却点不动正是审查里反复出现的假入口。与成员、预算策略、 工作区审计三区同一道门:整段不进 HTML,而不是 hidden 掉。分区抬头随之改成「系统诊断(仅管理员)」。 Co-Authored-By: Claude Fable 5.1 --- packages/ui/src/gold-path/locales.ts | 4 +- .../ui/src/gold-path/route-components.test.ts | 36 +++++++++++-- packages/ui/src/gold-path/route-components.ts | 51 ++++++++++++------- 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/gold-path/locales.ts b/packages/ui/src/gold-path/locales.ts index 822fb71d..89872047 100644 --- a/packages/ui/src/gold-path/locales.ts +++ b/packages/ui/src/gold-path/locales.ts @@ -295,7 +295,7 @@ const zh = { stopsNewDispatchesRunningOnesFinish: "不再开始新的,在跑的会跑完", subtasks: "子任务清单", system: "系统", - systemDiagnosticsForAdminsReadOnly: "系统诊断(管理员关注;普通成员只读参考)", + systemDiagnosticsForAdminsReadOnly: "系统诊断(仅管理员)", taskPlan: "任务计划", taskPlan2: "AI 小组任务计划", taskPlanProposal: "任务计划提议", @@ -655,7 +655,7 @@ const en = { stopsNewDispatchesRunningOnesFinish: "No new ones start; running ones finish", subtasks: "Subtasks", system: "System", - systemDiagnosticsForAdminsReadOnly: "System diagnostics (for admins; read-only reference for members)", + systemDiagnosticsForAdminsReadOnly: "System diagnostics (admins only)", taskPlan: "Task plan", taskPlan2: "Task plan", taskPlanProposal: "Task plan proposal", diff --git a/packages/ui/src/gold-path/route-components.test.ts b/packages/ui/src/gold-path/route-components.test.ts index b4b97aac..6088a697 100644 --- a/packages/ui/src/gold-path/route-components.test.ts +++ b/packages/ui/src/gold-path/route-components.test.ts @@ -2928,9 +2928,17 @@ test("R19-6 Cost route component caps by-workitem/by-team rows and trend days wi test("R4.11 Settings route component uses a typed Settings Page VM without leaking secrets or pet settings", () => { const vm = surfaceVm(); - const settings = renderWebRouteComponents(vm, { locale: "en-US" }).settings; - - assert.ok(settings); + const memberSettings = renderWebRouteComponents(vm, { locale: "en-US" }).settings; + assert.ok(memberSettings); + const settingsPageVm = vm.page_vms.settings; + assert.ok(settingsPageVm); + // A2-45:系统诊断两张卡只在管理员那一侧渲,所以下面这些断言走管理员渲染;成员那一侧的 + // 结构性缺席由紧接着的那条测试单独钉。 + const settings = renderWebRouteComponent( + { key: "settings", settings: settingsPageVm, isAdmin: true }, + { locale: "en-US" } + ); + assert.deepEqual(memberSettings.primaryHrefs, settings.primaryHrefs); assert.equal(settings.html.includes('data-r4-route-component="settings"'), true); assert.equal(settings.html.includes('data-r4-route-component-source="page-vm"'), true); assert.equal(settings.html.includes('data-r4-settings-runtime-status="ready"'), true); @@ -2959,6 +2967,28 @@ test("R4.11 Settings route component uses a typed Settings Page VM without leaki assertNoMainWindowBoundaryLeak(settings.html); }); +test("R26 A2-45 settings system diagnostics render for admins only — members get no section at all", () => { + const base = settingsVm("zh-CN"); + const member = renderWebRouteComponent({ key: "settings", settings: base }, { locale: "zh-CN" }); + // 结构性缺席,不是 hidden:这几行是部署的运行事实,普通成员既判断不了也调不动, + // 「看得见但点不动」正是审查里反复出现的假入口。 + assert.equal(member.html.includes("data-r10-settings-diagnostics"), false); + assert.equal(member.html.includes("data-r4-settings-runtime="), false); + assert.equal(member.html.includes("data-r4-settings-llm"), false); + assert.equal(member.html.includes("deepseek-v4-flash"), false); + // 语言与桌面两张卡是每个人的个人设置,照渲。 + assert.equal(member.html.includes('data-r4-settings-language="true"'), true); + assert.equal(member.html.includes('data-r4-settings-device="true"'), true); + // 外壳上的运行状态标记不在诊断区里,成员那一侧仍然给——它是路由自检用的,不是渲给人看的一行。 + assert.equal(member.html.includes('data-r4-settings-runtime-status="ready"'), true); + + const admin = renderWebRouteComponent({ key: "settings", settings: base, isAdmin: true }, { locale: "zh-CN" }); + assert.equal(admin.html.includes('data-r10-settings-diagnostics="true"'), true); + assert.equal(admin.html.includes('data-r4-settings-runtime="true"'), true); + assert.equal(admin.html.includes('data-r4-settings-llm="true"'), true); + assert.equal(admin.html.includes("系统诊断(仅管理员)"), true); +}); + test("R26 M8 settings MCP server list is admin-gated and read-only: members never see the section at all", () => { const base = settingsVm("zh-CN"); const withoutServers = renderWebRouteComponent({ key: "settings", settings: base, isAdmin: true }, { locale: "zh-CN" }); diff --git a/packages/ui/src/gold-path/route-components.ts b/packages/ui/src/gold-path/route-components.ts index 85634c90..c71ed5f4 100644 --- a/packages/ui/src/gold-path/route-components.ts +++ b/packages/ui/src/gold-path/route-components.ts @@ -58,6 +58,7 @@ import { createReplayReactRouteComponent, createSettingsReactRouteComponent, reactRouteComponentMarkerAttrs, + type SettingsRouteComponentProps, type WebReactRouteComponentAdapter } from "./route-react-components.js"; import { @@ -5252,6 +5253,35 @@ function renderSettingsMcpSection(vm: SettingsPageVM, locale: WorkHubLocale): st `; } +/** + * R26 A2-45:「系统诊断」两张卡(服务状态 / AI 运行配置)**仅管理员渲染**。 + * + * 这里的每一行都是这台部署的运行事实——并发处理能力、消息通道、数据存储、用哪个模型、密钥配没配。 + * 普通成员既无从判断也无处调整,看见的只有焦虑;而「看得见但点不动」正是审查里反复出现的假入口。 + * 与成员/预算策略/工作区审计三区同一道门:不是把它 hidden 掉,而是整段**不进 HTML**。 + */ +function renderSettingsDiagnosticsSection(props: SettingsRouteComponentProps, locale: WorkHubLocale): string { + return `

${escapeHtml(goldPathCopyT(locale, "systemDiagnosticsForAdminsReadOnly"))}

+
+
+

${escapeHtml(routeT(locale, "settings.runtime"))}

+
${escapeHtml(routeT(locale, "settings.runtimeStatus"))}${escapeHtml(runtimeStatusLabel(props.runtimeStatus, locale))}
+
${escapeHtml(routeT(locale, "settings.worker"))}${escapeHtml(String(props.workerCount))}
+
${escapeHtml(routeT(locale, "settings.broker"))}${escapeHtml(boolLabel(props.brokerConfigured, locale))}
+
${escapeHtml(routeT(locale, "settings.database"))}${escapeHtml(boolLabel(props.databaseConfigured, locale))}
+
+
+

${escapeHtml(routeT(locale, "settings.llm"))}

+
${escapeHtml(routeT(locale, "settings.provider"))}${escapeHtml(props.defaultProvider)}
+
${escapeHtml(routeT(locale, "settings.model"))}

${escapeHtml(props.defaultModel)}

${escapeHtml(String(props.providerCount))}
+
${escapeHtml(routeT(locale, "settings.apiKey"))}${escapeHtml(boolLabel(props.apiKeyConfigured, locale))}
+ ${!props.apiKeyConfigured ? `

${escapeHtml(goldPathCopyT(locale, "aiEngineKeyNotSetAi"))}

` : ""} +
${escapeHtml(routeT(locale, "settings.baseUrl"))}${escapeHtml(boolLabel(props.baseUrlConfigured, locale))}
+

${escapeHtml(goldPathCopyT(locale, "keysLiveOnlyInServerEnv"))}

+
+
`; +} + function renderSettingsRouteComponent(vm: SettingsPageVM, locale: WorkHubLocale, isAdmin = false): WebRouteComponent { const membersSection = isAdmin ? renderSettingsMembersSection(locale) : ""; const budgetPolicySection = isAdmin ? renderSettingsBudgetPolicySection(locale) : ""; @@ -5259,6 +5289,7 @@ function renderSettingsRouteComponent(vm: SettingsPageVM, locale: WorkHubLocale, const reactComponent = createSettingsReactRouteComponent(vm, locale); const reactAttrs = dataAttrs(reactRouteComponentMarkerAttrs(reactComponent)); const props = reactComponent.props; + const diagnosticsSection = isAdmin ? renderSettingsDiagnosticsSection(props, locale) : ""; return createWebRouteComponent({ key: "settings", css: webRouteComponentCss, @@ -5318,25 +5349,7 @@ function renderSettingsRouteComponent(vm: SettingsPageVM, locale: WorkHubLocale, ${membersSection} ${budgetPolicySection} ${workspaceAuditSection} -

${escapeHtml(goldPathCopyT(locale, "systemDiagnosticsForAdminsReadOnly"))}

-
-
-

${escapeHtml(routeT(locale, "settings.runtime"))}

-
${escapeHtml(routeT(locale, "settings.runtimeStatus"))}${escapeHtml(runtimeStatusLabel(props.runtimeStatus, locale))}
-
${escapeHtml(routeT(locale, "settings.worker"))}${escapeHtml(String(props.workerCount))}
-
${escapeHtml(routeT(locale, "settings.broker"))}${escapeHtml(boolLabel(props.brokerConfigured, locale))}
-
${escapeHtml(routeT(locale, "settings.database"))}${escapeHtml(boolLabel(props.databaseConfigured, locale))}
-
-
-

${escapeHtml(routeT(locale, "settings.llm"))}

-
${escapeHtml(routeT(locale, "settings.provider"))}${escapeHtml(props.defaultProvider)}
-
${escapeHtml(routeT(locale, "settings.model"))}

${escapeHtml(props.defaultModel)}

${escapeHtml(String(props.providerCount))}
-
${escapeHtml(routeT(locale, "settings.apiKey"))}${escapeHtml(boolLabel(props.apiKeyConfigured, locale))}
- ${!props.apiKeyConfigured ? `

${escapeHtml(goldPathCopyT(locale, "aiEngineKeyNotSetAi"))}

` : ""} -
${escapeHtml(routeT(locale, "settings.baseUrl"))}${escapeHtml(boolLabel(props.baseUrlConfigured, locale))}
-

${escapeHtml(goldPathCopyT(locale, "keysLiveOnlyInServerEnv"))}

-
-
+ ${diagnosticsSection} ` }); From 7dc8c1a1aaf811f0d48725393bea4c2b0ba0aed1 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:50:57 +0800 Subject: [PATCH 07/23] =?UTF-8?q?fix(api):=20=E4=BA=94=E4=B8=AA=20service?= =?UTF-8?q?=20=E9=87=8C=E5=89=A9=E4=BD=99=E7=9A=84=E3=80=8C=E4=BA=8B?= =?UTF-8?q?=E9=A1=B9=20/=20=E5=B7=A5=E5=8D=95=E3=80=8D=E6=8C=89=E8=AF=8D?= =?UTF-8?q?=E8=A1=A8=E6=94=B6=E5=8F=A3=E6=88=90=E3=80=8C=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit glossary §11 里同一个实体只有一个名字。会议/网盘草稿的两条 409、任务评论的 404 与 403、 风险摘要的停滞段与摘要句、无标题回落词、聊天里建任务的两条透明日志,全部搬进 services/locales.ts 并改说「任务」;ui-i18n 棘轮基线净减 13 条(work-item-comments.ts 整份清空)。 「及其余 N 项」在停滞段与临期段是同一句话,改成共用一条词典条目。建任务的透明日志此前会在 标题缺席时渲出 undefined,改成回落到刚提交的那个标题。 模型可见的 tool_result 文本(提示词那一侧仍说「工单」)未动——那要与 packages/agent 的 提示词和 golden 基线一起改,不在本工位范围。 Co-Authored-By: Claude Fable 5.1 --- apps/api/src/services/conversation-turns.ts | 7 +++++-- apps/api/src/services/drive-pages.ts | 5 +++-- apps/api/src/services/locales.ts | 21 +++++++++++++++++++++ apps/api/src/services/meeting-pages.ts | 5 +++-- apps/api/src/services/risk-monitor.test.ts | 10 +++++----- apps/api/src/services/risk-monitor.ts | 13 +++++++++---- apps/api/src/services/work-item-comments.ts | 5 +++-- scripts/dev/ui-i18n-baseline.json | 14 +------------- 8 files changed, 50 insertions(+), 30 deletions(-) diff --git a/apps/api/src/services/conversation-turns.ts b/apps/api/src/services/conversation-turns.ts index 1770e768..2d61b6e7 100644 --- a/apps/api/src/services/conversation-turns.ts +++ b/apps/api/src/services/conversation-turns.ts @@ -85,6 +85,7 @@ import { getDefaultStructuredLogger, type StructuredLogger } from "../logging.js import type { AuthActor } from "../middleware/auth.js"; import { InternalContractError, parseOutputContract } from "../pages/output-contract.js"; import { notifyConversationMessage } from "./conversation-message-notify.js"; +import { serviceT, serviceTf } from "./locales.js"; import { MAX_TURN_CONVERSATION_REFS, TURN_CONVERSATION_REF_MESSAGE_LIMIT, @@ -616,14 +617,16 @@ async function executeCreateWorkItemTool( }); return { content: `已经建好工单了:《${detail.workitem.title}》。`, - auditSummary: `建工单:${detail.workitem.title}`, + // 标题在 VM 上是可选的;透明日志这一行是用户看得见的,拿不到就回落到刚提交的那个标题, + // 不把 "undefined" 渲进聊天里。 + auditSummary: serviceTf("zh-CN", "turnWorkItemCreated", { title: detail.workitem.title ?? input.title }), isError: false }; } catch (error) { if (error instanceof WorkItemServiceError) { return { content: `没能建成工单:${error.message}——如实告诉对方,不要假装已经建好。`, - auditSummary: "建工单失败", + auditSummary: serviceT("zh-CN", "turnWorkItemCreateFailed"), isError: true }; } diff --git a/apps/api/src/services/drive-pages.ts b/apps/api/src/services/drive-pages.ts index c30b8156..e406750c 100644 --- a/apps/api/src/services/drive-pages.ts +++ b/apps/api/src/services/drive-pages.ts @@ -39,6 +39,7 @@ import { ASSIGNMENT_ROLES, canManageProjectDrive, canViewProjectDrive, canViewWo import type { AuthActor } from "../middleware/auth.js"; import { parseOutputContract } from "../pages/output-contract.js"; import { acceptedDeliverableToVm } from "./accepted-deliverables.js"; +import { serviceT } from "./locales.js"; import { getDefaultProposalService, ProposalServiceError, @@ -920,7 +921,7 @@ export function createDrivePageService(deps: DrivePageServiceDependencies): Driv }) { const source = input.page.source_context; if (!source || source.source_type !== "drive_comment") { - throw new DrivePageServiceError(409, "这个事项不是从网盘评论生成的草稿。", "drive_draft_source_missing"); + throw new DrivePageServiceError(409, serviceT("zh-CN", "driveDraftNotFromComment"), "drive_draft_source_missing"); } const workItem = input.page.workitem; const titleBase = compactText(workItem.title ?? workItem.summary_md ?? source.body, 80) ?? "Drive comment proposal"; @@ -1221,7 +1222,7 @@ export function createDrivePageService(deps: DrivePageServiceDependencies): Driv }); const source = initialPage.source_context; if (!source || source.source_type !== "drive_comment") { - throw new DrivePageServiceError(409, "这个事项不是从网盘评论生成的草稿。", "drive_draft_source_missing"); + throw new DrivePageServiceError(409, serviceT("zh-CN", "driveDraftNotFromComment"), "drive_draft_source_missing"); } await ensureCanManage({ actor: input.actor, diff --git a/apps/api/src/services/locales.ts b/apps/api/src/services/locales.ts index d54cf964..38244f98 100644 --- a/apps/api/src/services/locales.ts +++ b/apps/api/src/services/locales.ts @@ -112,6 +112,17 @@ const zh = { fullAutonomyAdopted: "在「全托管」下,AI 复核通过后按你的授权直接采纳。", budgetUncheckable: "预算暂时算不出来,这次没有启动,请稍后再试。", budgetFullyReserved: "正在跑的任务已经占满了预算,这次没能开始。等其中一个跑完,或者先调高预算。", + // R26 M8(F2 遗留 3):五个 service 里剩余的「事项 / 工单」按 glossary §11 统一成「任务」。 + meetingDraftNotFromInsight: "这个任务不是从会议洞察生成的草稿。", + driveDraftNotFromComment: "这个任务不是从网盘评论生成的草稿。", + taskCommentsNotFound: "没有找到这个任务。(Work item not found.)", + taskCommentsForbidden: "你没有权限查看这个任务的评论。", + riskStalledParagraph: "任务停滞({count} 项):{items}", + riskStalledRemainder: ";及其余 {count} 项", + riskStalledSummary: "{count} 项任务停滞", + taskUntitled: "(未命名任务)", + turnWorkItemCreated: "建任务:{title}", + turnWorkItemCreateFailed: "建任务失败", runRecoveredRequeued: "这次执行已恢复,正在重新排队。" } as const; @@ -208,6 +219,16 @@ const en = { fullAutonomyAdopted: "Under full autonomy, AI adopts its own work after its review, as you authorized.", budgetUncheckable: "Budget couldn't be checked just now, so this didn't start. Try again shortly.", budgetFullyReserved: "Running tasks are already using the full budget, so this one didn't start. Wait for one to finish, or raise the budget.", + meetingDraftNotFromInsight: "This task did not come from a meeting insight draft.", + driveDraftNotFromComment: "This task did not come from a drive comment draft.", + taskCommentsNotFound: "That task wasn't found.", + taskCommentsForbidden: "You don't have permission to read this task's comments.", + riskStalledParagraph: "Stalled tasks ({count}): {items}", + riskStalledRemainder: "; and {count} more", + riskStalledSummary: "{count} stalled tasks", + taskUntitled: "(untitled task)", + turnWorkItemCreated: "Task created: {title}", + turnWorkItemCreateFailed: "Couldn't create the task", runRecoveredRequeued: "This run recovered and is back in the queue." } as const satisfies Record; diff --git a/apps/api/src/services/meeting-pages.ts b/apps/api/src/services/meeting-pages.ts index bd40e748..d3854c6f 100644 --- a/apps/api/src/services/meeting-pages.ts +++ b/apps/api/src/services/meeting-pages.ts @@ -24,6 +24,7 @@ import { canManageProjectMeeting, canViewProjectMeetings, canViewWorkItemRecord import type { AuthActor } from "../middleware/auth.js"; import { parseOutputContract } from "../pages/output-contract.js"; +import { serviceT } from "./locales.js"; import { getDefaultMeetingAnalysisService, type MeetingAnalysisService @@ -510,7 +511,7 @@ export function createMeetingPageService(deps: MeetingPageServiceDependencies): }) { const source = input.page.source_context; if (!source || source.source_type !== "meeting_insight") { - throw new MeetingPageServiceError(409, "这个事项不是从会议洞察生成的草稿。", "meeting_draft_source_missing"); + throw new MeetingPageServiceError(409, serviceT("zh-CN", "meetingDraftNotFromInsight"), "meeting_draft_source_missing"); } const workItem = input.page.workitem; const proposalId = stableUuid(`meeting-draft-proposal:${workItem.id}:${source.insight_id}`); @@ -783,7 +784,7 @@ export function createMeetingPageService(deps: MeetingPageServiceDependencies): }); const source = initialPage.source_context; if (!source || source.source_type !== "meeting_insight") { - throw new MeetingPageServiceError(409, "这个事项不是从会议洞察生成的草稿。", "meeting_draft_source_missing"); + throw new MeetingPageServiceError(409, serviceT("zh-CN", "meetingDraftNotFromInsight"), "meeting_draft_source_missing"); } await ensureCanReadInsight({ actor: input.actor, diff --git a/apps/api/src/services/risk-monitor.test.ts b/apps/api/src/services/risk-monitor.test.ts index 30bb5cf7..a81913b0 100644 --- a/apps/api/src/services/risk-monitor.test.ts +++ b/apps/api/src/services/risk-monitor.test.ts @@ -110,8 +110,8 @@ test("buildRiskDigest assembles a single-signal digest with a PM-toned template const digest = buildRiskDigest({ projectId, projectName: "星尘短剧", signals }); assert.equal(digest.notificationTitle, "星尘短剧 · 今日风险巡检"); - assert.match(digest.notificationBody, /工单停滞(1 项):WI-1(接入支付)已停滞 6 天/u); - assert.equal(digest.chatSummary, "今天巡检发现 1 项风险信号——1 项工单停滞"); + assert.match(digest.notificationBody, /任务停滞(1 项):WI-1(接入支付)已停滞 6 天/u); + assert.equal(digest.chatSummary, "今天巡检发现 1 项风险信号——1 项任务停滞"); assert.deepEqual(digest.chatCounts, { stalled: 1, deadline: 0, costSpike: false, githubStale: false }); }); @@ -124,11 +124,11 @@ test("buildRiskDigest combines the work-item and cost signal kinds into one mult const digest = buildRiskDigest({ projectId, projectName: "星尘短剧", signals }); // 4 = 2 项停滞 + 1 项临期 + 成本放量计 1(信号计数按「项」累计,成本是项目级信号恒记 1)。 - assert.equal(digest.chatSummary, "今天巡检发现 4 项风险信号——2 项工单停滞、1 项临期未动工、成本异常放量"); + assert.equal(digest.chatSummary, "今天巡检发现 4 项风险信号——2 项任务停滞、1 项临期未动工、成本异常放量"); assert.deepEqual(digest.chatCounts, { stalled: 2, deadline: 1, costSpike: true, githubStale: false }); const paragraphs = digest.notificationBody.split("\n"); assert.equal(paragraphs.length, 3); - assert.match(paragraphs[0]!, /工单停滞(2 项)/u); + assert.match(paragraphs[0]!, /任务停滞(2 项)/u); assert.match(paragraphs[1]!, /临期未动工(1 项)/u); assert.match(paragraphs[2]!, /今日 ¥128,是近 7 日日均(¥40)的 3.2 倍/u); }); @@ -136,7 +136,7 @@ test("buildRiskDigest combines the work-item and cost signal kinds into one mult test("buildRiskDigest caps each signal bucket display and appends an honest overflow note", () => { const items = Array.from({ length: 7 }, (_, index) => ({ code: `WI-${index}`, - title: `工单 ${index}`, + title: `任务 ${index}`, daysIdle: 10 - index })); const digest = buildRiskDigest({ diff --git a/apps/api/src/services/risk-monitor.ts b/apps/api/src/services/risk-monitor.ts index 419da267..da77b069 100644 --- a/apps/api/src/services/risk-monitor.ts +++ b/apps/api/src/services/risk-monitor.ts @@ -19,6 +19,7 @@ import { } from "@workhub/db"; import { getDefaultStructuredLogger, type StructuredLogger } from "../logging.js"; +import { serviceT, serviceTf } from "./locales.js"; // R14 批 RISK(风险预警巡检,见 r14-release-readiness/05-risk-design.md §3-§4):薄 worker + 厚 service // 二层结构(照 conversation-reply-judge.ts 范式)——这个文件是厚的那层:signal 判定(三条纯函数, @@ -104,9 +105,12 @@ export function buildRiskDigest(input: { const shown = shownItems.map((item) => `${item.code}(${item.title})已停滞 ${item.daysIdle} 天`); const remainder = stalled.totalCount - shownItems.length; bodyParagraphs.push( - `工单停滞(${stalled.totalCount} 项):${shown.join(";")}${remainder > 0 ? `;及其余 ${remainder} 项` : ""}` + serviceTf("zh-CN", "riskStalledParagraph", { + count: stalled.totalCount, + items: `${shown.join(";")}${remainder > 0 ? serviceTf("zh-CN", "riskStalledRemainder", { count: remainder }) : ""}` + }) ); - summaryParts.push(`${stalled.totalCount} 项工单停滞`); + summaryParts.push(serviceTf("zh-CN", "riskStalledSummary", { count: stalled.totalCount })); totalSignalCount += stalled.totalCount; } @@ -119,7 +123,8 @@ export function buildRiskDigest(input: { ); const remainder = deadline.totalCount - shownItems.length; bodyParagraphs.push( - `临期未动工(${deadline.totalCount} 项):${shown.join(";")}${remainder > 0 ? `;及其余 ${remainder} 项` : ""}` + // 「及其余 N 项」与停滞那一段是同一句话,共用同一条词典条目,不各写一份。 + `临期未动工(${deadline.totalCount} 项):${shown.join(";")}${remainder > 0 ? serviceTf("zh-CN", "riskStalledRemainder", { count: remainder }) : ""}` ); summaryParts.push(`${deadline.totalCount} 项临期未动工`); totalSignalCount += deadline.totalCount; @@ -199,7 +204,7 @@ function computeStalledAndDeadlineSignals( const deadlineItems: Array<{ code: string; title: string; daysUntilDue: number }> = []; for (const item of items) { - const title = item.title ?? "(未命名工单)"; + const title = item.title ?? serviceT("zh-CN", "taskUntitled"); const idleDays = (now.getTime() - item.updatedAt.getTime()) / ONE_DAY_MS; if (idleDays >= settings.stall_days_threshold) { stalledItems.push({ code: item.code, title, daysIdle: Math.floor(idleDays) }); diff --git a/apps/api/src/services/work-item-comments.ts b/apps/api/src/services/work-item-comments.ts index 7b0789fc..8c9da84b 100644 --- a/apps/api/src/services/work-item-comments.ts +++ b/apps/api/src/services/work-item-comments.ts @@ -18,6 +18,7 @@ import { import type { AuthActor } from "../middleware/auth.js"; import { WorkItemServiceError } from "./work-items.js"; +import { serviceT } from "./locales.js"; export type WorkItemCommentService = { list: (input: { workItemId: string; actor: AuthActor }) => Promise; @@ -53,7 +54,7 @@ export function createWorkItemCommentService( async function requireVisibleWorkItem(workItemId: string, actor: AuthActor): Promise { const row = await deps.workItems.findWorkItemAccessRecord(workItemId); if (!row) { - throw new WorkItemServiceError(404, "not_found", "没有找到这个事项。(Work item not found.)"); + throw new WorkItemServiceError(404, "not_found", serviceT("zh-CN", "taskCommentsNotFound")); } const allowed = canViewWorkItemRecord( row, @@ -61,7 +62,7 @@ export function createWorkItemCommentService( actor.workspaceId ? { workspaceId: actor.workspaceId } : undefined ); if (!allowed) { - throw new WorkItemServiceError(403, "forbidden", "你没有权限查看这个事项的评论。"); + throw new WorkItemServiceError(403, "forbidden", serviceT("zh-CN", "taskCommentsForbidden")); } return row; } diff --git a/scripts/dev/ui-i18n-baseline.json b/scripts/dev/ui-i18n-baseline.json index b605d402..6ff233ae 100644 --- a/scripts/dev/ui-i18n-baseline.json +++ b/scripts/dev/ui-i18n-baseline.json @@ -455,8 +455,6 @@ "已经建好工单了:《 》。": 1, "已经把文件\" \"发给对方了。": 1, "已经把澄清问题发给对方了。": 2, - "建工单失败": 1, - "建工单:": 1, "当前模式只观察不回应,先在设置里调高档位。": 1, "成员": 1, "文件卡没能发出去,请稍后再试,不要假装已经发送。": 2, @@ -592,7 +590,6 @@ "评论最长 4000 字。": 1, "该项暂不可还原。": 1, "请基于这条网盘评论完善后续交付物,并在审阅后采纳到项目网盘。": 1, - "这个事项不是从网盘评论生成的草稿。": 2, "这条网盘评论已经被忽略,不能生成变更提议。": 1, "(空评论)": 1 }, @@ -691,7 +688,6 @@ "没有找到这场会议。": 3, "没有找到这条会议洞察。": 6, "生成草稿": 1, - "这个事项不是从会议洞察生成的草稿。": 2, "这个团队这段时间的 AI 用量已经到顶,暂时不能重新生成纪要。": 1, "这条会议洞察已经被忽略,不能生成变更提议。": 1, "重新生成纪要": 1 @@ -887,23 +883,19 @@ "项目里这个文件在你之后被人更新过,需要先对一下底稿。": 2 }, "apps/api/src/services/risk-monitor.ts": { - "(未命名工单)": 1, "· 今日风险巡检": 1, "临期未动工( 项):": 1, "今天巡检发现 项风险信号——": 1, "代码仓库长期没有新提交": 1, "代码仓库: 已经 天没有新提交。": 1, "代码仓库: 绑定后还没有同步到任何提交记录。": 1, - "工单停滞( 项):": 1, "成本异常放量": 1, "项临期未动工": 1, - "项工单停滞": 1, "项目成本:今日 ¥ ,是近 7 日日均(¥ )的 倍。": 1, "项目成本:近期无支出记录,今日新增 ¥ 。": 1, "( )将于 天后到期仍未动工": 1, "( )已停滞 天": 1, - "( )已过期 天仍未动工": 1, - ";及其余 项": 2 + "( )已过期 天仍未动工": 1 }, "apps/api/src/services/schedule-notify-pages.ts": { "会议建议已生成草稿": 1, @@ -1083,10 +1075,6 @@ "这个事项还没有归属工作区,暂时无法指派。": 1, "这个事项还没有归属工作区,暂时无法认领。": 1 }, - "apps/api/src/services/work-item-comments.ts": { - "你没有权限查看这个事项的评论。": 1, - "没有找到这个事项。(Work item not found.)": 1 - }, "apps/api/src/services/work-items.ts": { "AI 材料分析失败,尚未生成真实澄清反问。": 2, "AI 材料分析失败:": 1, From eec35609d586ac4f4ef10e5541c900815af16646 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:51:12 +0800 Subject: [PATCH 08/23] =?UTF-8?q?feat(b6):=20web=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF=E8=A1=A5=E3=80=8C=E6=8D=A2=E4=B8=AA=E5=81=9A=E6=B3=95?= =?UTF-8?q?=E3=80=8D=E6=8F=90=E9=86=92=E8=A1=8C=EF=BC=88=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E9=A1=B5=20+=20=E5=9B=9E=E6=94=BE=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 词典加八条完整句子(档位 × 重复形态 × 有没有工具名),中英各一套;工具 id 经 humanizeAgentToolId 去下划线并按语言加引号,界面上不再可能出现 run_command 这类原名。 实时页与回放页都把提醒行插在它所属那一步之后,对不上步骤的补在末尾(劝过就不许看不见), 脏数据由 readAgentRunReminderFacts 挡掉、整行不渲。事件标签表补 agent_run.reminded。 Co-Authored-By: Claude Fable 5.1 --- packages/contracts/src/domain/agent.ts | 11 ++++ packages/ui/src/agent-run/render.test.ts | 55 +++++++++++++++++ packages/ui/src/agent-run/render.ts | 64 +++++++++++++++++-- packages/ui/src/i18n.test.ts | 59 ++++++++++++++++++ packages/ui/src/i18n.ts | 78 +++++++++++++++++++++++- packages/ui/src/replay/render.test.ts | 44 +++++++++++++ packages/ui/src/replay/render.ts | 31 +++++++++- 7 files changed, 332 insertions(+), 10 deletions(-) diff --git a/packages/contracts/src/domain/agent.ts b/packages/contracts/src/domain/agent.ts index 152b68e0..8fffded9 100644 --- a/packages/contracts/src/domain/agent.ts +++ b/packages/contracts/src/domain/agent.ts @@ -103,6 +103,17 @@ export function readAgentRunReminderFacts(value: unknown): AgentRunReminderFacts return parsed.success ? parsed.data : undefined; } +/** + * 工具 id 的去下划线人话版:界面上绝不裸露 `run_command` / `mcp__gh__list_issues` 这种原名。 + * 规则只有两条——先取最后一段(插件/MCP 工具 id 形如 `mcp__<服务器>__<工具>`,前缀对用户无意义), + * 再把下划线换成空格。没有既有的「工具 id → 人话名」对照表,所以不做任何猜词映射;有了对照表之后 + * 由调用方先查表、查不到再退到这里。两端共用一份,避免中英文界面各写一套后名字对不上。 + */ +export function humanizeAgentToolId(toolId: string): string { + const tail = toolId.split("__").filter(Boolean).at(-1) ?? toolId; + return tail.replace(/_/gu, " ").trim() || toolId; +} + export const agentToolCallSchema = z.object({ id: z.string().min(1), tool_id: z.string().min(1), diff --git a/packages/ui/src/agent-run/render.test.ts b/packages/ui/src/agent-run/render.test.ts index 3e6b962e..b7ec406b 100644 --- a/packages/ui/src/agent-run/render.test.ts +++ b/packages/ui/src/agent-run/render.test.ts @@ -126,3 +126,58 @@ test("findings: budget card token unit is localized, no hardcoded English 'token const renderedEn = renderAgentRunLive(baseRun, "web", { locale: "en-US" }); assert.equal(renderedEn.html.includes("tokens<"), true); }); + +// R26 批 B6 观测面:「重复动作被劝过几次、劝的是什么」要在实时时间线上看得见。 +test("B6: 实时时间线在被劝的那一步之后补一条人话提醒行", () => { + const rendered = renderAgentRunLive( + { + ...baseRun, + trace: [ + { ...baseRun.trace[0]!, step_no: 3, phase: "tool_call" }, + { ...baseRun.trace[0]!, id: "60000000-0000-4000-8000-000000000002", step_no: 4, phase: "tool_call" } + ], + reminders: [{ step_no: 3, tier: 1, repeats: 3, shape: "identical", tool_id: "run_command" }] + }, + "web" + ); + + assert.equal(rendered.html.includes("第一次提醒:Cuu 连续 3 步做了同一件事"), true); + assert.equal(rendered.html.includes("run_command"), false, "原始工具 id 不该渲给用户"); + // 提醒行紧跟第 3 步、排在第 4 步之前。 + const reminderAt = rendered.html.indexOf('data-phase="reminded"'); + const step4At = rendered.html.lastIndexOf('4'); + assert.equal(reminderAt > -1 && step4At > reminderAt, true); +}); + +test("B6: 英文界面渲英文提醒行;脏数据整行不渲,不把半截事实编成句子", () => { + const withJunk = { + ...baseRun, + reminders: [ + { step_no: 1, tier: 2, repeats: 5, shape: "identical", tool_id: "echo" }, + { step_no: 1, repeats: 5, shape: "identical" }, + { step_no: 1, tier: 1, repeats: 3, shape: "spiral" }, + "not-an-object" + ] + }; + + const en = renderAgentRunLive(withJunk, "web", { locale: "en-US" }); + assert.equal(en.html.includes("Second reminder: Cuu repeated the same action for 5 steps"), true); + assert.equal(en.html.match(/data-phase="reminded"/gu)?.length, 1, "只有合法的那一条被渲出来"); + + const zh = renderAgentRunLive(withJunk, "web"); + assert.equal(zh.html.includes("第二次提醒:Cuu 连续 5 步做了同一件事"), true); +}); + +test("B6: 对不上任何步骤的提醒补在时间线末尾,不被悄悄丢掉", () => { + const rendered = renderAgentRunLive( + { ...baseRun, reminders: [{ step_no: 99, tier: 1, repeats: 3, shape: "alternating" }] }, + "web" + ); + + assert.equal(rendered.html.includes("第一次提醒:Cuu 连续 3 步在两个动作之间来回切换"), true); +}); + +test("B6: 没有提醒的运行渲染完全不变(additive optional,存量响应零回归)", () => { + assert.equal(renderAgentRunLive(baseRun, "web").html, renderAgentRunLive({ ...baseRun, reminders: [] }, "web").html); + assert.equal(renderAgentRunLive(baseRun, "web").html.includes('data-phase="reminded"'), false); +}); diff --git a/packages/ui/src/agent-run/render.ts b/packages/ui/src/agent-run/render.ts index f9bd54f9..e5feb52e 100644 --- a/packages/ui/src/agent-run/render.ts +++ b/packages/ui/src/agent-run/render.ts @@ -1,6 +1,8 @@ -import type { AgentRunLiveVM, AgentStep, CuuState } from "@workhub/contracts"; +import { readAgentRunReminderFacts, type AgentRunLiveVM, type AgentStep, type CuuState, type WorkHubLocale } from "@workhub/contracts"; import { + agentRunReminderLine, + agentRunReminderPhaseLabel, agentRunStatusLabel, agentStepPhaseLabel, agentStepPublicSummary, @@ -38,6 +40,8 @@ export const agentRunCss = [ ".wh-card{border:1px solid var(--line);background:var(--paper);border-radius:8px;padding:15px}.wh-row{display:flex;justify-content:space-between;gap:14px;border-top:1px solid var(--line);padding:12px 0}.wh-row:first-child{border-top:0}", ".wh-pill{display:inline-flex;align-items:center;gap:6px;border-radius:999px;background:var(--soft);padding:5px 9px;font-size:12px;color:var(--muted)}.wh-pill-run{background:#eaf6f4;color:var(--teal)}.wh-pill-done{background:#eaf8f0;color:var(--green)}.wh-pill-warn{background:#fff6e8;color:var(--amber)}.wh-pill-danger{background:#fff1ef;color:var(--danger)}", ".wh-trace{display:grid;gap:10px}.wh-step{display:grid;grid-template-columns:26px minmax(0,1fr);gap:10px;align-items:start}.wh-dot{width:24px;height:24px;border-radius:999px;background:#edf1ff;color:var(--blue);display:grid;place-items:center;font-size:12px;font-weight:750}.wh-step[data-phase=tool_result] .wh-dot{background:#eaf8f0;color:var(--green)}.wh-step[data-phase=final] .wh-dot{background:#fff6e8;color:var(--amber)}", + // B6:提醒行不是模型的一步——琥珀点区分于蓝色步骤点,第二档再压深一档提示「下一次就转交人」。 + ".wh-step[data-phase=reminded] .wh-dot{background:#fff6e8;color:var(--amber)}.wh-step[data-phase=reminded][data-reminder-tier=\"2\"] .wh-dot{background:#fdece2;color:#b4622a}", ".wh-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}.wh-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:8px;border:1px solid var(--line);padding:9px 12px;color:var(--ink);text-decoration:none;background:#fff;font-weight:700}.wh-btn-primary{background:var(--blue);border-color:var(--blue);color:#fff}.wh-btn-danger{background:#fff4f3;color:#a94137;border-color:#f3c5c0}", ".wh-desktop .wh-run-frame{max-width:920px;grid-template-columns:minmax(0,1fr) 240px}.wh-desktop .wh-run{background:linear-gradient(135deg,#edf6ff,#f8fbff)}@media (max-width:860px){.wh-run-frame{grid-template-columns:1fr}.wh-run-rail{position:static}.wh-title{font-size:24px}}" ].join(""); @@ -74,17 +78,65 @@ function statusClass(status: AgentRunLiveVM["status"]) { return "wh-pill wh-pill-warn"; } -function renderTrace(steps: AgentStep[], options?: UiRenderOptions) { +function renderTrace(steps: AgentStep[], reminders: ReadonlyArray | undefined, options?: UiRenderOptions) { const locale = uiLocale(options); + const trailing = trailingRunReminderRows(steps, reminders, locale); if (steps.length === 0) { - return `

${escapeHtml(uiT(locale, "agent.emptyTrace"))}

`; + // 一步都还没跑完就先被劝过是不可能的(提醒挂在某一步之后),但提醒真的先到时也不能丢—— + // 有提醒行就渲提醒,没有才落到空态。 + return trailing + ? `
${trailing}
` + : `

${escapeHtml(uiT(locale, "agent.emptyTrace"))}

`; } return `
${steps .map( (step) => - `
${step.step_no}
${escapeHtml(agentStepPhaseLabel(locale, step.phase))}

${escapeHtml(agentStepPublicSummary(locale, step))}

${step.snapshot_id ? `${escapeHtml(uiT(locale, "generic.snapshot"))}` : ""}
` + `
${step.step_no}
${escapeHtml(agentStepPhaseLabel(locale, step.phase))}

${escapeHtml(agentStepPublicSummary(locale, step))}

${step.snapshot_id ? `${escapeHtml(uiT(locale, "generic.snapshot"))}` : ""}
${renderRunReminderRows(reminders, step.step_no, locale)}` ) - .join("")}
`; + .join("")}${trailing}`; +} + +// R26 批 B6 观测面:把「重复动作被劝了几次、劝的是什么」插进步骤时间线。 +// +// 提醒不是模型的一步(它是运行环境往对话里追加的一句话),所以不占 step_no,也不冒充 AgentStep—— +// 单独一行、单独的 data-phase="reminded",排在它所属那一步之后。缺字段/脏数据由 +// readAgentRunReminderFacts 挡掉(返回 undefined 即整行不渲),绝不把半截事实编成一句话。 +export function renderRunReminderRows( + reminders: ReadonlyArray | undefined, + stepNo: number, + locale: WorkHubLocale +) { + if (!reminders?.length) { + return ""; + } + return reminders + .map((entry) => readAgentRunReminderFacts(entry)) + .filter((facts): facts is NonNullable => Boolean(facts) && facts?.step_no === stepNo) + .map( + (facts) => + `
!
${escapeHtml(agentRunReminderPhaseLabel(locale))}

${escapeHtml(agentRunReminderLine(locale, facts))}

` + ) + .join(""); +} + +/** + * 对不上任何已渲染步骤的提醒行(时间线被截断、或提醒先于该步的 trace 行到达)。 + * 直接丢掉等于「劝过但界面上看不见」,正是这批要修的;统一按步序补在时间线末尾。 + */ +function trailingRunReminderRows( + steps: ReadonlyArray, + reminders: ReadonlyArray | undefined, + locale: WorkHubLocale +) { + if (!reminders?.length) { + return ""; + } + const rendered = new Set(steps.map((step) => step.step_no)); + const orphans = reminders + .map((entry) => readAgentRunReminderFacts(entry)) + .filter((facts): facts is NonNullable => Boolean(facts) && !rendered.has(facts?.step_no ?? -1)) + .sort((a, b) => a.step_no - b.step_no); + return orphans.map((facts) => renderRunReminderRows([facts], facts.step_no, locale)).join(""); } function renderHandoff(vm: AgentRunLiveVM, options?: UiRenderOptions) { @@ -122,7 +174,7 @@ export function renderAgentRunLive(
${escapeHtml(uiT(locale, "generic.steps"))}

${escapeHtml(uiCount(locale, vm.trace.length, "条", "step"))}

${escapeHtml(uiT(locale, "agent.liveTitle"))}

-
${renderTrace(vm.trace, { locale })}
+
${renderTrace(vm.trace, vm.reminders, { locale })}

${escapeHtml(uiT(locale, "agent.handoff"))}

${renderHandoff(vm, { locale })}
diff --git a/packages/ui/src/i18n.test.ts b/packages/ui/src/i18n.test.ts index 36087c52..40d17a45 100644 --- a/packages/ui/src/i18n.test.ts +++ b/packages/ui/src/i18n.test.ts @@ -10,6 +10,7 @@ import { } from "@workhub/contracts"; import { + agentRunReminderLine, agentRunStatusLabel, agentStepPhaseLabel, deliverableTargetLabel, @@ -64,3 +65,61 @@ test("未映射的枚举回落到中性词,不把内部字面量渲给用户", ]); uiResetUnmappedLabelValues(); }); + +// R26 批 B6 观测面:重复动作提醒的人话行。事件里只有档位/连续步数/形态/原始工具 id, +// 句子在这里按 locale 组装——中英文各自是完整句子,工具名一律去下划线加引号后出现。 +test("B6: 重复动作提醒渲成完整人话行,工具名去下划线、不裸露原始工具 id", () => { + const tier1 = { step_no: 3, tier: 1 as const, repeats: 3, shape: "identical" as const, tool_id: "run_command" }; + + assert.equal( + agentRunReminderLine("zh-CN", tier1), + "第一次提醒:Cuu 连续 3 步做了同一件事(重复的是「run command」),已让它换个做法再继续。" + ); + assert.equal( + agentRunReminderLine("en-US", tier1), + "First reminder: Cuu repeated the same action for 3 steps (it kept using “run command”). Asked it to try another approach." + ); + // 原始工具 id 不出现在任何一种语言里。 + assert.equal(agentRunReminderLine("zh-CN", tier1).includes("run_command"), false); + assert.equal(agentRunReminderLine("en-US", tier1).includes("run_command"), false); +}); + +test("B6: 第二档提醒说明「再重复就交给人接手」,交替形态列出两个工具名", () => { + const tier2 = { + step_no: 5, + tier: 2 as const, + repeats: 5, + shape: "alternating" as const, + tool_id: "echo", + tool_ids: ["echo", "mcp__gh__list_issues"] + }; + + const zh = agentRunReminderLine("zh-CN", tier2); + assert.equal(zh.startsWith("第二次提醒:Cuu 连续 5 步在两个动作之间来回切换"), true); + // MCP/插件工具 id 的前缀对用户无意义,只留最后一段并去下划线。 + assert.equal(zh.includes("「echo」、「list issues」"), true); + assert.equal(zh.includes("mcp__gh__"), false); + assert.equal(zh.includes("这次执行会自动交给人接手"), true); + + const en = agentRunReminderLine("en-US", tier2); + assert.equal(en.startsWith("Second reminder: Cuu kept switching between two actions for 5 steps"), true); + assert.equal(en.includes("“echo”, “list issues”"), true); + assert.equal(en.includes("handed to a person"), true); +}); + +test("B6: 重复步没有工具调用时退到不提工具名的句子,两种语言都不留悬空括号", () => { + const noTool = { step_no: 3, tier: 1 as const, repeats: 3, shape: "identical" as const }; + + assert.equal(agentRunReminderLine("zh-CN", noTool), "第一次提醒:Cuu 连续 3 步做了同一件事,已让它换个做法再继续。"); + assert.equal( + agentRunReminderLine("en-US", noTool), + "First reminder: Cuu repeated the same action for 3 steps. Asked it to try another approach." + ); + assert.equal(agentRunReminderLine("zh-CN", noTool).includes("("), false); + assert.equal(agentRunReminderLine("en-US", noTool).includes("("), false); +}); + +test("B6: 事件类型在通知/事件标签表里有人话,不裸显 agent_run.reminded", () => { + assert.equal(notificationTypeLabel("agent_run.reminded", true), "AI 换个做法"); + assert.equal(notificationTypeLabel("agent_run.reminded", false), "AI course correction"); +}); diff --git a/packages/ui/src/i18n.ts b/packages/ui/src/i18n.ts index 78834680..f92d3a9b 100644 --- a/packages/ui/src/i18n.ts +++ b/packages/ui/src/i18n.ts @@ -1,5 +1,7 @@ import { + humanizeAgentToolId, normalizeWorkHubLocale, + type AgentRunReminderFacts, type AgentRunStatus, type AgentStepPhase, type DeliverableTargetKind, @@ -220,7 +222,44 @@ const copy = { "agent.handoffDone": { "zh-CN": "已完成", "en-US": "Done" }, "agent.handoffRemaining": { "zh-CN": "还剩", "en-US": "Remaining" }, "agent.handoffNext": { "zh-CN": "下一步", "en-US": "Next" }, - "agent.handoffBlocker": { "zh-CN": "阻塞", "en-US": "Blocked" } + "agent.handoffBlocker": { "zh-CN": "阻塞", "en-US": "Blocked" }, + + // R26 批 B6 观测面:重复动作「先劝再断」的两档提醒在时间线上的人话行。八条都是完整句子(档位 × + // 重复形态 × 有没有工具名),而不是拼半句——中英文的标点与语序不一样,拆成碎片拼装迟早会拼出病句。 + // {repeats} 是连续重复步数,{tools} 是已人话化并加引号的工具名(humanizeAgentToolId)。 + "agent.reminderIdenticalToolFirst": { + "zh-CN": "第一次提醒:Cuu 连续 {repeats} 步做了同一件事(重复的是{tools}),已让它换个做法再继续。", + "en-US": "First reminder: Cuu repeated the same action for {repeats} steps (it kept using {tools}). Asked it to try another approach." + }, + "agent.reminderIdenticalPlainFirst": { + "zh-CN": "第一次提醒:Cuu 连续 {repeats} 步做了同一件事,已让它换个做法再继续。", + "en-US": "First reminder: Cuu repeated the same action for {repeats} steps. Asked it to try another approach." + }, + "agent.reminderAlternatingToolFirst": { + "zh-CN": "第一次提醒:Cuu 连续 {repeats} 步在两个动作之间来回切换(来回切换的是{tools}),已让它换个做法再继续。", + "en-US": "First reminder: Cuu kept switching between two actions for {repeats} steps (switching between {tools}). Asked it to try another approach." + }, + "agent.reminderAlternatingPlainFirst": { + "zh-CN": "第一次提醒:Cuu 连续 {repeats} 步在两个动作之间来回切换,已让它换个做法再继续。", + "en-US": "First reminder: Cuu kept switching between two actions for {repeats} steps. Asked it to try another approach." + }, + "agent.reminderIdenticalToolSecond": { + "zh-CN": "第二次提醒:Cuu 连续 {repeats} 步做了同一件事(重复的是{tools})。再重复下去,这次执行会自动交给人接手。", + "en-US": "Second reminder: Cuu repeated the same action for {repeats} steps (it kept using {tools}). If it keeps going, this run is handed to a person." + }, + "agent.reminderIdenticalPlainSecond": { + "zh-CN": "第二次提醒:Cuu 连续 {repeats} 步做了同一件事。再重复下去,这次执行会自动交给人接手。", + "en-US": "Second reminder: Cuu repeated the same action for {repeats} steps. If it keeps going, this run is handed to a person." + }, + "agent.reminderAlternatingToolSecond": { + "zh-CN": "第二次提醒:Cuu 连续 {repeats} 步在两个动作之间来回切换(来回切换的是{tools})。再重复下去,这次执行会自动交给人接手。", + "en-US": "Second reminder: Cuu kept switching between two actions for {repeats} steps (switching between {tools}). If it keeps going, this run is handed to a person." + }, + "agent.reminderAlternatingPlainSecond": { + "zh-CN": "第二次提醒:Cuu 连续 {repeats} 步在两个动作之间来回切换。再重复下去,这次执行会自动交给人接手。", + "en-US": "Second reminder: Cuu kept switching between two actions for {repeats} steps. If it keeps going, this run is handed to a person." + }, + "agent.reminderPhase": { "zh-CN": "换个做法", "en-US": "Change of approach" } } satisfies Record; const workItemStatusLabels = { @@ -484,6 +523,8 @@ const NOTIFICATION_TYPE_EXACT: Record = { "proposal.ready": ["改动待审", "Proposal ready"], "proposal.opened": ["改动待审", "Proposal ready"], "agent_run.step": ["AI 进展", "AI progress"], // term-allow:key 是事件名标识符,值已人话 + // R26 批 B6:重复动作提醒(前两档)。第三档升级走 agent_run.escalated → 已有「需要负责人介入」。 + "agent_run.reminded": ["AI 换个做法", "AI course correction"], // term-allow:key 是事件名标识符,值已人话 "agent_run.succeeded": ["AI 完成", "AI done"], // term-allow:key 是事件名标识符,值已人话 "agent_run.failed": ["AI 失败", "AI failed"], // term-allow:key 是事件名标识符,值已人话 "approval.decided": ["审批已定", "Decided"], @@ -597,6 +638,41 @@ export function agentStepPublicSummary( } } +// R26 批 B6 观测面:把一条「重复动作提醒」的结构化事实渲成时间线上的一句人话。 +// +// 事件/VM 里只有档位、连续步数、重复形态和原始工具 id——句子在这里按 locale 组装,八条模板都是 +// 完整句子(见上方词典注释)。工具名一律经 humanizeAgentToolId 去下划线并加引号,绝不裸露 +// run_command 这类原名;重复的那一步没有工具调用时退到不提工具的那半边模板。 +// 脏数据(缺档位 / 形态不认识)由 readAgentRunReminderFacts 挡在门外,调用方拿到 undefined 就整行不渲。 +const REMINDER_COPY_KEYS = { + "identical:tool:1": "agent.reminderIdenticalToolFirst", + "identical:plain:1": "agent.reminderIdenticalPlainFirst", + "alternating:tool:1": "agent.reminderAlternatingToolFirst", + "alternating:plain:1": "agent.reminderAlternatingPlainFirst", + "identical:tool:2": "agent.reminderIdenticalToolSecond", + "identical:plain:2": "agent.reminderIdenticalPlainSecond", + "alternating:tool:2": "agent.reminderAlternatingToolSecond", + "alternating:plain:2": "agent.reminderAlternatingPlainSecond" +} as const satisfies Record; + +// 引号与顿号是排版而不是文案:中文用直角引号 + 顿号,英文用弯引号 + 逗号。两边都不裸出工具 id。 +function reminderToolNames(facts: AgentRunReminderFacts, locale: WorkHubLocale): string { + const ids = facts.tool_ids ?? (facts.tool_id ? [facts.tool_id] : []); + const quoted = ids.map((id) => (locale === "zh-CN" ? `「${humanizeAgentToolId(id)}」` : `“${humanizeAgentToolId(id)}”`)); + return quoted.join(locale === "zh-CN" ? "、" : ", "); +} + +export function agentRunReminderLine(locale: WorkHubLocale, facts: AgentRunReminderFacts): string { + const tools = reminderToolNames(facts, locale); + const key = REMINDER_COPY_KEYS[`${facts.shape}:${tools ? "tool" : "plain"}:${facts.tier}`]; + return uiT(locale, key).replace("{repeats}", String(facts.repeats)).replace("{tools}", tools); +} + +/** 时间线上这一行的左侧标签(与 agentStepPhaseLabel 同位)。 */ +export function agentRunReminderPhaseLabel(locale: WorkHubLocale) { + return uiT(locale, "agent.reminderPhase"); +} + export function deliverableTargetLabel(locale: WorkHubLocale, kind: DeliverableTargetKind | string) { return labelFromMap(locale, kind, deliverableTargetLabels, "kind"); } diff --git a/packages/ui/src/replay/render.test.ts b/packages/ui/src/replay/render.test.ts index e2697c3b..44184ab3 100644 --- a/packages/ui/src/replay/render.test.ts +++ b/packages/ui/src/replay/render.test.ts @@ -646,3 +646,47 @@ test("bindReplayRevertActions ignores buttons missing the snapshot or run id", ( orphan.click(); assert.equal(calls.length, 0); }); + +// R26 批 B6 观测面:回放页也要看得见「重复动作被劝过几次、劝的是什么」。 +test("B6: 回放时间线在被劝的那一步之后补一条人话提醒行(中英各一句)", () => { + const base = replayWithStructuredFields(); + const stepNo = base.steps[0]?.step_no ?? 1; + const vm: ReplayTraceVM = { + ...base, + reminders: [{ step_no: stepNo, tier: 2, repeats: 5, shape: "identical", tool_id: "read_project_file" }] + }; + + const zh = renderAgentRunReplay(vm, "web"); + assert.equal(zh.html.includes("第二次提醒:Cuu 连续 5 步做了同一件事"), true); + assert.equal(zh.html.includes("「read project file」"), true); + assert.equal(zh.html.includes("read_project_file"), false, "原始工具 id 不该渲给用户"); + assert.equal(zh.html.includes('data-replay-reminder-tier="2"'), true); + // 步数统计只数模型的步,提醒行不冒充一步。 + assert.equal(zh.stepCount, vm.steps.length); + + const en = renderAgentRunReplay(vm, "desktop", { locale: "en-US" }); + assert.equal(en.html.includes("Second reminder: Cuu repeated the same action for 5 steps"), true); +}); + +test("B6: 回放页脏提醒整行不渲,对不上步骤的提醒补在末尾", () => { + const base = replayWithStructuredFields(); + const vm: ReplayTraceVM = { + ...base, + reminders: [ + { step_no: 4242, tier: 1, repeats: 3, shape: "alternating", tool_id: "echo", tool_ids: ["echo", "run_command"] }, + { step_no: 1, repeats: 3, shape: "identical" }, + null + ] as ReplayTraceVM["reminders"] + }; + + const rendered = renderAgentRunReplay(vm, "web"); + assert.equal(rendered.html.includes("第一次提醒:Cuu 连续 3 步在两个动作之间来回切换"), true); + assert.equal(rendered.html.includes("「echo」、「run command」"), true); + assert.equal(rendered.html.match(/data-replay-reminder-tier=/gu)?.length, 1, "只有合法的那一条被渲出来"); +}); + +test("B6: 没有提醒的回放渲染完全不变(additive optional,存量响应零回归)", () => { + const vm = replayWithStructuredFields(); + assert.equal(renderAgentRunReplay(vm, "web").html, renderAgentRunReplay({ ...vm, reminders: [] }, "web").html); + assert.equal(renderAgentRunReplay(vm, "web").html.includes("data-replay-reminder-tier"), false); +}); diff --git a/packages/ui/src/replay/render.ts b/packages/ui/src/replay/render.ts index cc54b6bf..893ba947 100644 --- a/packages/ui/src/replay/render.ts +++ b/packages/ui/src/replay/render.ts @@ -1,3 +1,4 @@ +import { readAgentRunReminderFacts } from "@workhub/contracts"; import type { CuuState, ReplayMergeCandidateVM, @@ -6,7 +7,7 @@ import type { WorkHubLocale } from "@workhub/contracts"; -import { agentRunStatusLabel, agentStepPhaseLabel, agentStepPublicSummary, formatLocalTimestamp, uiCount, uiLocale, uiT, type UiRenderOptions } from "../i18n.js"; +import { agentRunReminderLine, agentRunReminderPhaseLabel, agentRunStatusLabel, agentStepPhaseLabel, agentStepPublicSummary, formatLocalTimestamp, uiCount, uiLocale, uiT, type UiRenderOptions } from "../i18n.js"; import { overlapHunkReviewCss, renderOverlapHunkReview } from "../overlap-hunk-review.js"; import { renderRichPatchViewer, richPatchViewerCss } from "../rich-patch-viewer.js"; import { @@ -48,6 +49,8 @@ export const replayCss = [ ".wh-btn-danger[data-replay-revert-armed=\"true\"]{background:#ffe3df;color:#8f2f27;border-color:#e7a49c}.wh-btn-danger[aria-disabled=\"true\"]{opacity:.6;pointer-events:none}", richPatchViewerCss, ".wh-row .wh-patch{margin-top:10px}", + // B6:提醒行不是模型的一步——左侧琥珀竖条区分,第二档再深一档提示「下一次就转交人」。 + ".wh-row[data-replay-reminder-tier]{border-left:3px solid var(--amber);padding-left:10px}.wh-row[data-replay-reminder-tier=\"2\"]{border-left-color:#b4622a}", overlapHunkReviewCss, ".wh-row .wh-diff3{margin-top:10px}", ".wh-replay-audit{border:1px solid #dbe5ff;border-radius:12px;background:#f7f9ff;padding:10px 12px;display:grid;gap:8px;margin-top:10px}.wh-replay-audit-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.wh-replay-audit-list{display:grid;gap:6px}.wh-replay-audit-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;border-top:1px solid #e5eaff;padding-top:6px}.wh-replay-audit-row:first-child{border-top:0;padding-top:0}.wh-replay-audit-code{font-family:\"Cascadia Mono\",\"SFMono-Regular\",monospace;font-size:12px;color:#45506b;overflow-wrap:anywhere}", @@ -343,9 +346,31 @@ export function renderAgentRunReplay( const rootClass = surface === "desktop" ? "wh-desktop" : "wh-web"; const run = vm.run as ReplayTraceVM["run"] & { id?: string; work_item_id?: string }; const runId = run.id ?? ""; + // R26 批 B6 观测面:每一步之后补上「这一步之后 Cuu 被劝过什么」。提醒不是模型的一步(是运行环境 + // 往对话里追加的一句话),因此不占步号、也不冒充步骤行——单独一行,标 data-replay-reminder-tier。 + const reminderRows = (stepNo: number) => + (vm.reminders ?? []) + .map((entry) => readAgentRunReminderFacts(entry)) + .filter((facts): facts is NonNullable => Boolean(facts) && facts?.step_no === stepNo) + .map( + (facts) => + `
${escapeHtml(agentRunReminderPhaseLabel(locale))}

${escapeHtml(agentRunReminderLine(locale, facts))}

` + ) + .join(""); + const renderedStepNos = new Set(vm.steps.map((step) => step.step_no)); + const orphanReminderStepNos = [ + ...new Set( + (vm.reminders ?? []) + .map((entry) => readAgentRunReminderFacts(entry)) + .filter((facts): facts is NonNullable => Boolean(facts) && !renderedStepNos.has(facts?.step_no ?? -1)) + .map((facts) => facts.step_no) + ) + ].sort((a, b) => a - b); const steps = vm.steps - .map((step) => `
${escapeHtml(agentStepPhaseLabel(locale, step.phase))}

${escapeHtml(agentStepPublicSummary(locale, step))}

#${escapeHtml(String(step.step_no))}
`) - .join(""); + .map((step) => `
${escapeHtml(agentStepPhaseLabel(locale, step.phase))}

${escapeHtml(agentStepPublicSummary(locale, step))}

#${escapeHtml(String(step.step_no))}
${reminderRows(step.step_no)}`) + .join("") + // 对不上任何步骤行的提醒(步骤被裁剪 / 数据不齐)补在末尾,绝不因为对不上就悄悄丢掉。 + + orphanReminderStepNos.map(reminderRows).join(""); const deliverables = renderDeliverables(vm, locale); const mergeTimeline = renderMergeTimeline(vm, locale); const auditDetails = renderStructuredFieldAuditDetails({ From bb683f278e5568b98bd1bf4b26fec16454d91077 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:52:40 +0800 Subject: [PATCH 09/23] =?UTF-8?q?docs:=20README=20=E8=A1=A5=E3=80=8CMCP=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E3=80=8D=E5=B0=8F=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 是什么、在哪里接、名字为什么受限、信任级别怎么选、为什么不能直接填 npx、密钥怎么给、 它跑在哪。包结构里补上 packages/mcp-client 一行。 Co-Authored-By: Claude Fable 5.1 --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 80c9a5cf..7b447cbf 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ packages/ api-client/ 对着 contracts 生成的带类型 HTTP 客户端。 tools/ agent 工具实现 —— 沙箱文件操作、run_command、技能加载 —— 以及迁移重放安全审计器。 plugin-host/ 第三方插件的子进程宿主 —— 兼容 DeepSeek Harness 的工具类插件,dsh 依赖全部关在这个包里。 + mcp-client/ MCP 服务器的客户端 —— stdio 会话、工具翻译与命名空间、接入前的启动前检查。 ``` ## 插件 @@ -226,6 +227,22 @@ export function apply(ctx) { **一句实话**:dsh 的 `0.1.x` 还在破坏性地改。已发布的第三方插件常常是对着另一个版本发的,装上去会在加载期报错——安装页会把「它要哪个版本」和「我们捆的是哪个版本」两个数一起摆给你看,但我们对上游没有影响力。 +## MCP 服务器 + +MCP(Model Context Protocol,模型上下文协议)服务器是跑在这台服务器上的一个独立进程,它把一组工具借给 Cuu 用。接一台文件服务器,Cuu 干活时就能读你指定目录下的文件;接一台 GitHub 服务器,它就能查 issue 和 PR。这一版只接 **stdio** 传输的服务器——要填的是启动它的那条命令,和你在终端里敲的那一行一样。 + +**怎么接**:桌面客户端 → 设置 → MCP 服务器 → 「接一台服务器」。管理员才看得到这一区;网页端的设置页只显示一份只读清单(接了哪几台、还连得上吗、每台断言成哪一档),添加、启停和移除都在桌面客户端里做。接入前服务端先做一次启动前检查——**不执行任何东西**,只做字符串判定和一次文件检查——没通过会直接说明是哪一项不成立,不让你猜。接好之后可以随时点「测试连接」问一句「现在连得上吗」。 + +**名字**:服务器名会成为它所有工具的命名空间——这些工具在模型眼里叫 `mcp__<服务器名>__<工具名>`,所以名字只收字母、数字、下划线和短横线,最长 32 个字符,同一个工作区里不能重名。名字里的词还会先拿去判风险:一台叫 `finance` 或 `publish` 的服务器,它的**每一个**工具每次调用都会停下来转人确认,和信任级别无关。起名尽量短、尽量中性。 + +**信任级别**:每台服务器有一档管理员设定的**风险上限**,新接入的一律落在最高的一档——它的每件工具都按「有外部影响」对待,每次调用都要人确认。确认一台服务器只读之后,可以在设置页把它断言为**只读**。断言是上限,不是保证:只有那些**自己也声明了只读**的工具才真的按只读运行(不再逐次转人),同一台服务器上没有这项声明的工具仍然停在最高档。改一次落一条审计。 + +**为什么不能直接填 `npx`**:生态里的文档几乎都写 `npx -y @modelcontextprotocol/server-github` 这样一行。这类启动器每次启动都会从网上下载一份代码并立刻执行——今天接上的和下周跑起来的不一定是同一份东西,而且这一步发生在任何检查之前。所以 `npx`、`pnpm dlx`、`bunx`、`uvx` 一律拒绝,与「带安装期脚本的插件包不装」是同一条红线。做法是先在这台服务器上把它装好,再把装好之后那个可执行文件的完整路径填进来。 + +**密钥怎么给**:环境变量那一栏只收非密的配置,键名看起来像凭据的(带 `TOKEN`、`SECRET`、`API_KEY`、`PASSWORD` 这类词)会被直接拒绝。真正的密钥走**密钥引用**:先在服务端的环境里配一个以 `WORKHUB_MCP_SECRET_` 开头的变量,再在表单里把「服务器读的变量名」指向它。数据库里存的是那个指针,不是值;值只在启动子进程的那一刻从服务端环境里取出来注入。引用的变量在服务端不存在时,这台服务器不会拿空串起进程,而是直接标成连不上。 + +**它跑在哪**:一个独立子进程,以启动 API 的那个系统用户身份运行,环境变量按白名单组装。工具调用照走 WorkHub 既有的还原点、只能人来做的动作拦截与审批,每次调用都落审计。连不上会重试,连续失败到一定次数就先不再自动重连,在设置页点「测试连接」可以重新开始。但这是**隔离容器,不是沙箱**:它能读写这台机器上它能碰的文件、能出网。接谁的服务器,自己判断。 + ## 文档 - 规格树索引:[`docs/workhub/`](docs/workhub/README.md) From bb3f1955711360e75b26fc7da4b7aba45ea738a2 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:54:18 +0800 Subject: [PATCH 10/23] =?UTF-8?q?docs:=20=E5=B7=A5=E5=8C=85=20M8=20?= =?UTF-8?q?=E7=9A=84=20Agent=20Note=EF=BC=88=E7=A8=B3=E5=AE=9A=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=A0=81/=E7=BD=91=E9=A1=B5=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E6=B8=85=E5=8D=95/=E4=B8=89=E6=9D=A1=E6=96=87=E6=A1=88?= =?UTF-8?q?=E9=81=97=E7=95=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...09-06-mcp-m8-web-readonly-and-leftovers.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .agents/notes/implemented/2026-09-06-mcp-m8-web-readonly-and-leftovers.md diff --git a/.agents/notes/implemented/2026-09-06-mcp-m8-web-readonly-and-leftovers.md b/.agents/notes/implemented/2026-09-06-mcp-m8-web-readonly-and-leftovers.md new file mode 100644 index 00000000..40d10658 --- /dev/null +++ b/.agents/notes/implemented/2026-09-06-mcp-m8-web-readonly-and-leftovers.md @@ -0,0 +1,106 @@ +# MCP 客户端接入:稳定错误码、网页只读清单与三条文案遗留(工包 M8) + +- Status: implemented +- Date: 2026-09-06 +- Owner: claude-code + +## Problem + +M7 把桌面设置页接通之后,留下三件互相咬合的事: + +1. **连不上的原因没有码**。`describeMcpSessionFailure()` 在 M3 就把八条会话失败原因翻成了 + `mcp_handshake_timeout` 这样的稳定码,但没有一个字段把码带出去——`McpServerVM.last_error` 与 + `McpServerConnectionVM.last_error` 都是自由文本。于是设计稿里那句「握手超时」桌面端**给不出**, + 只能回落到通用的「连不上 +(英文诊断)」。M7 的 Note 把这一条明写成建议留给后续工包。 +2. **网页端一台服务器都看不见**。`mcpServerSummaryVmSchema`(网页只读行的裁剪口径)从 M0 起就在契约里 + 躺着,没有任何一处填它、也没有任何一处渲它。一个只有网页的管理员无从知道这个部署接了什么。 +3. **F2 的两条遗留**。设置页「系统诊断」分区对普通成员仍然渲染(A2-45);五个 service 里还有约 10 处 + 「事项 / 工单」没按 glossary §11 收口成「任务」。 + +## Decision + +### 一、码从内存来,因为表里没有存码的列 + +`last_error_code` 加在三个读形状上(`McpServerVM` / `McpServerConnectionVM` / `McpServerSummaryVM`), +枚举是 M3 那八条 `mcp_*` 加上 `mcp_connect_failed` 兜底。**没有加 DB 列**:那需要一次迁移,而 +`packages/db` 不在本工位的改动范围;更重要的是加了列也只是把同一份信息存两遍——行上的 `last_error` +已经是那次失败的诊断,码是它的分类。 + +于是码来自**本进程的连接快照**:`McpServerStatusSnapshot` 多一个 `lastErrorReason`(原因枚举), +`toMcpServerVm(row, snapshot?)` 与 `toConnectionVm(snapshot)` 在有诊断文本时把它翻成码。 +**行上有诊断、这个进程却没有那台服务器的连接记录时(重启之后、或从没启用过),码缺席**—— +那是如实的「这一次的原因这个进程说不出来」,界面回落到通用的一句话,而不是编一个它并不知道的原因。 +契约注释与两处测试都把这条写死了。 + +**翻码固定发生在 `services/mcp-servers.ts`**,M2 的连接监督只回原因枚举。理由是循环: +`mcp-servers.ts` import `mcp-client.ts`,反过来去取那份映射会绕成一个环;复制一份则是第二份会漂移的 +词表。新导出的 `mcpSessionFailureCode(reason)` 与既有的 `describeMcpSessionFailure(error)` 共用同一张 +`SESSION_FAILURES`,测试里有一行断言两个出口给同一个码。 + +### 二、网页只读清单:结构性缺席,而不是隐藏 + +`settingsPageVmSchema` 加 `mcp_servers?: McpServerSummaryVM[]`,路由与插件清单**同一道管理员门**: +非管理员时服务端不取不填,字段结构性缺席(不是空数组——空数组会被读成「一台都没接」),整区不渲。 +取数失败降级为不渲这一区,不拖垮整页设置。 + +行的形状是裁剪过的 summary:命令、参数、环境变量、密钥引用、工作目录**结构性没有位置**, +`last_error` 也没有——它是宿主机的现场诊断(可执行文件路径、stderr 尾巴),和命令、工作目录属于同一类 +不该上网页的事实。**只有码能上网页**:码本身不含宿主机信息,却足以让这一行说出「服务器没有在规定 +时间内应答。」而不是笼统的「连不上」。 + +状态四种说法各说各的,与桌面端同一套判据:已停用 / 已连接 · N 个工具 / 已连接 · 它没有提供工具 / +连不上。第三种是刻意与第四种分开的——「连上了但一个工具都没有」和「连不上」下一步动作完全不同。 +空态一句话加一句去处(添加、启停和移除在桌面客户端的设置里操作),与插件区同款分工。 + +### 三、A2-45:系统诊断整段不进 HTML + +并发处理能力、消息通道、数据存储、用哪个模型、密钥配没配——这几行是部署的运行事实,普通成员既判断 +不了也调不动。与成员 / 预算策略 / 工作区审计三区同一道门(`isAdmin`),整段不渲染而不是 `hidden`。 +分区抬头随之从「系统诊断(管理员关注;普通成员只读参考)」改成「系统诊断(仅管理员)」——旧那句话 +在成员看不到这一区之后就不成立了。 + +`data-r4-settings-runtime-status` 留在外壳那一层(它是路由自检读的标记,不是渲给人看的一行), +成员那一侧仍然有,`apps/web/qa` 的实时冒烟以管理员身份登录,两处都不受影响。 + +### 四、五个 service 的「事项 / 工单」:搬进词典再改词,不就地改 + +门禁按「文件 + 归一化片段」记棘轮,就地改一句话会同时产生「基线条目已消失」与「新增违规」。所以改到的 +每一句都搬进 `services/locales.ts`(F2 建的那份),基线**只删不加**:净减 13 条, +`work-item-comments.ts` 整份从基线里消失。「及其余 N 项」在停滞段与临期段本来是同一句话写了两遍, +一并改成共用一条词典条目。 + +**只改用户可见文案**。`conversation-turns.ts` 里那几句 `content` 是回填给模型的 `tool_result` 文本, +与 `packages/agent` 的提示词(`turn-system-prompt` 等 golden 基线里仍写着「工单」)是同一片文本, +要改得一起改;`auditSummary` 走的是 `tool_note` 透明日志,用户在聊天里看得见,所以改。 + +## Alternatives considered + +- **给 `mcp_servers` 表加一列存错误码**。否决:需要迁移(`packages/db` 不在本工位范围),而且行上的 + `last_error` 已经是那次失败的诊断,码只是它的分类——存两遍必然有一天对不上。代价是重启后码缺席, + 但那一条被明确写进契约注释并有测试钉住,比一个可能过期的持久化码诚实。 +- **让 `mcp-client.ts` 自己算码**(把 `SESSION_FAILURES` 复制过去或反向 import)。否决:反向 import 绕成 + 循环,复制则是第二份会漂移的词表。回原因枚举、由上一层翻码,两边都只有一份事实源。 +- **网页只读行也带 `last_error`**。否决:那是宿主机现场诊断,与结构性不给 `command` / `cwd` 位置是同一条 + 理由。码不含宿主机信息,够用。 +- **网页也给「已连接(空闲,用到时再起)」那种说法**(桌面端有)。否决:网页只读行拿的是行上的状态, + 拿不到「此刻还有没有活着的子进程」;硬渲一个它不知道的区别只会说错话。 +- **A2-45 用服务端结构性缺席**(`buildSettingsPage` 对非管理员不填 `runtime` / `llm_runtime`)。否决: + 两个字段在 `settingsPageVmSchema` 里是必填的,改成可选会波及所有读它们的地方,而收益只是把一道已经 + 存在于渲染层的门再挪一次位置。成员 / 预算策略 / 工作区审计三区的先例就在渲染层。 +- **五个 service 就地改词、把基线条目改名**。否决:那不叫「基线只减」,只是把同一笔账换个写法记着。 + +## Consequences + +- **桌面端还没消费 `last_error_code`**。M7 已定型,本工包没有动 `apps/desktop-webview`。 + `settings-mcp.ts` 的 `mcpReasonLine` 现在可以按码出话(`mcp_handshake_timeout` 那句人话就是 M7 的 + Note 里说「给不出」的那一句),但那是下一个动桌面端的工包的事。 +- **M7 借来的管理员门可以换掉了**。`settings.ts` 里 `visible: vm.plugins !== undefined` 现在有了同款的 + `vm.mcp_servers !== undefined`,换成后者更贴切;同样留给下一个动桌面端的工包。 +- **OpenAPI 里设置页响应此前结构性不合法**:`settingsPageResponseSchema` 是 + `additionalProperties: false`,却没有列 `permission_policies` 与 `plugins`——一份真实的管理员响应 + 按这份文档是不合法的。补 `mcp_servers` 时把那两条一并补上。 +- **`apps/web/qa/r4-web-live-route-interaction.ts` 断言 `[data-r4-settings-runtime]` 与 + `[data-r4-settings-llm]` 存在**,它以管理员身份登录(`is_admin: nickname === "R4 Live Reviewer"`), + A2-45 之后仍然成立,未改 qa。 +- **模型可见文本零变化**:`pnpm gen:expected` 两次运行都无 diff。 +- **棘轮基线只减**:ui-i18n 2808 → 2795(净减 13);禁词基线 49 条不变。 From df3231454dbdbeb39f6449b947a99044741ba084 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:56:04 +0800 Subject: [PATCH 11/23] =?UTF-8?q?feat(b6):=20=E6=A1=8C=E9=9D=A2=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=E6=97=B6=E9=97=B4=E7=BA=BF=E8=A1=A5=E3=80=8C=E6=8D=A2?= =?UTF-8?q?=E4=B8=AA=E5=81=9A=E6=B3=95=E3=80=8D=E6=8F=90=E9=86=92=E8=A1=8C?= =?UTF-8?q?=E5=B9=B6=E8=AE=A2=E4=B8=8A=E6=96=B0=E4=BA=8B=E4=BB=B6=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spotlight/locales.ts 补八条完整句子(与 web 端同措辞),labels.ts 出同口径的组装函数; 回放视图把提醒行插在它所属那一步之后,对不上步骤的补在末尾,脏数据整行不渲。 桌面 per-run SSE 订阅登记 agent_run.reminded——EventSource 按事件名订阅,漏登记就被静默丢弃。 Co-Authored-By: Claude Fable 5.1 --- .../src/desktop-cuu-runtime.ts | 3 + apps/desktop-webview/src/spotlight/labels.ts | 38 ++++++++- apps/desktop-webview/src/spotlight/locales.ts | 21 +++++ .../src/spotlight/views/replay.test.ts | 80 ++++++++++++++++++- .../src/spotlight/views/replay.ts | 49 ++++++++++-- packages/ui/src/agent-run/render.test.ts | 2 +- 6 files changed, 185 insertions(+), 8 deletions(-) diff --git a/apps/desktop-webview/src/desktop-cuu-runtime.ts b/apps/desktop-webview/src/desktop-cuu-runtime.ts index 5eaf3773..f57cb360 100644 --- a/apps/desktop-webview/src/desktop-cuu-runtime.ts +++ b/apps/desktop-webview/src/desktop-cuu-runtime.ts @@ -1808,6 +1808,9 @@ const desktopCuuRunStreamEventNames = [ eventTypes.agentRunStep, eventTypes.stepToolResult, eventTypes.agentRunCompacting, + // R26 批 B6 观测面:重复动作提醒(前两档)。EventSource 按事件名订阅,漏登记这一条 = 事件被静默 + // 丢弃、run 卡与回放时间线都收不到「Cuu 被劝了」的信号(同 live-stream-targets 的既有教训)。 + eventTypes.agentRunReminded, eventTypes.agentRunFailed, eventTypes.agentRunEscalated ] as const; diff --git a/apps/desktop-webview/src/spotlight/labels.ts b/apps/desktop-webview/src/spotlight/labels.ts index fd2d3f4e..8da11f66 100644 --- a/apps/desktop-webview/src/spotlight/labels.ts +++ b/apps/desktop-webview/src/spotlight/labels.ts @@ -8,7 +8,9 @@ // 漏一个枚举取值该在 labels.test.ts 里红(那里按 contracts 的枚举数组逐个断言有映射), // 而不是在界面上漏出 `delivery_ready` 这种生 id。 -import { spotlightEnumLabel, spotlightT } from "./locales.js"; +import { humanizeAgentToolId, type AgentRunReminderFacts } from "@workhub/contracts"; + +import { spotlightEnumLabel, spotlightT, type SpotlightCopyKey } from "./locales.js"; export function workItemStatusLabel(status: string, zh: boolean): string { return spotlightEnumLabel("workItemStatus", status, zh) ?? spotlightT(zh, "unknownStatus"); @@ -50,6 +52,40 @@ export function agentStepPublicSummary( } } +// R26 批 B6 观测面:把一条「重复动作提醒」的结构化事实渲成回放时间线上的一句人话。 +// 与 web 端 packages/ui i18n.ts 的 agentRunReminderLine 同口径(同八条模板、同引号与顿号约定)—— +// 两端各留一份是这一层的既有惯例(agentStepPublicSummary 等同款),词表本身在 locales.ts。 +// +// 工具名一律先过 humanizeAgentToolId 去下划线再加引号,界面上绝不出现 run_command 这类原始 id; +// 重复的那一步没有工具调用时退到不提工具的那半边模板,不留悬空括号。 +const REMINDER_COPY_KEYS = { + "identical:tool:1": "reminderIdenticalToolFirst", + "identical:plain:1": "reminderIdenticalPlainFirst", + "alternating:tool:1": "reminderAlternatingToolFirst", + "alternating:plain:1": "reminderAlternatingPlainFirst", + "identical:tool:2": "reminderIdenticalToolSecond", + "identical:plain:2": "reminderIdenticalPlainSecond", + "alternating:tool:2": "reminderAlternatingToolSecond", + "alternating:plain:2": "reminderAlternatingPlainSecond" +} as const satisfies Record; + +function reminderToolNames(facts: AgentRunReminderFacts, zh: boolean): string { + const ids = facts.tool_ids ?? (facts.tool_id ? [facts.tool_id] : []); + const quoted = ids.map((id) => (zh ? `「${humanizeAgentToolId(id)}」` : `“${humanizeAgentToolId(id)}”`)); + return quoted.join(zh ? "、" : ", "); +} + +export function agentRunReminderLine(facts: AgentRunReminderFacts, zh: boolean): string { + const tools = reminderToolNames(facts, zh); + const key = REMINDER_COPY_KEYS[`${facts.shape}:${tools ? "tool" : "plain"}:${facts.tier}`]; + return spotlightT(zh, key).replace("{repeats}", String(facts.repeats)).replace("{tools}", tools); +} + +/** 提醒行的左侧标签(与 agentStepPhaseLabel 同位)。 */ +export function agentRunReminderPhaseLabel(zh: boolean): string { + return spotlightT(zh, "reminderPhase"); +} + // 选项风险等级(contracts riskLevel low/medium/high):与 web route-components localizedEnumLabel 同口径。 export function riskHintLabel(level: string, zh: boolean): string { return spotlightEnumLabel("riskHint", level, zh) ?? spotlightT(zh, "unknownRiskLevel"); diff --git a/apps/desktop-webview/src/spotlight/locales.ts b/apps/desktop-webview/src/spotlight/locales.ts index b681bbd7..c38ee24c 100644 --- a/apps/desktop-webview/src/spotlight/locales.ts +++ b/apps/desktop-webview/src/spotlight/locales.ts @@ -37,6 +37,18 @@ const zh = { projectCreatedThisPreviewCanT: "项目已创建。在 WorkHub 桌面应用里打开它的工作台。", projectNameEGMarketingOps: "项目名称,例如:市场部日常", recordedOneStep: "记录了一个步骤。", + // R26 批 B6 观测面:重复动作「先劝再断」的两档提醒在回放时间线上的人话行。八条都是完整句子 + // (档位 × 重复形态 × 有没有工具名),与 web 端 packages/ui i18n.ts 的 agent.reminder* 同口径、同措辞。 + // {repeats} 是连续重复步数,{tools} 是已人话化并加引号的工具名。 + reminderPhase: "换个做法", + reminderIdenticalToolFirst: "第一次提醒:Cuu 连续 {repeats} 步做了同一件事(重复的是{tools}),已让它换个做法再继续。", + reminderIdenticalPlainFirst: "第一次提醒:Cuu 连续 {repeats} 步做了同一件事,已让它换个做法再继续。", + reminderAlternatingToolFirst: "第一次提醒:Cuu 连续 {repeats} 步在两个动作之间来回切换(来回切换的是{tools}),已让它换个做法再继续。", + reminderAlternatingPlainFirst: "第一次提醒:Cuu 连续 {repeats} 步在两个动作之间来回切换,已让它换个做法再继续。", + reminderIdenticalToolSecond: "第二次提醒:Cuu 连续 {repeats} 步做了同一件事(重复的是{tools})。再重复下去,这次执行会自动交给人接手。", + reminderIdenticalPlainSecond: "第二次提醒:Cuu 连续 {repeats} 步做了同一件事。再重复下去,这次执行会自动交给人接手。", + reminderAlternatingToolSecond: "第二次提醒:Cuu 连续 {repeats} 步在两个动作之间来回切换(来回切换的是{tools})。再重复下去,这次执行会自动交给人接手。", + reminderAlternatingPlainSecond: "第二次提醒:Cuu 连续 {repeats} 步在两个动作之间来回切换。再重复下去,这次执行会自动交给人接手。", restorePoint: "还原点", retry: "重试", step: "步骤", @@ -77,6 +89,15 @@ const en = { projectCreatedThisPreviewCanT: "Project created. Open its workbench from the WorkHub desktop app.", projectNameEGMarketingOps: "Project name, e.g. Marketing ops", recordedOneStep: "Recorded one step.", + reminderPhase: "Change of approach", + reminderIdenticalToolFirst: "First reminder: Cuu repeated the same action for {repeats} steps (it kept using {tools}). Asked it to try another approach.", + reminderIdenticalPlainFirst: "First reminder: Cuu repeated the same action for {repeats} steps. Asked it to try another approach.", + reminderAlternatingToolFirst: "First reminder: Cuu kept switching between two actions for {repeats} steps (switching between {tools}). Asked it to try another approach.", + reminderAlternatingPlainFirst: "First reminder: Cuu kept switching between two actions for {repeats} steps. Asked it to try another approach.", + reminderIdenticalToolSecond: "Second reminder: Cuu repeated the same action for {repeats} steps (it kept using {tools}). If it keeps going, this run is handed to a person.", + reminderIdenticalPlainSecond: "Second reminder: Cuu repeated the same action for {repeats} steps. If it keeps going, this run is handed to a person.", + reminderAlternatingToolSecond: "Second reminder: Cuu kept switching between two actions for {repeats} steps (switching between {tools}). If it keeps going, this run is handed to a person.", + reminderAlternatingPlainSecond: "Second reminder: Cuu kept switching between two actions for {repeats} steps. If it keeps going, this run is handed to a person.", restorePoint: "Restore point", retry: "Retry", step: "Step", diff --git a/apps/desktop-webview/src/spotlight/views/replay.test.ts b/apps/desktop-webview/src/spotlight/views/replay.test.ts index 37310287..d444a957 100644 --- a/apps/desktop-webview/src/spotlight/views/replay.test.ts +++ b/apps/desktop-webview/src/spotlight/views/replay.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import type { AgentRunLiveVM, AgentStep, ReplayTraceVM, Snapshot } from "@workhub/contracts"; -import { createReplayView, runListHtml, snapshotsSectionHtml } from "./replay.js"; +import { createReplayView, runListHtml, runReminderRows, snapshotsSectionHtml } from "./replay.js"; const ts = "2026-07-03T10:24:00.000Z"; @@ -418,3 +418,81 @@ test("F-06 a snapshot renders as unactionable text (not a dead button) when the dispose?.(); }); + +// R26 批 B6 观测面:桌面回放时间线要能看见「重复动作被劝了几次、劝的是什么」。 +test("B6 桌面回放在被劝的那一步之后补一条人话提醒行,不裸露原始工具 id", () => { + const zh = runReminderRows( + [{ step_no: 2, tier: 1, repeats: 3, shape: "identical", tool_id: "read_project_file" }], + 2, + true + ); + + assert.match(zh, /第一次提醒:Cuu 连续 3 步做了同一件事/u); + assert.match(zh, /「read project file」/u); + assert.doesNotMatch(zh, /read_project_file/u); + assert.match(zh, /data-spot-reminder-tier="1"/u); + // 别的步骤不该借到这一行。 + assert.equal( + runReminderRows([{ step_no: 2, tier: 1, repeats: 3, shape: "identical" }], 3, true), + "" + ); +}); + +test("B6 桌面回放第二档提醒说明「再重复就交给人接手」,英文界面出英文", () => { + const facts = [ + { step_no: 5, tier: 2, repeats: 5, shape: "alternating", tool_id: "echo", tool_ids: ["echo", "mcp__gh__list_issues"] } + ]; + + assert.match(runReminderRows(facts, 5, true), /再重复下去,这次执行会自动交给人接手。/u); + assert.match(runReminderRows(facts, 5, true), /「echo」、「list issues」/u); + const en = runReminderRows(facts, 5, false); + assert.match(en, /Second reminder: Cuu kept switching between two actions for 5 steps/u); + assert.match(en, /handed to a person/u); + assert.doesNotMatch(en, /mcp__gh__/u); +}); + +test("B6 桌面回放对脏提醒整行不渲,缺工具名时不留悬空括号", () => { + assert.equal(runReminderRows([{ step_no: 1, repeats: 3, shape: "identical" }], 1, true), ""); + assert.equal(runReminderRows([{ step_no: 1, tier: 1, repeats: 3, shape: "spiral" }], 1, true), ""); + assert.equal(runReminderRows(["not-an-object", null, undefined], 1, true), ""); + assert.equal(runReminderRows(undefined, 1, true), ""); + + const plain = runReminderRows([{ step_no: 1, tier: 1, repeats: 3, shape: "identical" }], 1, true); + assert.match(plain, /第一次提醒:Cuu 连续 3 步做了同一件事,已让它换个做法再继续。/u); + assert.doesNotMatch(plain, /(/u); +}); + +test("B6 桌面回放:run 时间线把提醒排在它所属那一步之后,对不上步骤的补在末尾", async () => { + const body = { innerHTML: "", addEventListener() {} } as unknown as HTMLElement; + const view = createReplayView(); + const vm = liveRun(); + const withReminders: AgentRunLiveVM = { + ...vm, + status: "succeeded", + reminders: [ + { step_no: vm.trace[0]?.step_no ?? 1, tier: 1, repeats: 3, shape: "identical", tool_id: "run_command" }, + { step_no: 4242, tier: 2, repeats: 5, shape: "identical", tool_id: "echo" } + ] + }; + const dispose = view.mount({ + client: { async getAgentRun() { return withReminders; } }, + locale: "zh-CN", + body, + back() {}, + open() {}, + target: { id: "93000000-0000-4000-8000-000000000911" }, + setSubtitle() {}, + toast() {}, + requestResize() {}, + refocusBody() {}, + signal: new AbortController().signal + } as unknown as Parameters[0]) as (() => void) | undefined; + await tick(); + + const firstReminderAt = body.innerHTML.indexOf('data-spot-reminder-tier="1"'); + const orphanAt = body.innerHTML.indexOf('data-spot-reminder-tier="2"'); + assert.equal(firstReminderAt > -1, true, "第一档提醒行应出现在时间线里"); + assert.equal(orphanAt > firstReminderAt, true, "对不上步骤的提醒补在末尾,不被丢掉"); + assert.doesNotMatch(body.innerHTML, /run_command/u); + dispose?.(); +}); diff --git a/apps/desktop-webview/src/spotlight/views/replay.ts b/apps/desktop-webview/src/spotlight/views/replay.ts index 81dc5389..10f5f006 100644 --- a/apps/desktop-webview/src/spotlight/views/replay.ts +++ b/apps/desktop-webview/src/spotlight/views/replay.ts @@ -8,14 +8,21 @@ // 回放整页用的 gold-path 旧卡片语言),改用 Spotlight 自己的玻璃组件词汇(wh-spot-*)画同一份数据, // 复用同一套 data-* 属性契约让既有、已测试的 binder 原样生效。 -import type { AgentRunLiveVM, AgentStep, AttentionHomeVM, ReplayTraceVM } from "@workhub/contracts"; +import { readAgentRunReminderFacts, type AgentRunLiveVM, type AgentStep, type AttentionHomeVM, type ReplayTraceVM } from "@workhub/contracts"; import { escapeHtml } from "@workhub/web-runtime"; import { formatLocalTimestamp } from "@workhub/ui"; import type { ReplayRevertRoot } from "@workhub/ui/replay"; import { bindDesktopAgentRunReplayRevert } from "../../desktop-agent-run-replay.js"; import { spotlightErrorHtml, type SpotlightCapabilityView, type SpotlightViewContext } from "../view-context.js"; -import { agentRunStatusLabel, agentStepPhaseLabel, agentStepPublicSummary, snapshotKindLabel } from "../labels.js"; +import { + agentRunReminderLine, + agentRunReminderPhaseLabel, + agentRunStatusLabel, + agentStepPhaseLabel, + agentStepPublicSummary, + snapshotKindLabel +} from "../labels.js"; import { spotlightViewsT } from "./locales.js"; @@ -63,6 +70,22 @@ export function runListHtml(runs: BgRun[], zh: boolean): string { .join("")}
`; } +// R26 批 B6 观测面:某一步之后的提醒行。脏数据(缺档位 / 形态不认识 / 根本不是对象)由 +// readAgentRunReminderFacts 挡掉——返回 undefined 就整行不渲,绝不把半截事实编成一句话。 +export function runReminderRows(reminders: ReadonlyArray | undefined, stepNo: number, zh: boolean): string { + if (!reminders?.length) { + return ""; + } + return reminders + .map((entry) => readAgentRunReminderFacts(entry)) + .filter((facts): facts is NonNullable => Boolean(facts) && facts?.step_no === stepNo) + .map( + (facts) => + `
${escapeHtml(agentRunReminderPhaseLabel(zh))}
${escapeHtml(agentRunReminderLine(facts, zh))}
` + ) + .join(""); +} + function traceHtml(vm: AgentRunLiveVM, zh: boolean, waiting = false): string { const u = vm.usage; // L14:列表里这条运行标着「等你拍板」(waiting_for_user,仅 attention 列表态有,详情 VM 的 status 枚举不含它)。 @@ -76,13 +99,29 @@ function traceHtml(vm: AgentRunLiveVM, zh: boolean, waiting = false): string {
${spotlightViewsT(zh, "cost2")}¥${escapeHtml(String(u.estimated_cost_cny))}
`; const steps = vm.trace ?? []; - const timeline = steps.length + // R26 批 B6 观测面:把「这一步之后 Cuu 被劝过什么」补进时间线。提醒不是模型的一步(是运行环境 + // 往对话里追加的一句话),所以不占步号、也不冒充步骤行——单独一行,带 data-spot-reminder-tier。 + const reminderRows = (stepNo: number) => runReminderRows(vm.reminders, stepNo, zh); + const renderedStepNos = new Set(steps.map((step) => step.step_no)); + // 对不上任何步骤行的提醒(trace 还没拉到那一步 / 数据不齐)补在末尾,绝不因为对不上就悄悄丢掉。 + const orphanRows = [ + ...new Set( + (vm.reminders ?? []) + .map((entry) => readAgentRunReminderFacts(entry)) + .filter((facts): facts is NonNullable => Boolean(facts) && !renderedStepNos.has(facts?.step_no ?? -1)) + .map((facts) => facts.step_no) + ) + ] + .sort((a, b) => a - b) + .map(reminderRows) + .join(""); + const timeline = steps.length || orphanRows ? `
${steps .map( (s) => - `
${escapeHtml(agentStepPhaseLabel(s.phase, zh))}
${escapeHtml(agentStepPublicSummary(s, zh))}
` + `
${escapeHtml(agentStepPhaseLabel(s.phase, zh))}
${escapeHtml(agentStepPublicSummary(s, zh))}
${reminderRows(s.step_no)}` ) - .join("")}
` + .join("")}${orphanRows}` : `

${spotlightViewsT(zh, "noStepsYet")}

`; return `
${header}${decideBtn}${timeline}
`; } diff --git a/packages/ui/src/agent-run/render.test.ts b/packages/ui/src/agent-run/render.test.ts index b7ec406b..aad84c40 100644 --- a/packages/ui/src/agent-run/render.test.ts +++ b/packages/ui/src/agent-run/render.test.ts @@ -158,7 +158,7 @@ test("B6: 英文界面渲英文提醒行;脏数据整行不渲,不把半截 { step_no: 1, tier: 1, repeats: 3, shape: "spiral" }, "not-an-object" ] - }; + } as unknown as AgentRunLiveVM; const en = renderAgentRunLive(withJunk, "web", { locale: "en-US" }); assert.equal(en.html.includes("Second reminder: Cuu repeated the same action for 5 steps"), true); From d85509b2ce2adcb01ac0f947045ba60bd613ee8b Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 02:58:56 +0800 Subject: [PATCH 12/23] =?UTF-8?q?chore(b6):=20openapi=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=20VM=20=E7=9A=84=20reminders=20=E6=95=B0?= =?UTF-8?q?=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentRunLive / replayTrace 两个响应 schema 都是 additionalProperties:false——不同步就等于 把这一列声明成非法字段。事件本身不进 openapi:/api/push/stream/* 一律只声明 text/event-stream 字符串,从不逐类枚举事件形状。 Co-Authored-By: Claude Fable 5.1 --- apps/api/src/openapi.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 43bc0b57..71cfc107 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -4521,6 +4521,23 @@ const agentRunUsageResponseSchema = { }, additionalProperties: false } as const; +// R26 批 B6 观测面:一次「重复动作提醒」的结构化事实(contracts 的 agentRunReminderFactsSchema)。 +// 只有事实、没有句子——中英文由前端按 payload 组装,所以这里也不该出现任何可读文案字段。 +// 同一形状同时是 SSE 事件 agent_run.reminded 的 data 主体(该事件不在 openapi 里描述: +// /api/push/stream/* 一律只声明 text/event-stream 字符串,从不逐类枚举事件形状)。 +const agentRunReminderResponseSchema = { + type: "object", + required: ["step_no", "tier", "repeats", "shape"], + properties: { + step_no: { type: "integer", minimum: 1 }, + tier: { type: "integer", enum: [1, 2] }, + repeats: { type: "integer", minimum: 1 }, + shape: { type: "string", enum: ["identical", "alternating"] }, + tool_id: { type: "string", minLength: 1 }, + tool_ids: { type: "array", minItems: 2, items: { type: "string", minLength: 1 } } + }, + additionalProperties: false +} as const; const agentRunLiveResponseSchema = { type: "object", required: [ @@ -4546,6 +4563,7 @@ const agentRunLiveResponseSchema = { budget_decision: agentRunBudgetDecisionResponseSchema, usage: agentRunUsageResponseSchema, trace: { type: "array", items: agentStepResponseSchema }, + reminders: { type: "array", items: agentRunReminderResponseSchema }, handoff: structuredHandoffResponseSchema, stream_href: { type: "string", minLength: 1 }, replay_href: { type: "string", minLength: 1 } @@ -4574,6 +4592,7 @@ const replayTraceResponseSchema = { properties: { run: agentRunResponseSchema, steps: { type: "array", items: agentStepResponseSchema }, + reminders: { type: "array", items: agentRunReminderResponseSchema }, evidence_refs: { type: "array", items: evidenceRefSchema }, snapshots: { type: "array", items: snapshotResponseSchema }, audit_logs: { type: "array", items: { type: "object", additionalProperties: true } }, From 76fe9a3aaf4680c7ddad2e5eb2f83950f8cf7573 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:00:28 +0800 Subject: [PATCH 13/23] =?UTF-8?q?docs(b6):=20Agent=20Note=20=E8=AE=B0?= =?UTF-8?q?=E3=80=8C=E9=87=8D=E5=A4=8D=E5=8A=A8=E4=BD=9C=E6=8F=90=E9=86=92?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E9=9D=A2=E3=80=8D=E7=9A=84=E5=8F=96=E8=88=8D?= =?UTF-8?q?=E4=B8=8E=E5=89=A9=E4=BD=99=E4=B8=80=E8=B7=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../2026-09-06-b6-reminder-events.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .agents/notes/implemented/2026-09-06-b6-reminder-events.md diff --git a/.agents/notes/implemented/2026-09-06-b6-reminder-events.md b/.agents/notes/implemented/2026-09-06-b6-reminder-events.md new file mode 100644 index 00000000..6490db78 --- /dev/null +++ b/.agents/notes/implemented/2026-09-06-b6-reminder-events.md @@ -0,0 +1,88 @@ +# 重复动作提醒的观测面(agent_run.reminded + 两端时间线) + +- Status: implemented +- Date: 2026-09-06 +- Owner: claude-code + +## Problem + +B6「重复动作先劝再断」(`.agents/notes/implemented/2026-09-05-repeat-tool-reminder-tiers.md`) +落地后,前两档提醒只存在于**模型可见的对话线**里:运行环境往对话里追加了一句「你已经连续 N 步…」, +模型看得见,人看不见。界面上唯一的迹象是这次运行多烧了几步,然后要么自己走出来、要么在第八步 +突然升级成 `agent_run.escalated`——中间那两次「劝过」既没有事件、也没有任何一行界面。 + +那份落地档案自己把这件事记成了否决项(「为提醒新增一个事件类型:要动 contracts 的事件枚举与 +openapi,范围外。提醒本身在对话线里,需要时再补事件」)。这一批就是来补的:Cuu 被劝了几次、 +劝的是什么,得能在时间线上读到。 + +## Decision + +**新增事件类型 `agent_run.reminded`,不复用 `agent_run.step` 加 `kind`。** 理由是形状: +`agent_run.compacting`(压缩)与 `agent_run.escalated`(升级)都是「运行环境介入了」这一类, +各自有独立事件类型;`agent_run.step` 的各种 `kind` 全是「模型这一步产出了什么」(thinking / +text / tool_call / stream_event / provider_retry)。提醒是前者不是后者——它恰好是升级的前两档, +和 `agent_run.escalated` 是同一条判定链上的三档,做成兄弟事件类型最贴既有形状。附带的好处是 +消费方能按事件名过滤(桌面 per-run SSE 就是显式事件名订阅,见下方 Consequences)。 + +**payload 只带事实,不带句子。** `agentRunReminderFactsSchema`(`contracts/domain/agent.ts`): +`step_no` / `tier`(1|2) / `repeats` / `shape`(identical|alternating) / `tool_id?` / `tool_ids?`。 +事件 data 在此之上加 `run_id`(+ 运行器补的 `work_item_id`)。中英文句子由两端各自按 locale 组装: +事件里塞中文句子会让英文界面无法本地化,也会把**模型可见**的提醒正文和**用户可见**的界面文案 +锁死在一起——那两份文本的读者不同,措辞标准也不同(一个要让模型改行为,一个要让人看懂发生了什么)。 + +`tool_ids` 只在一次重复涉及不止一个工具(交替形态两边工具不同)时出现,单工具只留 `tool_id`, +同一事实不存两份。两者都允许缺席(重复的那一步理论上可以没有工具调用),渲染层要能退回不提工具的说法。 + +**两套引擎同点同形状。** `loop.ts` 在提醒真正被 push 进 messages 的那一行发; +`loop2/config-builder.ts` 在 `shouldStopAfterTurn` 里暂存提醒的同一行发。两处都紧跟在这一步的 +`agent_run.step(control)` 之后(loop2 的 `shouldStopAfterTurn` 跑在 `turn_end` sink 之后), +因此在事件序列里落在同一个位置。`equivalence.test.ts` 的事件投影补上 `tier/repeats/shape/tool_ids` +逐条 deepEqual,另加两条专项用例(全同链路两条提醒 + 第三档只发 escalated;交替形态带两个工具名)。 + +**第三档不重复发。** tier 3 在两套引擎里都提前 return 到升级路径,走不到发事件这一行。 + +**时间线渲染:事实进 VM,句子进词典。** `AgentRunLiveVM` / `ReplayTraceVM` / `AgentRunTraceVM` +各加一个 additive optional 的 `reminders` 数组(缺席与空数组同义,存量客户端零回归)。 +`packages/ui`(实时页 + 回放页)与桌面 Spotlight 回放视图各自把提醒渲成**独立一行**,插在它所属 +那一步之后——提醒不是模型的一步,不占步号、不冒充 `AgentStep`,`stepCount` 也不变。 +对不上任何已渲染步骤的提醒补在时间线末尾:劝过就不许在界面上看不见。 + +文案是八条完整句子(档位 × 重复形态 × 有没有工具名)× 中英,不是拼半句——中英文的标点与语序 +不一样,拆成碎片拼装迟早拼出病句。工具名先过 `humanizeAgentToolId`(取 `__` 最后一段、下划线换 +空格)再按语言加引号(中文直角引号 + 顿号,英文弯引号 + 逗号),界面上不再可能出现 `run_command`、 +`mcp__gh__list_issues` 这类原始 id。仓内没有既有的「工具 id → 人话名」对照表,所以不做任何猜词映射; +将来有了对照表,由调用方先查表、查不到再退到这个去下划线版本。 + +## Alternatives considered + +- **复用 `agent_run.step` + `kind: "reminded"`,preview_text 直接写中文句子**:能白捡桌面 Cuu 卡片 + 的现成渲染(`cardFromAgentRunEvent` 拿 `preview_text` 当卡片正文)。否决两条理由:一是 + `packages/agent` 也在 `audit:ui-i18n` 的扫描面内,loop 里新写中文字面量过不了门(既有的 + `previewText` 要么是机器串如 `doom_loop`,要么已在基线里);二是英文界面会直接看到中文句子。 + 改成结构化 payload + 两端组词后,`previewText` 只留机器串 `repeat_reminder tier=N`。 +- **把提醒落成一条 `agent_run_steps` 行**,这样现有时间线(渲的是 `AgentStep[]`)不用改就能看见: + 要给 `agentStepPhases` 加枚举值 + 数据库迁移 + 运行器 recorder 改动,而且语义是错的——提醒不是 + 模型的一步,混进步号会让「跑了几步」这个数字开始撒谎。 +- **只加事件、不动 VM**,让前端从 SSE 流里自己攒:仓内没有任何一个前端保留 run 事件用于展示 + (web 回放页收到事件是触发整页重拉,桌面 run 卡是触发 `getAgentRun` 重取,Spotlight 回放视图走 + trace 游标轮询)。只发事件等于观测面停在「有一条 SSE,没人渲」。 +- **在事件 payload 里直接给渲染好的句子**:见 Decision——两端无法本地化,且会把模型可见文本与 + 界面文案绑死。 + +## Consequences + +- **事件枚举增一项**:`eventTypeSchema` 由 `Object.values(eventTypes)` 生成,新类型自动进契约面。 + `toCuuState` 映到 `thinking`(不是 `worried`)——前两档只是自救提示,运行仍在继续;`worried` + 是「需要人介入」的信号,那由第三档的 `agent_run.escalated` 负责。 +- **EventSource 是按事件名订阅的**:桌面 per-run 流的 `desktopCuuRunStreamEventNames` 必须显式登记 + 新事件名,漏登记就被静默丢弃(web 的 `live-stream-targets.ts` 早有同款教训)。已登记。 +- **openapi 的 `agentRunLive` / `replayTrace` 两个响应 schema 是 `additionalProperties: false`**, + 所以 `reminders` 必须同步声明,否则等于把这一列声明成非法字段。已同步。事件本身不进 openapi: + `/api/push/stream/*` 一律只声明 `text/event-stream` 字符串,从不逐类枚举事件形状。 +- **模型可见文本零变化**:`buildDoomLoopReminder` 一个字没动,`pnpm gen:expected` 跑完 + `packages/agent/expected/doom-loop-reminder.*` 无 diff。 +- **还差最后一跳(本批范围外)**:事件已发、VM 字段已开、两端渲染已就位,但把 + `agent_run.reminded` 攒进运行记录再喂进 VM 的那一段在 `apps/api` 的业务代码里 + (运行器的 run 记录 + `apps/api/src/pages/replay.ts` 的 `toAgentRunLiveVm` / replay VM 组装 + + trace 持久化)。补上之前,`reminders` 恒为缺席,时间线表现与改动前完全一致(additive optional + 的既有取舍)。这一段要连持久化一起做,否则 worker 重启后已发生的提醒会丢。 From d13189103a9959e55a4961a818837da7406ce057 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:14:08 +0800 Subject: [PATCH 14/23] =?UTF-8?q?fix(api):=20=E8=B7=AF=E7=94=B1=E5=B1=82?= =?UTF-8?q?=E5=89=A9=E4=BD=99=E7=9A=84=E3=80=8C=E4=BA=8B=E9=A1=B9=20/=20?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E3=80=8D=E6=8C=89=E8=AF=8D=E8=A1=A8=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E6=88=90=E3=80=8C=E4=BB=BB=E5=8A=A1=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 七个路由文件与指派/认领服务里 21 处用户可见的 HTTP 错误消息与提示还在说「事项」。 按 M8 的做法先把整句搬进 apps/api/src/services/locales.ts(中文 key 集 + 英文 satisfies), 调用点改读词典,再改词——就地改词会同时产生「基线条目已消失」与「新增违规」。 顺带统一两处不一致:权限类消息此前有带「你」和不带「你」两种写法,一律收成 「你没有权限……」;三处 404 共用的那句去掉尾巴上的英文「(Work item not found.)」, 中文消息里不再夹半句英文(词典的英文侧已经覆盖 en-US)。 「待审批的任务比这里显示的更多」原本是行内 zh/en 三目,搬进词典后仍按请求 locale 出话。 ui-i18n 棘轮基线只减:2795 → 2768(净减 27,零新增);禁词基线 49 条不变; pnpm gen:expected 无 diff(模型可见文本零变化)。 Co-Authored-By: Claude Fable 5.1 --- apps/api/src/agent-runs.test.ts | 2 +- apps/api/src/routes/agent-runs.ts | 5 +-- apps/api/src/routes/audit.ts | 7 ++-- apps/api/src/routes/pages.ts | 7 ++-- apps/api/src/routes/permissions.ts | 5 +-- apps/api/src/routes/proposals.ts | 19 +++++----- apps/api/src/routes/task-plans.ts | 7 ++-- apps/api/src/routes/workitems.ts | 3 +- apps/api/src/services/locales.ts | 36 +++++++++++++++++-- apps/api/src/services/work-item-assignment.ts | 27 ++++++++++---- scripts/dev/ui-i18n-baseline.json | 27 ++------------ 11 files changed, 86 insertions(+), 59 deletions(-) diff --git a/apps/api/src/agent-runs.test.ts b/apps/api/src/agent-runs.test.ts index 41917411..9145eba5 100644 --- a/apps/api/src/agent-runs.test.ts +++ b/apps/api/src/agent-runs.test.ts @@ -1380,7 +1380,7 @@ test("agent run enqueue reports malformed work item ids as missing work items be assert.equal(response.status, 404); assert.equal(body.error.code, "http_error"); - assert.equal(body.error.message, "没有找到这个事项。"); + assert.equal(body.error.message, "没有找到这个任务。"); assert.equal(enqueued, false); }); diff --git a/apps/api/src/routes/agent-runs.ts b/apps/api/src/routes/agent-runs.ts index a1d37f31..3c0c39df 100644 --- a/apps/api/src/routes/agent-runs.ts +++ b/apps/api/src/routes/agent-runs.ts @@ -41,6 +41,7 @@ import { type WorkItemService } from "../services/work-items.js"; import { readJsonObject } from "./json-body.js"; +import { serviceT } from "../services/locales.js"; import { isUuidParam } from "./uuid-param.js"; function auditLogRunId(detailJson: unknown) { @@ -256,7 +257,7 @@ export function createAgentRunRoutes(deps: AgentRunRoutesDependencies = {}) { } routes.post("/workitems/:id/agent-runs", createCurrentUserMiddleware(authSource), async (c) => { - const workItemId = requireUuidParam(c.req.param("id"), "没有找到这个事项。"); + const workItemId = requireUuidParam(c.req.param("id"), serviceT("zh-CN", "taskNotFound")); await assertCanReadWorkItem(workItemId, c.var.actor); await assertCanMutateWorkItem(workItemId, c.var.actor); const payload = startAgentRunRequestSchema.parse(await readJsonObject(c)); @@ -301,7 +302,7 @@ export function createAgentRunRoutes(deps: AgentRunRoutesDependencies = {}) { throw new AgentRunnerError( 409, "agent_run_not_startable", - "当前事项状态不能启动 AI,请刷新后再试。", + serviceT("zh-CN", "agentRunNotStartableStatus"), { work_item_status: kickoffResult?.status ?? null } ); } diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts index b124086e..802b6805 100644 --- a/apps/api/src/routes/audit.ts +++ b/apps/api/src/routes/audit.ts @@ -19,6 +19,7 @@ import { } from "../middleware/auth.js"; import { buildReplayManifestFacts, toAuditLogFact, toSnapshotVm } from "../pages/replay.js"; import { readJsonObject } from "./json-body.js"; +import { serviceT } from "../services/locales.js"; import { isUuidParam } from "./uuid-param.js"; import { getDefaultAuditStores } from "../services/audit-stores.js"; import { getDefaultAgentRunQueue } from "../workers/agent-runner.js"; @@ -52,7 +53,7 @@ export function createAuditRoutes(deps: AuditRoutesDependencies = {}) { // detailPage 已内置 admin 读全量(§4.4)与普通用户关系/活跃过滤。 async function assertCanReadWorkItem(workItemId: string, actor: AuthEnv["Variables"]["actor"]) { if (!workItems) { - throw new HTTPException(403, { message: "没有权限查看这个事项的审计。" }); + throw new HTTPException(403, { message: serviceT("zh-CN", "taskAuditViewForbidden") }); } try { await workItems.detailPage({ workItemId, actor }); @@ -66,7 +67,7 @@ export function createAuditRoutes(deps: AuditRoutesDependencies = {}) { async function assertCanMutateWorkItemArtifacts(workItemId: string, actor: AuthEnv["Variables"]["actor"]) { if (!workItems) { - throw new HTTPException(403, { message: "你没有权限修改这个事项的交付物。" }); + throw new HTTPException(403, { message: serviceT("zh-CN", "taskArtifactsEditForbidden") }); } try { await workItems.assertCanMutateArtifacts({ workItemId, actor }); @@ -83,7 +84,7 @@ export function createAuditRoutes(deps: AuditRoutesDependencies = {}) { // 路由 uuid 形参先校验:非 uuid 串原本直达 detailPage / listSnapshotsForWorkItem 的 uuid 列 → // PG 22P02 → 误报 500;与「合法但不存在」同样回 404,不泄露事项存在性。 if (!isUuidParam(workItemId)) { - throw new HTTPException(404, { message: "没有找到这个事项。" }); + throw new HTTPException(404, { message: serviceT("zh-CN", "taskNotFound") }); } await assertCanReadWorkItem(workItemId, c.var.actor); const snapshotRows = await snapshots.listSnapshotsForWorkItem(workItemId, { includeReverted: true }); diff --git a/apps/api/src/routes/pages.ts b/apps/api/src/routes/pages.ts index fd864f4a..2b4f94f1 100644 --- a/apps/api/src/routes/pages.ts +++ b/apps/api/src/routes/pages.ts @@ -41,6 +41,7 @@ import { } from "@workhub/db"; import { isUuidParam } from "./uuid-param.js"; +import { serviceT } from "../services/locales.js"; import { buildAttentionHomePage } from "../pages/attention.js"; import { buildAgentArmyDashboardPage } from "../pages/agent-army.js"; @@ -661,9 +662,7 @@ export function createPageRoutes(deps: PageRoutesDependencies = {}) { if (pending.counts.pending_total_capped === 1) { sourceWarnings.push({ source: "approvals", - message: locale === "en-US" - ? "There are more pending approvals than shown here — open Approvals for the full list." - : "待审批的事项比这里显示的更多——去审批页看完整清单。" + message: serviceT(locale, "approvalsCappedMore") }); } } catch { @@ -763,7 +762,7 @@ export function createPageRoutes(deps: PageRoutesDependencies = {}) { // 路由 uuid 形参先校验:非 uuid 串原本直达 detailPage 的 uuid 列 → PG 22P02 → 误报 500; // 与「合法但不存在」同样回 404,不泄露事项存在性。 if (!isUuidParam(c.req.param("id"))) { - throw new HTTPException(404, { message: "没有找到这个事项。" }); + throw new HTTPException(404, { message: serviceT("zh-CN", "taskNotFound") }); } try { const data = await workItems.detailPage({ diff --git a/apps/api/src/routes/permissions.ts b/apps/api/src/routes/permissions.ts index 54e12531..cb5bad08 100644 --- a/apps/api/src/routes/permissions.ts +++ b/apps/api/src/routes/permissions.ts @@ -25,6 +25,7 @@ import { type WorkItemService } from "../services/work-items.js"; import { readJsonObject } from "./json-body.js"; +import { serviceT } from "../services/locales.js"; import { isUuidParam } from "./uuid-param.js"; export type PermissionRoutesDependencies = { @@ -48,7 +49,7 @@ export function createPermissionRoutes(deps: PermissionRoutesDependencies = {}) async function assertCanReadWorkItem(workItemId: string, actor: AuthEnv["Variables"]["actor"]) { const workItems = deps.workItems === false ? undefined : deps.workItems ?? getDefaultWorkItemService(); if (!workItems) { - throw new HTTPException(403, { message: "你没有权限查看这个事项。" }); + throw new HTTPException(403, { message: serviceT("zh-CN", "taskViewForbidden") }); } try { await workItems.detailPage({ workItemId, actor }); @@ -107,7 +108,7 @@ export function createPermissionRoutes(deps: PermissionRoutesDependencies = {}) } const payload = createApprovalRequestSchema.parse(rawPayload); if (!payload.work_item_id && payload.routed_to_user_id && payload.routed_to_user_id !== c.var.currentUser.id) { - throw new HTTPException(403, { message: "无事项上下文的审批只能路由给自己。" }); + throw new HTTPException(403, { message: serviceT("zh-CN", "approvalWithoutTaskContext") }); } // 防止任意用户把待审批塞进别人的收件箱:非管理员只能把 /ask 路由给自己。 // 服务端的合法升级(agent-runner → 路由给工作项负责人)走 service.createApproval 直连,不经此 HTTP 路由。 diff --git a/apps/api/src/routes/proposals.ts b/apps/api/src/routes/proposals.ts index f28a9b5e..5643bf7f 100644 --- a/apps/api/src/routes/proposals.ts +++ b/apps/api/src/routes/proposals.ts @@ -65,6 +65,7 @@ import { getDefaultWorkItemStatusKickoff, type WorkItemStatusKickoff } from "./a import { pageT } from "../pages/i18n.js"; import { parseOutputContract } from "../pages/output-contract.js"; import { readJsonObject } from "./json-body.js"; +import { serviceT } from "../services/locales.js"; import { isUuidParam } from "./uuid-param.js"; type TaskPlanRouteDispatcher = { @@ -399,7 +400,9 @@ const proposalActionCopy: Record string }; var: { actor: AuthActor } }) { if (!workItems) { - throw new HTTPException(403, { message: "没有权限修改这个事项。" }); + throw new HTTPException(403, { message: serviceT("zh-CN", "taskEditForbidden") }); } const planId = requirePlanId(c.req.param("planId")); const workspaceId = c.var.actor.workspaceId; @@ -159,7 +160,7 @@ export function createTaskPlanRoutes(deps: TaskPlanRoutesDependencies = {}) { routes.post("/workitems/:id/task-plan", createCurrentUserMiddleware(authSource), async (c) => { if (!workItems) { - throw new HTTPException(403, { message: "没有权限修改这个事项。" }); + throw new HTTPException(403, { message: serviceT("zh-CN", "taskEditForbidden") }); } const workItemId = requireWorkItemId(c.req.param("id")); await workItems.assertCanMutateArtifacts({ workItemId, actor: c.var.actor }); diff --git a/apps/api/src/routes/workitems.ts b/apps/api/src/routes/workitems.ts index 35030b1c..7bfaa941 100644 --- a/apps/api/src/routes/workitems.ts +++ b/apps/api/src/routes/workitems.ts @@ -32,6 +32,7 @@ import { type WorkItemCommentService } from "../services/work-item-comments.js"; import { readJsonObject } from "./json-body.js"; +import { serviceT } from "../services/locales.js"; import { isUuidParam } from "./uuid-param.js"; export type WorkItemRoutesDependencies = { @@ -49,7 +50,7 @@ function handleWorkItemError(error: unknown): never { // 同样的 404(WorkItemServiceError,经 app.onError 收口),不泄露存在性。 function requireWorkItemId(value: string): string { if (!isUuidParam(value)) { - throw new WorkItemServiceError(404, "not_found", "没有找到这个事项。(Work item not found.)"); + throw new WorkItemServiceError(404, "not_found", serviceT("zh-CN", "taskNotFound")); } return value; } diff --git a/apps/api/src/services/locales.ts b/apps/api/src/services/locales.ts index 38244f98..e74f27a4 100644 --- a/apps/api/src/services/locales.ts +++ b/apps/api/src/services/locales.ts @@ -1,4 +1,6 @@ -// 服务层产出的用户可见文案(错误消息、升级说明、审阅结论)的单一来源。 +// API 层产出的用户可见文案(错误消息、升级说明、审阅结论)的单一来源。 +// 起于服务层,R26 F3 起路由层的 HTTP 错误消息也读这一份——同一句「没有找到这个任务。」 +// 此前在 routes 与 services 各写一遍,两处改词必然漂移。 // // 形状照 packages/ui/src/locales.ts:中文对象是 key 集事实源,英文对象用 // `satisfies Record` 做编译期对齐。 @@ -123,7 +125,22 @@ const zh = { taskUntitled: "(未命名任务)", turnWorkItemCreated: "建任务:{title}", turnWorkItemCreateFailed: "建任务失败", - runRecoveredRequeued: "这次执行已恢复,正在重新排队。" + runRecoveredRequeued: "这次执行已恢复,正在重新排队。", + + // R26 F3:路由层剩余的「事项 / 工单」按 glossary §11 收口成「任务」。 + // 权限类统一成「你没有权限……」一种口吻——此前 routes 里同一句话有带「你」和不带「你」两种写法。 + taskAuditViewForbidden: "你没有权限查看这个任务的审计。", + taskArtifactsEditForbidden: "你没有权限修改这个任务的交付物。", + taskAssignForbidden: "你没有权限指派这个任务。", + taskClaimForbidden: "你现在还不能认领这个任务。", + taskWorkspaceMissingAssign: "这个任务还没有归属工作区,暂时无法指派。", + taskWorkspaceMissingClaim: "这个任务还没有归属工作区,暂时无法认领。", + taskNotClaimable: "这个任务已被认领或已不在可认领状态。", + assigneeDirectoryUnavailable: "成员目录暂时无法校验,任务没有被指派。", + agentRunNotStartableStatus: "当前任务状态不能启动 AI,请刷新后再试。", + approvalWithoutTaskContext: "无任务上下文的审批只能路由给自己。", + approvalsCappedMore: "待审批的任务比这里显示的更多——去审批页看完整清单。", + planDispatchAuditableFlow: "后续执行会继续进入可审计的任务流。" } as const; const en = { @@ -229,7 +246,20 @@ const en = { taskUntitled: "(untitled task)", turnWorkItemCreated: "Task created: {title}", turnWorkItemCreateFailed: "Couldn't create the task", - runRecoveredRequeued: "This run recovered and is back in the queue." + runRecoveredRequeued: "This run recovered and is back in the queue.", + + taskAuditViewForbidden: "You don't have permission to view this task's audit trail.", + taskArtifactsEditForbidden: "You don't have permission to edit this task's deliverables.", + taskAssignForbidden: "You don't have permission to assign this task.", + taskClaimForbidden: "You can't claim this task yet.", + taskWorkspaceMissingAssign: "This task doesn't belong to a workspace yet, so it can't be assigned.", + taskWorkspaceMissingClaim: "This task doesn't belong to a workspace yet, so it can't be claimed.", + taskNotClaimable: "This task is already claimed, or it is no longer claimable.", + assigneeDirectoryUnavailable: "The member directory couldn't be checked just now, so the task wasn't assigned.", + agentRunNotStartableStatus: "This task's current status can't start AI. Refresh and try again.", + approvalWithoutTaskContext: "An approval with no task context can only be routed to you.", + approvalsCappedMore: "There are more pending approvals than shown here — open Approvals for the full list.", + planDispatchAuditableFlow: "Execution will continue in the auditable task flow." } as const satisfies Record; export type ServiceCopyKey = keyof typeof zh; diff --git a/apps/api/src/services/work-item-assignment.ts b/apps/api/src/services/work-item-assignment.ts index 2dc5c6f5..0a4a4305 100644 --- a/apps/api/src/services/work-item-assignment.ts +++ b/apps/api/src/services/work-item-assignment.ts @@ -30,6 +30,7 @@ import { } from "@workhub/db"; import type { AuthActor } from "../middleware/auth.js"; +import { serviceT } from "./locales.js"; import { WorkItemServiceError } from "./work-items.js"; export type AssignmentRole = (typeof ASSIGNMENT_ROLES)[number]; @@ -110,7 +111,7 @@ export function createWorkItemAssignmentService( async function requireAccessRow(workItemId: string): Promise { const row = await deps.workItems.findWorkItemAccessRecord(workItemId); if (!row) { - throw new WorkItemServiceError(404, "not_found", "没有找到这个事项。(Work item not found.)"); + throw new WorkItemServiceError(404, "not_found", serviceT("zh-CN", "taskNotFound")); } return row; } @@ -120,17 +121,25 @@ export function createWorkItemAssignmentService( const accessRow = await requireAccessRow(workItemId); const allowed = canManageWorkItemAssignees(accessRow, permissionUser(actor), permissionScope(actor)); if (!allowed) { - throw new WorkItemServiceError(403, "forbidden", "你没有权限指派这个事项。"); + throw new WorkItemServiceError(403, "forbidden", serviceT("zh-CN", "taskAssignForbidden")); } // 被指派人必须是这个工作项所属工作区的 active 成员——否则会把工作指派给不在本租户的人。 const workspaceId = accessRow.workspaceId ?? accessRow.project?.workspaceId ?? null; if (!workspaceId) { - throw new WorkItemServiceError(409, "work_item_workspace_missing", "这个事项还没有归属工作区,暂时无法指派。"); + throw new WorkItemServiceError( + 409, + "work_item_workspace_missing", + serviceT("zh-CN", "taskWorkspaceMissingAssign") + ); } // MRG-13:双查对齐 approvals delegate——membership 不查 users.deletedAt,已停用账号仍可能留着 // active 成员行;先过 findActiveById(只回活跃账号),把「指派给已停用账号」挡在 422。 if (!deps.users) { - throw new WorkItemServiceError(503, "assign_user_directory_unavailable", "成员目录暂时无法校验,事项没有被指派。"); + throw new WorkItemServiceError( + 503, + "assign_user_directory_unavailable", + serviceT("zh-CN", "assigneeDirectoryUnavailable") + ); } const assignee = await deps.users.findActiveById(assigneeUserId); if (!assignee) { @@ -163,14 +172,18 @@ export function createWorkItemAssignmentService( const accessRow = await requireAccessRow(workItemId); const allowed = canClaimWorkItem(accessRow, permissionUser(actor), permissionScope(actor)); if (!allowed) { - throw new WorkItemServiceError(403, "forbidden", "你现在还不能认领这个事项。"); + throw new WorkItemServiceError(403, "forbidden", serviceT("zh-CN", "taskClaimForbidden")); } // R21 加固(A9 兜底口径统一):与 assign 一致——workspaceId 缺失时兜到项目侧,而不是 actor 侧。 // 旧写法兜 actor.workspaceId 会让「工作项/项目都没归属工作区」的行被写进 actor 的工作区(跨租户 // 语义污染),且与 claimOwnerlessWorkItem 的 workspace CAS 谓词对不上。 const workspaceId = accessRow.workspaceId ?? accessRow.project?.workspaceId ?? null; if (!workspaceId) { - throw new WorkItemServiceError(409, "work_item_workspace_missing", "这个事项还没有归属工作区,暂时无法认领。"); + throw new WorkItemServiceError( + 409, + "work_item_workspace_missing", + serviceT("zh-CN", "taskWorkspaceMissingClaim") + ); } const claimed = await deps.workItems.claimOwnerlessWorkItem({ workItemId, @@ -180,7 +193,7 @@ export function createWorkItemAssignmentService( }); if (!claimed) { // CAS 落空:已被别人认领 / 已离开可认领状态(并发或过期点击)——不覆盖既有认领人。 - throw new WorkItemServiceError(409, "work_item_not_claimable", "这个事项已被认领或已不在可认领状态。"); + throw new WorkItemServiceError(409, "work_item_not_claimable", serviceT("zh-CN", "taskNotClaimable")); } await writeAuditBestEffort({ actorKind: "human", diff --git a/scripts/dev/ui-i18n-baseline.json b/scripts/dev/ui-i18n-baseline.json index 6ff233ae..54968fdc 100644 --- a/scripts/dev/ui-i18n-baseline.json +++ b/scripts/dev/ui-i18n-baseline.json @@ -65,8 +65,6 @@ "apps/api/src/routes/agent-runs.ts": { "AI 启动状态写入失败,请稍后重试。": 1, "你没有权限查看这次 AI 执行。": 1, - "当前事项状态不能启动 AI,请刷新后再试。": 1, - "没有找到这个事项。": 1, "没有找到这次 AI 执行。": 5 }, "apps/api/src/routes/ai-settings.ts": { @@ -79,13 +77,10 @@ "这条审批不在你的待处理列表里。": 1 }, "apps/api/src/routes/audit.ts": { - "你没有权限修改这个事项的交付物。": 1, "快照还原失败。": 1, "没有找到可回滚的快照。": 2, - "没有找到这个事项。": 1, "没有找到这次 AI 执行。": 1, "没有找到这次 AI 执行的本地工作目录,无法还原文件。": 1, - "没有权限查看这个事项的审计。": 1, "这个快照不属于这次 AI 执行。": 1 }, "apps/api/src/routes/auth.ts": { @@ -219,8 +214,6 @@ "变更评审暂时加载失败。请打开项目页或稍后重试。": 1, "审批待办暂时加载失败。请打开审批页或稍后重试。": 2, "审批待办超过仪表盘扫描上限;这里显示的是可见下限,请打开审批页查看全部。": 1, - "待审批的事项比这里显示的更多——去审批页看完整清单。": 1, - "没有找到这个事项。": 1, "没有找到这个变更申请。": 2, "没有找到这个桌面工作台。": 1, "没有找到这个网盘文件。": 1, @@ -233,10 +226,8 @@ }, "apps/api/src/routes/permissions.ts": { "你只能把审批请求路由给自己。": 1, - "你没有权限查看这个事项。": 1, "只有管理员可以查看或修改权限策略。": 1, - "找不到这条权限策略。": 1, - "无事项上下文的审批只能路由给自己。": 1 + "找不到这条权限策略。": 1 }, "apps/api/src/routes/plugins.ts": { "没有找到这个插件。": 1 @@ -269,7 +260,6 @@ "任务计划已经批准,但子任务启动失败,请稍后刷新后重试。": 1, "单个 AI 执行暂时不可用。": 1, "变更已采纳,但合并快照记录不完整,请勿重试,请联系管理员核查。": 1, - "后续执行会继续进入可审计的事项流。": 1, "已打回": 1, "已批准": 1, "已改为单个 AI 直接执行,计划草稿已取消。": 1, @@ -284,12 +274,9 @@ "打回原因:": 1, "接下来可以把这份交付物变更采纳到正式版本。": 1, "查看打回理由。": 1, - "没有找到这个事项。": 2, "没有找到这个变更申请。": 2, "没有找到这个合并建议。": 2, "没有找到这条变更记录。": 2, - "没有权限修改这个事项。": 2, - "没有权限查看这个事项。": 2, "用户选择先不拆,直接单个 AI 执行。": 1, "被打回:": 1, "计划保持已批准状态,需要时可以再手动开始。": 1, @@ -313,9 +300,7 @@ }, "apps/api/src/routes/task-plans.ts": { "恢复失败,请稍后再点一次。": 1, - "没有找到这个事项。": 1, "没有找到这个任务计划。": 3, - "没有权限修改这个事项。": 2, "这个军团不在暂停状态,无法恢复。刷新看看最新状态。": 1, "这个军团当前不在派发中,无法暂停。刷新看看最新状态。": 1 }, @@ -330,7 +315,6 @@ }, "apps/api/src/routes/workitems.ts": { "正式交付物文件已不存在,请重新生成或查看历史记录。": 4, - "没有找到这个事项。(Work item not found.)": 1, "没有找到这份正式交付物。(Deliverable not found.)": 1, "这类正式交付物暂不支持在线预览,请下载查看。": 1 }, @@ -1065,15 +1049,8 @@ "需要已登录的真人用户才能访问个人资料。": 1 }, "apps/api/src/services/work-item-assignment.ts": { - "你没有权限指派这个事项。": 1, - "你现在还不能认领这个事项。": 1, - "成员目录暂时无法校验,事项没有被指派。": 1, - "没有找到这个事项。(Work item not found.)": 1, "被指派人不存在或账号已停用。": 1, - "被指派人不是这个工作区的成员。": 1, - "这个事项已被认领或已不在可认领状态。": 1, - "这个事项还没有归属工作区,暂时无法指派。": 1, - "这个事项还没有归属工作区,暂时无法认领。": 1 + "被指派人不是这个工作区的成员。": 1 }, "apps/api/src/services/work-items.ts": { "AI 材料分析失败,尚未生成真实澄清反问。": 2, From cab4dcd15f5da03a7533cd7ac9afacc8b0a4d4d5 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:21:10 +0800 Subject: [PATCH 15/23] =?UTF-8?q?feat(b6b):=20agent=5Fruns=20=E5=8A=A0=20r?= =?UTF-8?q?eminders=5Fjson=EF=BC=8C=E9=87=8D=E5=A4=8D=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E6=8F=90=E9=86=92=E8=BF=9B=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B6 已经让「先劝再断」的前两档发出 agent_run.reminded、两端时间线也会渲这一行, 但那条事件只活在 SSE 流里——运行记录不留、库里不落,回放页(读的是库)永远看不到。 迁移 0075 给 agent_runs 加一列可空 jsonb(null 与空数组同义,既有行不回填), 仓储的 insert/update 一并读写。提醒不落 agent_steps:它不是模型的一步,占了步号 会让「跑了几步」这个数字开始撒谎。 Co-Authored-By: Claude Fable 5.1 --- .../migrations/0075_agent_run_reminders.sql | 14 +++++ packages/db/migrations/meta/_journal.json | 9 +++- packages/db/src/agent-runs.test.ts | 51 ++++++++++++++++++ packages/db/src/repositories/agent-runs.ts | 5 ++ packages/db/src/schema.test.ts | 54 +++++++++++++++---- packages/db/src/schema/core.ts | 5 ++ 6 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 packages/db/migrations/0075_agent_run_reminders.sql diff --git a/packages/db/migrations/0075_agent_run_reminders.sql b/packages/db/migrations/0075_agent_run_reminders.sql new file mode 100644 index 00000000..34d5e5a6 --- /dev/null +++ b/packages/db/migrations/0075_agent_run_reminders.sql @@ -0,0 +1,14 @@ +-- R26 批 B6b(重复动作提醒的持久化):agent_runs 加 reminders_json。 +-- +-- 修的裂缝:B6 已经让「先劝再断」的前两档发出 agent_run.reminded 事件、两端时间线也已经会渲这一行, +-- 但那条事件只存在于 SSE 流里——运行记录不留、库里不落。于是回放页(读的是库)永远看不到提醒, +-- worker 重启/换人接手后连正在跑的这次运行都不知道自己被劝过几次。 +-- +-- 存法与同表的 handoff_json / budget_decision_json 一致:一列 jsonb,装一个数组,每个元素是 +-- contracts 的 agentRunReminderFactsSchema(step_no/tier/repeats/shape/tool_id?/tool_ids?)。 +-- 提醒**不**落 agent_steps:它不是模型的一步,占了步号会让「跑了几步」这个数字开始撒谎 +-- (B6 的落地档案里已把这条记成否决项)。 +-- +-- 可空、无默认值:null = 这次运行没被劝过,与空数组同义,读回时一律退回「缺席」。既有行不用回填。 +-- 全 additive:ADD COLUMN IF NOT EXISTS,migration-audit replay 整链重跑安全(同 0070/0071/0074 约定)。 +ALTER TABLE "agent_runs" ADD COLUMN IF NOT EXISTS "reminders_json" jsonb; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index de1e5d02..e0045c60 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -458,7 +458,7 @@ "breakpoints": true }, { -"idx": 65, + "idx": 65, "version": "7", "when": 1783924000000, "tag": "0065_project_plan_drafts", @@ -526,6 +526,13 @@ "when": 1783929006000, "tag": "0074_plugin_trust_level", "breakpoints": true + }, + { + "idx": 75, + "version": "7", + "when": 1783929007000, + "tag": "0075_agent_run_reminders", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/agent-runs.test.ts b/packages/db/src/agent-runs.test.ts index 4f35e025..b6d5f913 100644 --- a/packages/db/src/agent-runs.test.ts +++ b/packages/db/src/agent-runs.test.ts @@ -48,6 +48,7 @@ function runRow(id: string, taskPlanItemId: string): AgentRunRow { outcomeReason: null, handoffMd: null, handoffJson: null, + remindersJson: null, workdirRef: null, claimedBy: null, claimedAt: null, @@ -186,3 +187,53 @@ test("INF-05: non-terminal or unfenced updateRun writes keep the old predicates" const unfencedQuery = queries.at(-1); assert.deepEqual(queryParamValues(unfencedQuery?.where), [runId]); }); + +// R26 批 B6b(重复动作提醒的持久化):agent_runs.reminders_json 的写入侧。钉两件事—— +// (a) 调用方带了 remindersJson 时,INSERT / UPDATE 都真的把它写进这一列(漏掉就等于提醒只活在内存里, +// worker 一换人、回放页一打开就什么都看不到); +// (b) 调用方没带时,UPDATE 的 set 里**不出现**这个键——drizzle 跳过 undefined,已存的提醒不会被 +// 一次无关的进度写清成 null。 +test("R26-B6b agent run repository writes reminders_json on insert and update, and leaves it alone when absent", async () => { + const runId = "40000000-0000-4000-8000-0000000000e1"; + const reminders = [ + { step_no: 3, tier: 1, repeats: 3, shape: "identical", tool_id: "read_file" } + ]; + const base = { + runId, + workspaceId, + workItemId, + actorUserId, + mode: "worker" as const, + status: "running" as const, + title: "Repeat-reminded run", + model: "deepseek-v4-flash", + budget: { maxSteps: 15, totalTimeoutS: 300, maxTokens: 120000, maxCostCny: "5" }, + budgetDecisionJson: {}, + usage: { stepsUsed: 3, tokenIn: 3, tokenOut: 3, estimatedCostCny: "0" }, + createdAt: now, + updatedAt: now + }; + const row = runRow(runId, "81000000-0000-4000-8000-0000000000e1"); + const { db, queries } = createQueryRecorder([[row], [row], [row]]); + const repository = createAgentRunRepository(db); + + await repository.createRun({ ...base, remindersJson: reminders }); + const insert = queries.at(-1); + assert.equal(insert?.operation, "insert"); + assert.equal(insert?.targetTable, agentRuns); + assert.deepEqual((insert?.valuesValue as Record).remindersJson, reminders); + + await repository.updateRun({ ...base, remindersJson: reminders }); + const update = queries.at(-1); + assert.equal(update?.operation, "update"); + assert.deepEqual((update?.setValue as Record).remindersJson, reminders); + + await repository.updateRun(base); + const untouched = queries.at(-1); + assert.equal( + "remindersJson" in (untouched?.setValue as Record) + && (untouched?.setValue as Record).remindersJson !== undefined, + false, + "没带提醒的进度写不许把已存的 reminders_json 清空" + ); +}); diff --git a/packages/db/src/repositories/agent-runs.ts b/packages/db/src/repositories/agent-runs.ts index 1b458726..824f726e 100644 --- a/packages/db/src/repositories/agent-runs.ts +++ b/packages/db/src/repositories/agent-runs.ts @@ -69,6 +69,9 @@ export type AgentRunForPersistence = { outcomeReason?: string; handoffMd?: string; handoffJson?: Record; + // R26 批 B6b:重复动作提醒(agent_run.reminded 的事实部分)。缺席 = 这次运行没被劝过; + // 调用方只在非空时带上,因此 undefined 在 UPDATE 里天然被 drizzle 跳过,不会把已存的一列清成 null。 + remindersJson?: Record[]; workdirRef?: string; createdAt: Date; updatedAt: Date; @@ -202,6 +205,7 @@ function runInsertValues(run: AgentRunForPersistence): typeof agentRuns.$inferIn outcomeReason: run.outcomeReason, handoffMd: run.handoffMd, handoffJson: run.handoffJson, + remindersJson: run.remindersJson, workdirRef: run.workdirRef, startedAt: runningStartedAt(run), finishedAt: terminalFinishedAt(run), @@ -232,6 +236,7 @@ function runUpdateValues(run: AgentRunForPersistence): Partial { +test("migration journal ends with 0075 agent_run_reminders", () => { const journal = JSON.parse( readFileSync(new URL("../migrations/meta/_journal.json", import.meta.url), "utf8") ) as { @@ -709,31 +709,67 @@ test("migration journal ends with 0074 plugin_trust_level", () => { when: finalEntry.when }, { - // R26 X:0074(plugin_trust_level,when=1783929006000)接在 0073(mcp_servers,when=1783929005000) - // 之后,journal 收于 0074,when 严格递增。 - idx: 74, + // R26 B6b:0075(agent_run_reminders,when=1783929007000)接在 0074(plugin_trust_level, + // when=1783929006000)之后,journal 收于 0075,when 严格递增。 + idx: 75, version: "7", - tag: "0074_plugin_trust_level", + tag: "0075_agent_run_reminders", breakpoints: true, - when: 1783929006000 + when: 1783929007000 } ); - // when 严格递增——0069 → 0070 → 0071 → 0072 → 0073 → 0074 的时间戳必须依次增大。 + // when 严格递增——0069 → 0070 → 0071 → 0072 → 0073 → 0074 → 0075 的时间戳必须依次增大。 const entry0069 = journal.entries.find((entry) => entry.tag === "0069_event_outbox"); const entry0070 = journal.entries.find((entry) => entry.tag === "0070_proactive_intent_delivering"); const entry0071 = journal.entries.find((entry) => entry.tag === "0071_event_outbox_failed"); const entry0072 = journal.entries.find((entry) => entry.tag === "0072_plugins"); const entry0073 = journal.entries.find((entry) => entry.tag === "0073_mcp_servers"); - assert.ok(entry0069 && entry0070 && entry0071 && entry0072 && entry0073 && finalEntry); + const entry0074 = journal.entries.find((entry) => entry.tag === "0074_plugin_trust_level"); + assert.ok(entry0069 && entry0070 && entry0071 && entry0072 && entry0073 && entry0074 && finalEntry); assert.equal(entry0070.idx, 70); assert.equal(entry0071.idx, 71); assert.equal(entry0072.idx, 72); assert.equal(entry0073.idx, 73); + assert.equal(entry0074.idx, 74); assert.ok(entry0070.when > entry0069.when); assert.ok(entry0071.when > entry0070.when); assert.ok(entry0072.when > entry0071.when); assert.ok(entry0073.when > entry0072.when); - assert.ok(finalEntry.when > entry0073.when); + assert.ok(entry0074.when > entry0073.when); + assert.ok(finalEntry.when > entry0074.when); +}); + +/** + * R26 批 B6b:重复动作提醒的持久化列。钉三件事——(a) 迁移 replay 安全(ADD COLUMN IF NOT EXISTS); + * (b) 这一列**可空且无默认值**:null = 没被劝过,与空数组同义,既有行不用回填; + * (c) drizzle schema 与迁移同步,且提醒**没有**混进 agent_steps(它不是模型的一步,占了步号会让 + * 「跑了几步」撒谎——B6 的落地档案已把这条记成否决项)。 + */ +test("R26-B6b migration 0075 adds a nullable replay-safe reminders_json to agent_runs", () => { + const migrationUrl = new URL("../migrations/0075_agent_run_reminders.sql", import.meta.url); + assert.equal(existsSync(migrationUrl), true, "missing migration 0075_agent_run_reminders.sql"); + const migration = readFileSync(migrationUrl, "utf8"); + assert.match( + migration, + /ALTER TABLE "agent_runs" ADD COLUMN IF NOT EXISTS "reminders_json" jsonb;/u, + "整链重跑安全要求 ADD COLUMN IF NOT EXISTS" + ); + assert.equal(/NOT NULL/u.test(migration), false, "reminders_json 必须可空:null 与空数组同义"); + assert.equal(/DEFAULT/u.test(migration), false, "reminders_json 不给默认值,既有行不回填"); + assert.equal(/CONCURRENTLY/u.test(migration), false, "迁移里不许有 CONCURRENTLY(事务里跑不了)"); + + const reminders = getTableConfig(agentRuns).columns.find((column) => column.name === "reminders_json"); + assert.ok(reminders, "drizzle schema 少了 reminders_json 列"); + assert.equal(reminders.notNull, false); + assert.equal(reminders.default, undefined); + assert.equal(reminders.getSQLType(), "jsonb"); + + // 提醒不落 agent_steps:那张表只有六个标量列,多一列就等于给提醒发了个步号。 + assert.equal( + tableColumnNames(agentSteps).some((name) => /remind/u.test(name)), + false, + "提醒不是模型的一步,不许混进 agent_steps" + ); }); /** diff --git a/packages/db/src/schema/core.ts b/packages/db/src/schema/core.ts index 7681dbc5..0e6d189a 100644 --- a/packages/db/src/schema/core.ts +++ b/packages/db/src/schema/core.ts @@ -1817,6 +1817,11 @@ export const agentRuns = pgTable( outcomeReason: varchar("outcome_reason", { length: 256 }), handoffMd: text("handoff_md"), handoffJson: jsonb("handoff_json").$type(), + // R26 批 B6b:这次运行里「重复动作被劝过几次、劝的是什么」。每个元素是 contracts 的 + // agentRunReminderFactsSchema(step_no/tier/repeats/shape/tool_id?/tool_ids?)——只有事实没有句子, + // 中英文由两端各自按 locale 组装。null 与空数组同义(没被劝过),读回一律退回缺席。 + // 不落 agent_steps:提醒不是模型的一步,混进步号会让「跑了几步」撒谎(见 0075 迁移注释)。 + remindersJson: jsonb("reminders_json").$type(), workdirRef: varchar("workdir_ref", { length: 512 }), claimedBy: varchar("claimed_by", { length: 128 }), claimedAt: timestampTz("claimed_at"), From f00ab0818e0c10be7b8de7b55ac4543dcc08c0c7 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:21:58 +0800 Subject: [PATCH 16/23] =?UTF-8?q?feat(desktop):=20MCP=20=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E6=8C=89=E7=A8=B3=E5=AE=9A=E9=94=99=E8=AF=AF=E7=A0=81=E8=AF=B4?= =?UTF-8?q?=E6=B8=85=E8=BF=9E=E4=B8=8D=E4=B8=8A=E7=9A=84=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=EF=BC=8C=E7=AE=A1=E7=90=86=E5=91=98=E9=97=A8=E6=8D=A2=E6=88=90?= =?UTF-8?q?=20mcp=5Fservers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M7 的 Note 记着一条给不出的话:握手超时时界面只能说「连不上 +(英文诊断)」, 因为契约里没有字段把 M3 早就算出来的码带出来。M8 把 last_error_code 加到了三个读形状上, 这里消费它:九条码各一句人话,与网页只读清单逐字同句(同一台服务器在两端只有一种说法)。 码到词典键的映射用 satisfies Record 全表覆盖——契约新增一条码时 这里编译不过,而不是悄悄落到兜底句上。连接快照上的码优先于行上的码(前者来自这个进程最近 一次握手);码缺席时(重启之后这个进程不记得那次失败)仍说通用的那一句,不编一个不知道的原因。 重连预算耗尽时两句都说:为什么失败 + 为什么不再自动重试,两件事的下一步动作不同。 管理员门从 M7 借来的 vm.plugins !== undefined 换成 vm.mcp_servers !== undefined (M8 已给 settings VM 加了这个字段):一个只有插件管理权的身份不会再看到一个必然 403 的分区。 渲染与拉清单两处一起换。 Co-Authored-By: Claude Fable 5.1 --- .../src/spotlight/views/locales.ts | 20 ++++ .../src/spotlight/views/settings-mcp.test.ts | 103 +++++++++++++++++- .../src/spotlight/views/settings-mcp.ts | 47 +++++++- .../src/spotlight/views/settings.test.ts | 36 +++++- .../src/spotlight/views/settings.ts | 15 +-- 5 files changed, 208 insertions(+), 13 deletions(-) diff --git a/apps/desktop-webview/src/spotlight/views/locales.ts b/apps/desktop-webview/src/spotlight/views/locales.ts index 29b8f88e..826afa0d 100644 --- a/apps/desktop-webview/src/spotlight/views/locales.ts +++ b/apps/desktop-webview/src/spotlight/views/locales.ts @@ -316,6 +316,18 @@ const zh = { mcpErrRemoteExec: "npx 这类启动器每次启动都会从网上下载并执行代码,我们不这么起服务器。请先在这台机器上把它装好,再填装好之后的可执行文件路径。", mcpErrSecretRefOutOfScope: "密钥引用只能指向服务端上带指定前缀的变量名。", mcpErrValidation: "有字段填得不对,检查后重填。", + // R26 F3:连接/会话失败的稳定错误码各一句人话(契约 mcpServerErrorCodeSchema)。 + // 与网页只读清单(packages/ui/.../route-components-copy.ts 的 settings.mcpErr*)逐字同句: + // 同一台服务器在两端只有一种说法。第九条码 mcp_connect_failed 是「说不出原因」的兜底, + // 用的就是既有的 mcpConnectFailedHint。 + mcpFailCallTimeout: "上一次调用等太久没有结果。", + mcpFailExited: "这台服务器自己退出了。", + mcpFailHandshakeTimeout: "服务器没有在规定时间内应答。", + mcpFailNotRunning: "这台服务器当前没在运行。", + mcpFailProtocol: "服务器的应答读不了。", + mcpFailProtocolVersion: "双方的协议版本对不上。", + mcpFailServerError: "服务器拒绝了这次连接。", + mcpFailSpawn: "这台服务器没能启动。", mcpFieldArgs: "启动参数,一行一个(可不填)", mcpFieldCommand: "启动命令:这台机器上的可执行文件,完整路径最稳", mcpFieldCwd: "工作目录(可不填)", @@ -935,6 +947,14 @@ const en = { "Launchers like npx download and run code from the network on every start, so we don't launch servers that way. Install it on this machine first, then point at the installed executable.", mcpErrSecretRefOutOfScope: "Secret references can only point at server-side variable names carrying the required prefix.", mcpErrValidation: "Something in the form isn't right. Check it and try again.", + mcpFailCallTimeout: "The last call took too long to come back.", + mcpFailExited: "The server exited on its own.", + mcpFailHandshakeTimeout: "The server did not answer in time.", + mcpFailNotRunning: "The server is not running right now.", + mcpFailProtocol: "The server's answer could not be read.", + mcpFailProtocolVersion: "The two sides' protocol versions do not match.", + mcpFailServerError: "The server refused the connection.", + mcpFailSpawn: "The server could not start.", mcpFieldArgs: "Start-up arguments, one per line (optional)", mcpFieldCommand: "Start command: an executable on this machine — a full path is safest", mcpFieldCwd: "Working directory (optional)", diff --git a/apps/desktop-webview/src/spotlight/views/settings-mcp.test.ts b/apps/desktop-webview/src/spotlight/views/settings-mcp.test.ts index ba0474e5..67ff33cf 100644 --- a/apps/desktop-webview/src/spotlight/views/settings-mcp.test.ts +++ b/apps/desktop-webview/src/spotlight/views/settings-mcp.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { McpServerConnectionVM, McpServerVM } from "@workhub/contracts"; +import type { McpServerConnectionVM, McpServerErrorCode, McpServerVM } from "@workhub/contracts"; import { MCP_SERVER_NAME_MAX_CHARS, @@ -9,6 +9,7 @@ import { MCP_TOOL_ID_PREFIX, emptyMcpFormState, mcpAddErrorText, + mcpErrorCodeLine, mcpReasonLine, mcpServersSectionHtml, mcpStatusLine, @@ -141,6 +142,106 @@ test("a spent reconnect budget says why nothing is being retried and points at T assert.match(reason, /测试连接/u); }); +// —— R26 F3:连不上的原因按稳定错误码出话 —— // + +// 契约 mcpServerErrorCodeSchema 的九条码。这里写死一份,是为了在契约新增一条码而 +// MCP_ERROR_CODE_COPY 忘了跟时**测试先红**(那张表的 satisfies 只保证不缺键,保证不了这里有覆盖)。 +const MCP_ERROR_CODES: readonly McpServerErrorCode[] = [ + "mcp_spawn_failed", + "mcp_handshake_timeout", + "mcp_protocol_version_unsupported", + "mcp_protocol_error", + "mcp_server_error", + "mcp_call_timeout", + "mcp_not_running", + "mcp_exited", + "mcp_connect_failed" +]; + +test("every error code has its own sentence in both languages, and no code leaves it blank", () => { + const zhLines = new Set(); + for (const code of MCP_ERROR_CODES) { + const zhLine = mcpErrorCodeLine(code, true) ?? ""; + const enLine = mcpErrorCodeLine(code, false) ?? ""; + assert.ok(zhLine.length > 0, `${code} 没有中文句子`); + assert.ok(enLine.length > 0, `${code} has no English sentence`); + assert.notEqual(zhLine, enLine, `${code} 两种语言给了同一串`); + zhLines.add(zhLine); + } + // 九条各说各的:合并两条码会让两种完全不同的下一步动作看起来是同一件事。 + assert.equal(zhLines.size, MCP_ERROR_CODES.length); + // 拿不到码时不出这一行——由 mcpReasonLine 落回通用句,而不是在这里编一个原因。 + assert.equal(mcpErrorCodeLine(undefined, true), undefined); +}); + +test("the fallback code says exactly what a missing code says — it means the same thing", () => { + const withFallbackCode = mcpServerVm({ + status: "connect_failed", + tool_count: 0, + last_error_code: "mcp_connect_failed" + } as Partial); + const withNoCode = mcpServerVm({ status: "connect_failed", tool_count: 0 } as Partial); + assert.equal(mcpReasonLine(withFallbackCode, undefined, true), mcpReasonLine(withNoCode, undefined, true)); +}); + +test("a handshake timeout finally gets the sentence M7 could not give", () => { + const server = mcpServerVm({ + status: "connect_failed", + tool_count: 0, + tools: [], + last_error: "mcp handshake timed out after 20000ms", + last_error_code: "mcp_handshake_timeout" + } as Partial); + const reason = mcpReasonLine(server, undefined, true) ?? ""; + assert.match(reason, /^服务器没有在规定时间内应答。/u, "按码出的原因在最前"); + assert.match(reason, /handshake timed out/u, "原始诊断仍然只是括号里的次级信息"); + assert.doesNotMatch(reason, /连不上这台服务器/u, "有了具体原因就不再说那句笼统的"); + assert.match(mcpReasonLine(server, undefined, false) ?? "", /^The server did not answer in time\./u); +}); + +test("the connection snapshot's code wins over the row's — it is this process's latest handshake", () => { + const server = mcpServerVm({ + status: "connect_failed", + tool_count: 0, + last_error: "spawn ENOENT", + last_error_code: "mcp_spawn_failed" + } as Partial); + const connection: McpServerConnectionVM = { + live: false, + tool_count: 0, + last_error: "mcp server exited with code 1", + last_error_code: "mcp_exited" + }; + assert.match(mcpReasonLine(server, connection, true) ?? "", /^这台服务器自己退出了。/u); +}); + +test("no code at all keeps M7's generic sentence rather than inventing a reason", () => { + const server = mcpServerVm({ + status: "connect_failed", + tool_count: 0, + last_error: "mcp server 'gh' is not running" + } as Partial); + const reason = mcpReasonLine(server, undefined, true) ?? ""; + assert.match(reason, /^连不上这台服务器。/u); + assert.match(reason, /is not running/u); +}); + +test("a spent retry budget says both things: why it failed and why nothing is retrying", () => { + const server = mcpServerVm({ status: "connect_failed", tool_count: 0 } as Partial); + const connection: McpServerConnectionVM = { + live: false, + tool_count: 0, + blocked_reason: "mcp server 'gh' failed 4 times within 10 minutes; last error: spawn ENOENT", + last_error_code: "mcp_spawn_failed" + }; + const reason = mcpReasonLine(server, connection, true) ?? ""; + assert.match(reason, /^这台服务器没能启动。/u); + assert.match(reason, /不自动重连/u); + assert.match(reason, /测试连接/u); + // 英文两句之间要有空格,不然会连成一串。 + assert.match(mcpReasonLine(server, connection, false) ?? "", /start\. It failed too many times/u); +}); + test("connected with zero tools is its own answer, not a silent success", () => { const server = mcpServerVm({ tool_count: 0, tools: [] } as Partial); const reason = mcpReasonLine(server, { live: true, tool_count: 0 }, true) ?? ""; diff --git a/apps/desktop-webview/src/spotlight/views/settings-mcp.ts b/apps/desktop-webview/src/spotlight/views/settings-mcp.ts index ccf92abd..8fb8bb87 100644 --- a/apps/desktop-webview/src/spotlight/views/settings-mcp.ts +++ b/apps/desktop-webview/src/spotlight/views/settings-mcp.ts @@ -14,13 +14,14 @@ import type { McpServerConnectionVM, + McpServerErrorCode, McpServerTrustLevel, McpServerVM } from "@workhub/contracts"; import { escapeHtml } from "@workhub/web-runtime"; import { withErrorDetail } from "../../load-state-copy.js"; -import { spotlightViewsT } from "./locales.js"; +import { spotlightViewsT, type SpotlightViewsCopyKey } from "./locales.js"; /** * 工具名预览的三个常量与 `@workhub/mcp-client` 的 `names.ts` 逐字对齐,但**刻意不 import 那个包**: @@ -86,12 +87,46 @@ export function mcpStatusLine(server: McpServerVM, connection: McpServerConnecti return `${head} · ${mcpToolCountText(toolCount, zh)}`; } +/** + * 连接/会话失败的**稳定错误码** → 一句人话。 + * + * 全表覆盖 `mcpServerErrorCodeSchema` 的九条码(`satisfies` 保证契约新增一条码时这里编译不过, + * 而不是悄悄落到兜底句上)。句子与网页只读清单逐字同句,同一台服务器在两端只有一种说法。 + * + * `mcp_connect_failed` 是「这次说不出原因」的兜底码,它的那一句就是既有的通用句——不为它编一个 + * 更具体的原因。**绝不解析 `last_error` 里的英文诊断串**:那是给人看的现场信息(stderr 尾巴、 + * 命令路径、失败次数),照着它切字符串会在下一次改措辞时把中文界面变成半截英文。 + */ +const MCP_ERROR_CODE_COPY = { + mcp_spawn_failed: "mcpFailSpawn", + mcp_handshake_timeout: "mcpFailHandshakeTimeout", + mcp_protocol_version_unsupported: "mcpFailProtocolVersion", + mcp_protocol_error: "mcpFailProtocol", + mcp_server_error: "mcpFailServerError", + mcp_call_timeout: "mcpFailCallTimeout", + mcp_not_running: "mcpFailNotRunning", + mcp_exited: "mcpFailExited", + mcp_connect_failed: "mcpConnectFailedHint" +} as const satisfies Record; + +export function mcpErrorCodeLine(code: McpServerErrorCode | undefined, zh: boolean): string | undefined { + return code === undefined ? undefined : spotlightViewsT(zh, MCP_ERROR_CODE_COPY[code]); +} + /** * 状态行之下的补充说明:为什么连不上 / 为什么不再自动重连 / 连上了却一个工具都没有。 * + * 「为什么连不上」优先按 `last_error_code` 出人话(R26 M8 才把码带到读形状上;在那之前这里只能 + * 落回通用的一句)。码**可能缺席**——行上有诊断、这个进程却不记得那次失败时(重启之后)就没有码, + * 那时仍然说通用的那一句,而不是编一个它并不知道的原因。连接快照上的码优先于行上的码: + * 前者来自这个进程最近一次握手,后者是上一次持久化的分类。 + * * `last_error` 与 `blocked_reason` 都是服务端给的**诊断串**(多数情况下是英文),不是界面文案—— * 所以产品句子在前、原始诊断只作为括号里的次级信息(`withErrorDetail` 的既有口径), * 而不是把一句英文直接当结论渲上去。 + * + * 重连预算耗尽时两句话都要说:为什么失败(码)+ 为什么不再自动重试。它们是两件事, + * 下一步动作也不同(修服务器 vs 点「测试连接」)。 */ export function mcpReasonLine( server: McpServerVM, @@ -104,7 +139,13 @@ export function mcpReasonLine( if (server.status === "connect_failed") { const blocked = connection?.blocked_reason; const reason = blocked ?? connection?.last_error ?? server.last_error; - const lead = blocked ? spotlightViewsT(zh, "mcpRetryBudgetSpent") : spotlightViewsT(zh, "mcpConnectFailedHint"); + const coded = mcpErrorCodeLine(connection?.last_error_code ?? server.last_error_code, zh); + const sentences = [coded, blocked ? spotlightViewsT(zh, "mcpRetryBudgetSpent") : undefined] + .filter((sentence): sentence is string => sentence !== undefined); + // 中文句子自带句号,英文句间要一个空格(同 withErrorDetail 里按语言分叉的口径)。 + const lead = sentences.length > 0 + ? sentences.join(zh ? "" : " ") + : spotlightViewsT(zh, "mcpConnectFailedHint"); return reason ? withErrorDetail(zh, lead, new Error(reason)) : lead; } const toolCount = connection?.tool_count ?? server.tool_count; @@ -223,7 +264,7 @@ export type DesktopMcpFormState = { }; export type DesktopMcpSectionState = { - /** 管理员门:服务端只给管理员填 settings VM 的 plugins 字段,这里跟着同一个信号(见 settings.ts 的接线注释)。 */ + /** 管理员门:服务端只给管理员填 settings VM 的 mcp_servers 字段,这里跟着同一个信号(见 settings.ts 的接线注释)。 */ visible: boolean; servers: readonly McpServerVM[] | undefined; /** 按 id 索引的连接事实。停用/尚未连过的服务器不在这张表里。 */ diff --git a/apps/desktop-webview/src/spotlight/views/settings.test.ts b/apps/desktop-webview/src/spotlight/views/settings.test.ts index 7d920a9b..9cb27aa4 100644 --- a/apps/desktop-webview/src/spotlight/views/settings.test.ts +++ b/apps/desktop-webview/src/spotlight/views/settings.test.ts @@ -2325,9 +2325,9 @@ function mcpActionResult(server: McpServerVM, over: Record = {} } as unknown as McpServerActionResult; } -/** 管理员的 settings VM(服务端只给管理员填 plugins;MCP 区借同一个信号)。 */ +/** 管理员的 settings VM:服务端只给管理员填 plugins 与 mcp_servers,两个区各据自己那个字段。 */ function adminSettingsVm(): SettingsPageVM { - return { ...settingsVm(), plugins: [] } as unknown as SettingsPageVM; + return { ...settingsVm(), plugins: [], mcp_servers: [] } as unknown as SettingsPageVM; } function mcpListVm(servers: McpServerVM[], over: Record = {}) { @@ -2391,6 +2391,38 @@ test("a non-admin settings view never fetches the MCP server list either", async }); }); +test("R26 F3: the MCP section follows mcp_servers, not the plugins field it borrowed in M7", async () => { + await withFakeHtmlElement(async () => { + const body = new FakeBody(); + let listed = 0; + // 一个填了 plugins、却没有 mcp_servers 的 VM:M7 借 plugins 时这里会渲出一个必然 403 的分区。 + const pluginsOnly = { ...settingsVm(), plugins: [] } as unknown as SettingsPageVM; + await createSettingsView().mount( + baseCtx(body, { + client: { + pages: { async settings() { return pluginsOnly; } }, + async request(path: string) { + if (path === "/api/me/profile") return userProfileVm() as unknown as T; + return aiProfileVm() as unknown as T; + }, + async listPlugins() { + return { plugins: [], bootstrap_path_count: 0 } as unknown as never; + }, + async listMcpServers() { + listed += 1; + return mcpListVm([]); + } + } as unknown as SpotlightViewContext["client"] + }) + ); + await tick(); + await tick(); + assert.equal(listed, 0); + assert.doesNotMatch(body.innerHTML, /data-spot-mcp-section/u); + assert.match(body.innerHTML, /data-spot-plugins-section/u, "插件区仍据 plugins 渲染,两个门互不牵连"); + }); +}); + test("the settings view lists MCP servers for an admin and renders every action", async () => { await withFakeHtmlElement(async () => { const body = new FakeBody(); diff --git a/apps/desktop-webview/src/spotlight/views/settings.ts b/apps/desktop-webview/src/spotlight/views/settings.ts index 3b4f08dc..30578d69 100644 --- a/apps/desktop-webview/src/spotlight/views/settings.ts +++ b/apps/desktop-webview/src/spotlight/views/settings.ts @@ -1329,10 +1329,11 @@ export function createSettingsView(): SpotlightCapabilityView { // R26 M7:MCP 服务器治理。 // - // 管理员门借 `vm.plugins !== undefined`:服务端只给管理员填那个字段,而 /api/mcp-servers 整条 - // 端点也是管理员门(403 `mcp_admin_required`)——两者是同一个身份判定的两个出口,借它就不必 - // 自己猜身份、也不必靠一个注定 403 的请求闪一下。settings VM 目前**没有** mcp_servers 字段 - // (那是工包 M8 的网页只读行要加的),等它落地后这里可以换成同款的 `vm.mcp_servers !== undefined`。 + // 管理员门走 `vm.mcp_servers !== undefined`:服务端只给管理员填那个字段(M8 的网页只读行加的), + // 而 /api/mcp-servers 整条端点也是管理员门(403 `mcp_admin_required`)——两者是同一个身份判定 + // 的两个出口,据它就不必自己猜身份、也不必靠一个注定 403 的请求闪一下。M7 当时这个字段还不 + // 存在,借的是同一道门的 `vm.plugins`;M8 落地后换成本字段,一个只有插件管理权的身份不会再 + // 看到一个必然 403 的分区。 let mcpServers: McpServerVM[] | undefined; let mcpConnections: Record = {}; let mcpSecretRefEnvPrefix = ""; @@ -1496,7 +1497,7 @@ export function createSettingsView(): SpotlightCapabilityView { ); const mcpHtml = mcpServersSectionHtml( { - visible: vm.plugins !== undefined, + visible: vm.mcp_servers !== undefined, servers: mcpServers, connections: mcpConnections, secretRefEnvPrefix: mcpSecretRefEnvPrefix, @@ -1636,8 +1637,8 @@ export function createSettingsView(): SpotlightCapabilityView { loadServerHealth(), // 非管理员的 VM 里没有 plugins 字段——那就连列表都不去拉(省一次注定 403 的请求)。 vm.plugins !== undefined ? loadPlugins() : Promise.resolve(), - // MCP 清单端点同样是管理员门,借同一个信号(见上面 mcpServers 那组状态的注释)。 - vm.plugins !== undefined ? loadMcpServers() : Promise.resolve() + // MCP 清单端点同样是管理员门,据它自己的那个信号(见上面 mcpServers 那组状态的注释)。 + vm.mcp_servers !== undefined ? loadMcpServers() : Promise.resolve() ]); if (disposed) return; renderAll(); From e5d573046032fa2408a2a1d2eb2b8e31bc53ff77 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:23:36 +0800 Subject: [PATCH 17/23] =?UTF-8?q?docs:=20=E5=B7=A5=E5=8C=85=20F3=20?= =?UTF-8?q?=E7=9A=84=20Agent=20Note=EF=BC=88=E8=B7=AF=E7=94=B1=E5=B1=82?= =?UTF-8?q?=E6=9C=AF=E8=AF=AD=E6=94=B6=E5=8F=A3=20/=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=E6=8C=89=E7=A0=81=E5=87=BA=E8=AF=9D=20/=20=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E9=81=97=E7=95=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...-06-routes-copy-and-desktop-error-codes.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .agents/notes/implemented/2026-09-06-routes-copy-and-desktop-error-codes.md diff --git a/.agents/notes/implemented/2026-09-06-routes-copy-and-desktop-error-codes.md b/.agents/notes/implemented/2026-09-06-routes-copy-and-desktop-error-codes.md new file mode 100644 index 00000000..c9cfa220 --- /dev/null +++ b/.agents/notes/implemented/2026-09-06-routes-copy-and-desktop-error-codes.md @@ -0,0 +1,114 @@ +# 路由层「事项 / 工单」收口成「任务」,桌面端 MCP 分区消费 last_error_code(工包 F3) + +- Status: implemented +- Date: 2026-09-06 +- Owner: claude-code + +## Problem + +M8 的 Note 结尾留了两条互不相干、却都卡在「没人接手」的事: + +1. **路由层还在说「事项」**。M8 把五个 service 的用词按 glossary §11 收口成了「任务」,但 + `apps/api/src/routes/` 里同一批话还有 21 处没动——HTTP 错误消息也是用户看得见的文案 + (前端把 `error.message` 直接渲出来)。于是同一个 404 从服务层出来说「任务」、从路由层出来 + 说「事项」,取决于请求打在哪条路径上。 +2. **桌面端拿到了码却不用**。M7 的 Note 明写「握手超时那句人话给不出」——`describeMcpSessionFailure()` + 在 M3 就把八条会话失败原因翻成了稳定码,只是没有字段带出来。M8 给三个读形状都加了 + `last_error_code`,网页只读清单当天就按码出话了,桌面端仍然只会说「连不上 +(英文诊断)」。 + 同一份 Note 还留了第二条:M7 的管理员门是**借**来的(`vm.plugins !== undefined`),M8 之后 + `vm.mcp_servers` 已经存在。 + +## Decision + +### 一、改词之前先把整句搬进词典,一句都不就地改 + +门禁按「文件 + 归一化片段」记棘轮(`scripts/dev/check-ui-i18n.ts`),就地改一句话会同时产生 +「基线条目已消失」与「新增违规」——净效果是门禁变红而账没清。所以 21 处**每一处**都先搬进 +`apps/api/src/services/locales.ts`(M8 建的那份),调用点改成 `serviceT(...)`,再改词。 +基线只减:2795 → 2768(净减 27,零新增;片段计数 > 站点数是因为同一句话在同一文件里出现多次)。 + +**词典从「服务层的」变成「API 层的」**。routes 与 services 各写一份等价词典是第二份会漂移的表: +「没有找到这个任务。」这一句在 routes 里出现 6 次、在 services 里已经有一条 `taskNotFound`。 +让 routes `import { serviceT } from "../services/locales.js"`,一句话只有一个出处。 +文件头注释随之改写;没有新起 `routes/locales.ts`。 + +**locale 一律传 `"zh-CN"`,只有原本就双语的那一处例外**。这一批是「改词」,不是「把 locale +一路传下去」——后者会改变 en-US 客户端拿到的响应,值得单独一批。唯一的例外是 `pages.ts` 里 +「待审批的任务比这里显示的更多」:它本来就是 `locale === "en-US" ? … : …` 的行内三目, +搬进词典后写成 `serviceT(locale, …)` 才是**保持**原行为。 + +**顺带统一三处不一致**(都是这批已经动到的句子,不额外扩面): + +- 权限类消息此前有「你没有权限修改这个事项。」与「没有权限修改这个事项。」两种写法,一律收成 + 带「你」的那种——同一件事在同一个产品里只该有一种口吻。 +- 三处 404 的中文消息尾巴上挂着 `(Work item not found.)`。中文消息里夹半句英文本身就是这批要清的 + 东西,而且 "work item" 正是 glossary 里那个要退役的英文词;词典的英文侧(`taskNotFound` → + "That task wasn't found.")已经覆盖 en-US,这条尾巴没有存在的理由。全仓没有任何断言依赖它。 +- `proposalActionCopy` 里那条英文原本写着 "the auditable work-item flow",一并改成 "task flow"。 + +### 二、桌面端按码出话:一张全表覆盖的映射,而不是一串 case + +`MCP_ERROR_CODE_COPY` 是 `Record` 并带 `satisfies`—— +契约里新增第十条码时**这里编译不过**,而不是悄悄落到兜底句上。这比 `switch` 加 `default` 强的 +地方正在于此:`default` 会把一条新码渲成一句错话,而且不会有人发现。 + +**九条码各一句**,且与网页只读清单(`route-components-copy.ts` 的 `settings.mcpErr*`)逐字同句: +同一台服务器在两端只有一种说法,运维把网页上看到的那句话念给桌面端的人听时不会对不上。 +第九条 `mcp_connect_failed` 是「这次说不出原因」的兜底码,它的那一句就是既有的通用句 +`mcpConnectFailedHint`——不为一个明说了「不知道」的码编一个更具体的原因。测试里有一条断言 +「兜底码的那一行」与「根本没有码的那一行」逐字相同。 + +**连接快照上的码优先于行上的码**:前者来自这个进程最近一次握手,后者是上一次持久化下来的分类。 +**码可能缺席**(重启之后这个进程不记得那次失败,M8 的契约注释把这条写死了),那时仍说通用的 +那一句,而不是编一个它并不知道的原因。 + +**重连预算耗尽时两句话都说**:为什么失败(码)+ 为什么不再自动重试。M7 那版是后者顶掉前者—— +在只有通用句可说时那是对的("连不上这台服务器。" 没有信息量),有了码之后就不对了: +「这台服务器没能启动。」和「接连失败太多次,先不自动重连了。」是两件事,下一步动作也不同 +(修服务器 vs 点「测试连接」)。中文两句直接相接(句子自带句号),英文之间补一个空格—— +与 `withErrorDetail` 里按语言分叉的既有口径一致。 + +### 三、管理员门换成 `vm.mcp_servers !== undefined` + +渲染与拉清单两处一起换。差别不是学术的:M7 借 `vm.plugins` 时,一个「有插件管理权、没有 MCP +管理权」的身份会看到一个必然 403 的分区。当前服务端不存在这种身份,但门的语义应当指向它真正 +守的那件事。新增一条测试专钉这个差别:一个填了 `plugins`、没有 `mcp_servers` 的 VM, +MCP 区不渲、清单一次都不拉,而插件区照渲。 + +## Alternatives considered + +- **给路由层新起一份 `routes/locales.ts`**(工位说明里给的另一个选项)。否决:那会让 + 「没有找到这个任务。」在两份词典里各存一遍,正是这批要消灭的东西。routes 本来就大量 import + services,多这一个 import 没有分层代价。 +- **把 `proposalActionCopy` 整张表搬进一个 `-copy.ts`**(它形状上本来就是词典)。否决:那会把 + 三十来条英文一次性拉进禁词门的英文表扫描面(`BANNED_EN` 只跑词典文件),风险与这批要办的事 + 无关。只把改到的那一条挪进词典、其余留在原处,是最小且可解释的做法。 +- **在 routes 里改用 `serviceT(requestLocale(c), …)`**。否决:会改变 en-US 客户端拿到的错误消息, + 超出「改词」的范围;`pages.ts` 那一处是因为**原本就**双语才用 locale。 +- **就地改词、把棘轮基线里的条目一并改名**。否决:那不叫「基线只减」,只是把同一笔账换个写法记着。 +- **桌面端用 `switch (code)` + `default` 出话**(网页那侧的写法)。否决:见上;网页那侧的 + `default` 返回空串、不出这一行,代价小得多,桌面端这里要拿它当唯一的产品句子。 +- **码缺席时按 `last_error` 的英文文本猜一个原因**。否决:那串是给人看的现场信息(stderr 尾巴、 + 命令路径、失败次数),照着它切字符串会在下一次改措辞时把中文界面变成半截英文——这正是 + M3 起就写在契约注释里的纪律。 +- **重连预算耗尽时只说码那一句**(把 M7 的那句顶掉)。否决:用户会看不出「为什么点了没反应」, + 而那句话里恰好带着唯一的出路(点「测试连接」)。 + +## Consequences + +- **改了三条消息的字面,不只是术语**:三处 404 去掉了 `(Work item not found.)` 尾巴; + 四处「没有权限修改这个事项。」补上了「你」;一处英文 "work-item flow" → "task flow"。 + 全仓没有断言依赖这三处(唯一一条断言产线消息的 `agent-runs.test.ts` 已同步)。 +- **`services/locales.ts` 里 `taskCommentsNotFound` 与 `taskNotFound` 现在只差一条英文尾巴**。 + 前者的唯一调用点在 `work-item-comments.ts`(不在本工位的允许改动范围),合并成一条留给下一批。 +- **「事项 / 工单」没有门禁**。`check-copy-terms.ts` 的禁词表里没有这两个词,所以这次收口全靠 + 人扫一遍 grep,明天就可能被一句新代码带回来。把它们加进禁词表会一次性暴露 60 余处存量 + (`packages/agent` 的提示词、`packages/cuu`、`apps/web` 等),那是一批独立的账,不在这里开。 +- **`packages/agent` 的「工单」一个字没动**:那是模型可见文本,与 golden 基线绑在一起, + 按工位纪律与 golden 一起走。`routes/drive.ts` 与 `routes/meetings.ts` 各有一处 + `requireUuidParam(…, "事项")` 也没动——两个文件不在本工位的允许改动清单里。 +- **模型可见文本零变化**:`pnpm gen:expected` 两次运行都无 diff。 +- **棘轮基线只减**:ui-i18n 2795 → 2768(净减 27,零新增);禁词基线 49 条不变。 +- **桌面端真机未验**(浏览器预览没有 Tauri 运行时,`desktop-webview` 渲染不出):一台真的连不上 + 的服务器,行内那句「原因 + 重连预算 +(诊断)」在窄栏里会比 M7 那版更长,换行值得在 `.app` + 上看一眼。 From ea391bc5aa4fdc27c4e9381344df0271b6093f79 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:27:48 +0800 Subject: [PATCH 18/23] =?UTF-8?q?feat(b6b):=20agent=5Frun.reminded=20?= =?UTF-8?q?=E6=8E=A5=E8=BF=9B=E8=BF=90=E8=A1=8C=E8=AE=B0=E5=BD=95=E4=B8=8E?= =?UTF-8?q?=E5=AE=9E=E6=97=B6/=E5=9B=9E=E6=94=BE=E4=B8=A4=E4=B8=AA=20VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补上 B6 记下的最后一跳:运行器在事件出门处把提醒累进 AgentRunQueueRecord (readAgentRunReminderFacts 解析、剥掉 run_id 等事件专属字段),持久化层把它 映到 agent_runs.reminders_json 再读回,replay.ts 的实时 VM 与回放 VM 各透传一行。 提醒不进 trace:它不是模型的一步,占了步号会让「跑了几步」撒谎——渲染层按 step_no 把它插在所属步骤之后。累加不额外发一次写,随这次运行既有的落盘路径进库;单 run 封顶 32 条(正常最多两条,上限只是防御)。 Co-Authored-By: Claude Fable 5.1 --- apps/api/src/agent-runs.test.ts | 319 ++++++++++++++++++ apps/api/src/pages/replay.ts | 5 + .../api/src/services/agent-run-persistence.ts | 25 ++ apps/api/src/workers/agent-runner.ts | 34 +- 4 files changed, 382 insertions(+), 1 deletion(-) diff --git a/apps/api/src/agent-runs.test.ts b/apps/api/src/agent-runs.test.ts index 41917411..408fb30a 100644 --- a/apps/api/src/agent-runs.test.ts +++ b/apps/api/src/agent-runs.test.ts @@ -16,6 +16,7 @@ import { allowedWorkItemTransitions, eventTypes, type AcceptedDeliverableVM, + type AgentRunReminderFacts, type WorkHubEvent, type WorkItemStatus } from "@workhub/contracts"; @@ -43,6 +44,7 @@ import type { import { COOKIE_NAME, type AuthDependencies, type AuthEnv } from "./middleware/auth.js"; import { createAgentRunRoutes, type ProposalReplayAuditReader } from "./routes/agent-runs.js"; +import { buildReplayTracePage, toAgentRunLiveVm } from "./pages/replay.js"; import { createDbAgentRunPersistence } from "./services/agent-run-persistence.js"; import { createAgentRunConfidenceRecorder } from "./services/agent-run-confidence.js"; import { createAgentRunSnapshotHook } from "./services/agent-run-snapshots.js"; @@ -51,6 +53,7 @@ import { createHumanReservedGuard } from "./services/human-reserved-guard.js"; import { createInMemoryProposalService } from "./services/proposals.js"; import { WorkItemServiceError, type WorkItemService } from "./services/work-items.js"; import { + AGENT_RUN_REMINDER_CAP, AgentRunnerError, canUseToolForTaskPlanRole, createInMemoryAgentRunQueue, @@ -8106,3 +8109,319 @@ test("INF-10: the snapshot hook leaves no orphan snapshot row when the atomic wr assert.equal(snapshots.rows.length, 0, "a failed atomic write leaves no orphan snapshot row"); assert.equal(auditLogs.rows.length, 0); }); + +// R26 批 B6b(重复动作提醒的持久化与观测面)。 +// +// B6 让「先劝再断」的前两档发出 agent_run.reminded,两端时间线也已经会渲这一行;但那条事件只活在 +// SSE 流里——运行记录不留、库里不落,于是回放页(读的是库)永远看不到,换个 worker 接手也不知道 +// 已经劝过几次。这一组用例钉住补上的那一跳:事件 → 运行记录 → 落盘 → 读回 → 两个 VM。 +// +// 脚本化 client 连着三步发同一个工具调用(工具名 + 入参完全一致 → 指纹相同),第三步跨过第一档 +// 阈值(默认窗口 3)触发 tier 1;第四步改口收尾,运行正常成功。 +function repeatedToolAgentClient(): AgentLoopClient { + let calls = 0; + return { + model: "deepseek-v4-flash", + messages: { + async create() { + calls += 1; + if (calls <= 3) { + return { + id: `msg-repeat-${calls}`, + stopReason: "tool_use", + usage: { inputTokens: 1, outputTokens: 1 }, + content: [ + { + type: "tool_use", + // 指纹只看工具名与入参(见 control.ts 的 fingerprintAssistantBlocks),id 逐次不同不影响判定。 + id: `tool-repeat-${calls}`, + name: "read_file", + input: { path: "notes.md" } + } + ] + }; + } + return { + id: "msg-repeat-done", + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 1 }, + content: [{ type: "text", text: "已改用别的做法并完成。" }] + }; + } + } + }; +} + +const b6bTier1Reminder: AgentRunReminderFacts = { + step_no: 3, + tier: 1, + repeats: 3, + shape: "identical", + tool_id: "read_file" +}; + +test("R26-B6b 连续三步同一工具:提醒进运行记录与两个 VM,且不占步号", async () => { + const runtimeSettings = settings(); + const persistence = new MemoryAgentRunPersistence(); + const publishedEvents: { type: string; data: Record }[] = []; + const queue = createInMemoryAgentRunQueue({ + settings: runtimeSettings, + now: () => now, + id: () => "40000000-0000-4000-8000-0000000000e2", + persistence, + client: () => repeatedToolAgentClient(), + requireDeliverable: false, + confidence: false, + proposals: false, + notifications: false, + tools: () => ({ + toModelTools: () => [], + async execute() { + return { ok: true, isError: false, content: "notes.md 的内容与上次一样。" }; + } + }), + eventBus: { + async publish(_topic, type, data) { + publishedEvents.push({ + type, + data: (data as WorkHubEvent>).data as Record + }); + } + } + }); + + const queued = await queue.enqueue({ workItemId, actorId: userId, title: "Repeat-reminded run" }); + const settled = await queue.runNext(); + + assert.equal(settled?.status, "succeeded"); + + // (a) 事件确实发了一条、且只发一条(第一档跨过阈值那一步发一次,第 4 步链路已断)。 + const reminded = publishedEvents.filter((event) => event.type === eventTypes.agentRunReminded); + assert.equal(reminded.length, 1); + assert.equal(reminded[0]?.data["tier"], 1); + assert.equal(reminded[0]?.data["step_no"], 3); + + // (b) 事件被累进了运行记录,run_id/work_item_id 这些事件专属字段由 readAgentRunReminderFacts 剥掉。 + assert.deepEqual(settled?.reminders, [b6bTier1Reminder]); + + // (c) 提醒不是模型的一步:步号与 trace 行数一字未变。第 3 步照旧只有 tool_call + tool_result 两行, + // 步数用量仍是 4(三次重复 + 一次收尾),终态行落在 stepsUsed + 1。 + assert.equal(settled?.usage.steps_used, 4); + assert.equal(settled?.trace.filter((step) => step.step_no === 3).length, 2); + assert.deepEqual( + settled?.trace.filter((step) => step.step_no === 3).map((step) => step.phase), + ["tool_call", "tool_result"] + ); + assert.deepEqual([...new Set(settled?.trace.map((step) => step.step_no))], [1, 2, 3, 4, 5]); + + // (d) 实时页与回放页两个 VM 都带上这一行(渲染由 packages/ui / 桌面端按 locale 组词,见 B6)。 + assert.deepEqual(toAgentRunLiveVm(settled!).reminders, [b6bTier1Reminder]); + assert.deepEqual(buildReplayTracePage({ run: settled! }).reminders, [b6bTier1Reminder]); + + // (e) worker 崩溃恢复:换一个 queue(内存 runs 全空)从持久化读回,提醒仍在,VM 照样有这一行。 + const restarted = createInMemoryAgentRunQueue({ + settings: runtimeSettings, + now: () => now, + id: () => "40000000-0000-4000-8000-0000000000e3", + persistence, + confidence: false, + proposals: false, + notifications: false, + eventBus: false + }); + const recovered = await restarted.get(queued.run_id); + assert.deepEqual(recovered?.reminders, [b6bTier1Reminder]); + assert.deepEqual(toAgentRunLiveVm(recovered!).reminders, [b6bTier1Reminder]); +}); + +// 没被劝过的运行不许多出这个键:缺席与空数组同义,additive optional 的既有取舍——存量客户端读旧响应零回归。 +test("R26-B6b 没触发提醒的运行,记录与两个 VM 都不出现 reminders 键", async () => { + const runtimeSettings = settings(); + const queue = createInMemoryAgentRunQueue({ + settings: runtimeSettings, + now: () => now, + id: () => "40000000-0000-4000-8000-0000000000e4", + client: () => singleToolThenDoneAgentClient(), + requireDeliverable: false, + confidence: false, + proposals: false, + notifications: false, + eventBus: false, + tools: () => ({ + toModelTools: () => [], + async execute() { + return { ok: true, isError: false, content: "done" }; + } + }) + }); + + await queue.enqueue({ workItemId, actorId: userId, title: "Quiet run" }); + const settled = await queue.runNext(); + + assert.equal(settled?.status, "succeeded"); + assert.equal("reminders" in settled!, false); + assert.equal("reminders" in toAgentRunLiveVm(settled!), false); + assert.equal("reminders" in buildReplayTracePage({ run: settled! }), false); +}); + +// B6b 落盘往返:运行记录的 reminders ↔ agent_runs.reminders_json。走的是真 DB 适配层 +// (createDbAgentRunPersistence 的 toPersistenceRun / toQueueRun),不是内存假仓储的 structuredClone—— +// 后者只能证明对象可复制,证明不了提醒真的经过了那一列。 +function b6bRunRecord(overrides: Partial = {}): AgentRunQueueRecord { + return { + run_id: "40000000-0000-4000-8000-0000000000e5", + work_item_id: workItemId, + actor_id: userId, + mode: "worker", + status: "running", + title: "Reminder round-trip", + budget: { max_steps: 15, total_timeout_s: 300, max_tokens: 120000, max_cost_cny: "5" }, + budget_decision: { + decision_id: "pcost-round-trip", + allowed: true, + model_route: { provider: "deepseek", model: "deepseek-v4-flash", reason: "default" } + }, + usage: { steps_used: 3, token_in: 3, token_out: 3, estimated_cost_cny: "0" }, + trace: [], + created_at: now.toISOString(), + updated_at: now.toISOString(), + ...overrides + }; +} + +function b6bPersistenceWithRow() { + let stored: Record | undefined; + const repository = { + async createRun(run: Parameters[0]) { + stored = { + id: run.runId, + orgId: null, + workspaceId: null, + workItemId: run.workItemId, + parentRunId: null, + taskPlanId: null, + taskPlanItemId: null, + taskPlanItemEpoch: null, + objectiveId: null, + agentRole: null, + objectiveMd: null, + mode: run.mode, + actor: "human", + actorUserId: run.actorUserId, + title: run.title, + status: run.status, + model: run.model, + turnsUsed: run.usage.stepsUsed, + maxTurns: run.budget.maxSteps, + totalTimeoutS: run.budget.totalTimeoutS, + maxTokens: run.budget.maxTokens, + maxCostCny: run.budget.maxCostCny, + seconds: 0, + tokenIn: run.usage.tokenIn, + tokenOut: run.usage.tokenOut, + costEstimate: run.usage.estimatedCostCny, + budgetDecisionJson: run.budgetDecisionJson, + outcomeReason: null, + handoffMd: null, + handoffJson: null, + // 缺席即库里的 null——「这次运行没被劝过」。 + remindersJson: run.remindersJson ?? null, + workdirRef: null, + claimedBy: null, + claimedAt: null, + heartbeatAt: null, + leaseExpiresAt: null, + executionHint: "server", + sourceConversationId: null, + sourceActionCardItemId: null, + recoverAttempts: 0, + startedAt: null, + finishedAt: null, + createdAt: run.createdAt, + updatedAt: run.updatedAt + }; + return stored as Awaited>; + }, + async findById() { + return stored + ? { run: stored, steps: [] } as unknown as Awaited> + : null; + }, + async createRunIfWorkItemIdle() { throw new Error("not used"); }, + async updateRun() { throw new Error("not used"); }, + async cancelActiveRun() { throw new Error("not used"); }, + async replaceTrace() { throw new Error("not used"); }, + async setWorkdir() { throw new Error("not used"); }, + async listActive() { return []; }, + async claimQueued() { throw new Error("not used"); }, + async claimNextQueued() { throw new Error("not used"); }, + async heartbeatClaim() { throw new Error("not used"); }, + async requeueExpiredClaims() { throw new Error("not used"); }, + async restoreDeadLetterClaim() { throw new Error("not used"); }, + async listUnsettledTaskPlanRuns() { throw new Error("not used"); } + } as unknown as AgentRunRepository; + return { + persistence: createDbAgentRunPersistence(repository), + row: () => stored, + seedReminders(value: unknown) { + if (stored) { + stored.remindersJson = value; + } + } + }; +} + +test("R26-B6b 提醒经 reminders_json 往返:写进列、读回记录、空的一律缺席", async () => { + const withReminders = b6bPersistenceWithRow(); + await withReminders.persistence.createRun(b6bRunRecord({ reminders: [b6bTier1Reminder] })); + assert.deepEqual(withReminders.row()?.["remindersJson"], [b6bTier1Reminder]); + assert.deepEqual((await withReminders.persistence.get(b6bRunRecord().run_id))?.reminders, [b6bTier1Reminder]); + + // 没被劝过:列写 null,读回不出现这个键(缺席与空数组同义)。 + const quiet = b6bPersistenceWithRow(); + await quiet.persistence.createRun(b6bRunRecord()); + assert.equal(quiet.row()?.["remindersJson"], null); + assert.equal("reminders" in (await quiet.persistence.get(b6bRunRecord().run_id))!, false); + + // 空数组不落列:写 [] 只会让「这一列有没有内容」多出一个没有语义差别的第三态。 + const empty = b6bPersistenceWithRow(); + await empty.persistence.createRun(b6bRunRecord({ reminders: [] })); + assert.equal(empty.row()?.["remindersJson"], null); + assert.equal("reminders" in (await empty.persistence.get(b6bRunRecord().run_id))!, false); +}); + +test("R26-B6b 库里的脏提醒逐条丢弃,非数组整列忽略,条数封顶", async () => { + const dirty = b6bPersistenceWithRow(); + await dirty.persistence.createRun(b6bRunRecord()); + + // 逐条宽容读取:解析不出来的那条整条丢掉,剩下的照常渲——宁可少一行,也不把半截数据编成一句话。 + dirty.seedReminders([ + b6bTier1Reminder, + { step_no: 5, tier: 3, repeats: 8, shape: "identical" }, + { step_no: 5, tier: 2, repeats: 5, shape: "spiral" }, + "not-an-object", + { step_no: 5, tier: 2, repeats: 5, shape: "identical", tool_id: "read_file", run_id: workItemId } + ]); + assert.deepEqual((await dirty.persistence.get(b6bRunRecord().run_id))?.reminders, [ + b6bTier1Reminder, + // 事件专属字段 run_id 由 readAgentRunReminderFacts 自动剥掉。 + { step_no: 5, tier: 2, repeats: 5, shape: "identical", tool_id: "read_file" } + ]); + + // 整列不是数组(历史脏数据/手改):不猜、不半读,整列当缺席。 + dirty.seedReminders({ step_no: 3 }); + assert.equal("reminders" in (await dirty.persistence.get(b6bRunRecord().run_id))!, false); + + // 封顶:正常最多两条,上限只是防御——别让一列 jsonb 撑爆整页时间线。到顶后留最早的那些。 + dirty.seedReminders( + Array.from({ length: AGENT_RUN_REMINDER_CAP + 5 }, (_, index) => ({ + step_no: index + 1, + tier: 1, + repeats: 3, + shape: "identical" + })) + ); + const capped = (await dirty.persistence.get(b6bRunRecord().run_id))?.reminders; + assert.equal(capped?.length, AGENT_RUN_REMINDER_CAP); + assert.equal(capped?.at(-1)?.step_no, AGENT_RUN_REMINDER_CAP); +}); diff --git a/apps/api/src/pages/replay.ts b/apps/api/src/pages/replay.ts index 1d360141..20ee0f18 100644 --- a/apps/api/src/pages/replay.ts +++ b/apps/api/src/pages/replay.ts @@ -120,6 +120,9 @@ export function toAgentRunLiveVm(run: AgentRunQueueRecord, locale: WorkHubLocale budget_decision: run.budget_decision, usage: run.usage, trace: run.trace.map((step) => toAgentStepVm(run.run_id, step)), + // B6b:提醒是独立一行,不是一步——只透传事实,句子由两端按 locale 组装(packages/ui 的 + // agentRunReminderLine / 桌面 spotlight)。空数组不带键:缺席与 [] 同义,少一个没语义差别的第三态。 + ...(run.reminders?.length ? { reminders: run.reminders } : {}), ...(run.handoff ? { handoff: toStructuredHandoff(run.handoff) } : {}), stream_href: `/api/push/stream/run/${run.run_id}`, replay_href: `/api/agent-runs/${run.run_id}/replay` @@ -524,6 +527,8 @@ export function buildReplayTracePage(input: { return parseOutputContract(replayTraceVmSchema, { run: toAgentRunVm(input.run, locale), steps: input.run.trace.map((step) => toAgentStepVm(input.run.run_id, step)), + // B6b:同 toAgentRunLiveVm——回放读的是库里的 reminders_json,实时读的是运行记录,两条路同一形状。 + ...(input.run.reminders?.length ? { reminders: input.run.reminders } : {}), evidence_refs: buildReplayEvidenceRefs(auditLogs, locale), snapshots, audit_logs: auditLogs, diff --git a/apps/api/src/services/agent-run-persistence.ts b/apps/api/src/services/agent-run-persistence.ts index 56005b09..d54d32c7 100644 --- a/apps/api/src/services/agent-run-persistence.ts +++ b/apps/api/src/services/agent-run-persistence.ts @@ -1,3 +1,4 @@ +import { readAgentRunReminderFacts } from "@workhub/contracts"; import { createAgentRunRepository, getSharedDatabaseClient, @@ -11,6 +12,10 @@ import { import { getDefaultStructuredLogger } from "../logging.js"; +import { + AGENT_RUN_REMINDER_CAP +} from "../workers/agent-runner.js"; + import type { AgentRunClaimLease, AgentRunHeartbeatLease, @@ -88,6 +93,9 @@ function toPersistenceRun(run: AgentRunQueueRecord): AgentRunForPersistence { ...(outcomeReason ? { outcomeReason } : {}), ...(handoffText ? { handoffMd: handoffText } : {}), ...(run.handoff ? { handoffJson: run.handoff as unknown as Record } : {}), + // B6b:空数组不落列——缺席与 [] 同义(没被劝过),写 [] 只会让「这一列有没有内容」多出一个 + // 没有语义差别的第三态。省略键后 drizzle 在 UPDATE 里跳过这一列,既有值不被清空。 + ...(run.reminders?.length ? { remindersJson: run.reminders as unknown as Record[] } : {}), ...(run.workdir_ref ? { workdirRef: run.workdir_ref } : {}), createdAt: toDate(run.created_at), updatedAt: toDate(run.updated_at) @@ -178,6 +186,21 @@ function queueHandoff(rows: StoredAgentRunRows): AgentRunQueueRecord["handoff"] }; } +// B6b:读回提醒。null(从没劝过)/ 非数组(脏数据)一律退回缺席;逐条走 contracts 的宽容读取, +// 解析不出来的那一条整条丢掉——渲染层宁可少一行,也绝不把半截数据编成一句话。上限与运行器写入侧同为 +// AGENT_RUN_REMINDER_CAP,防一行畸形 JSON 把整页时间线撑爆。 +function queueReminders(rows: StoredAgentRunRows): AgentRunQueueRecord["reminders"] { + const raw = rows.run.remindersJson; + if (!Array.isArray(raw)) { + return undefined; + } + const reminders = raw + .map((item) => readAgentRunReminderFacts(item)) + .filter((item): item is NonNullable => Boolean(item)) + .slice(0, AGENT_RUN_REMINDER_CAP); + return reminders.length > 0 ? reminders : undefined; +} + function queueTrace(rows: StoredAgentRunRows): AgentRunTraceStepRecord[] { return rows.steps.map((step) => { const record: AgentRunTraceStepRecord = { @@ -202,6 +225,7 @@ function queueTrace(rows: StoredAgentRunRows): AgentRunTraceStepRecord[] { function toQueueRun(rows: StoredAgentRunRows): AgentRunQueueRecord { const usage = queueUsage(rows); const handoff = queueHandoff(rows); + const reminders = queueReminders(rows); const claim = rows.run.claimedBy && rows.run.claimedAt && rows.run.heartbeatAt && rows.run.leaseExpiresAt ? { claimed_by: rows.run.claimedBy, @@ -244,6 +268,7 @@ function toQueueRun(rows: StoredAgentRunRows): AgentRunQueueRecord { estimated_cost_cny: usage.estimatedCostCny }, trace: queueTrace(rows), + ...(reminders ? { reminders } : {}), ...(handoff ? { handoff } : {}), ...(claim ? { claim } : {}), created_at: rows.run.createdAt.toISOString(), diff --git a/apps/api/src/workers/agent-runner.ts b/apps/api/src/workers/agent-runner.ts index 22907379..9d6f7124 100644 --- a/apps/api/src/workers/agent-runner.ts +++ b/apps/api/src/workers/agent-runner.ts @@ -23,6 +23,8 @@ import { settings as runtimeSettings, type Settings } from "@workhub/config"; import { eventTypes, evidenceRefSchema, + readAgentRunReminderFacts, + type AgentRunReminderFacts, type ConfidenceVerdict, type CuuState, type EvidenceRef, @@ -193,6 +195,10 @@ export type AgentRunQueueRecord = { estimated_cost_cny: string; }; trace: AgentRunTraceStepRecord[]; + // R26 批 B6b:这次运行里「重复动作被劝过几次、劝的是什么」。每条对应一次 agent_run.reminded + // (前两档;第三档走升级,不发提醒)。**不进 trace**:提醒不是模型的一步,占了步号会让 + // 「跑了几步」撒谎;渲染层按 step_no 把它插在所属步骤之后。缺席与空数组同义。 + reminders?: AgentRunReminderFacts[]; handoff?: { done: string[]; remaining: string[]; @@ -211,6 +217,13 @@ export type AgentRunQueueRecord = { updated_at: string; }; +/** + * R26 批 B6b:单次运行最多保留的提醒条数。正常上限是 2(第一档一次、第二档一次,第三档改走升级), + * 32 纯属防御——真出现「每步都重新命中第一档」的病态序列时,别让一列 jsonb 无限长、也别让时间线被 + * 同一句话刷屏。到顶后丢新的留旧的:最早那两条(第一/第二档)才是解释这次运行为什么会升级的证据。 + */ +export const AGENT_RUN_REMINDER_CAP = 32; + // chain1/rank2:proposal.opened 的目标话题——既发工作项流(看改动/审批页等订阅者),也发派活用户的 per-user // /me 流。桌面富 Cuu 决策卡只订 topics.user,不发 /me 则旗舰「AI 把决策端到你面前」降级成一条干巴巴通知。 // agent-runs.test 的真实 run 集成用例端到端断言两个话题都收到。 @@ -1882,7 +1895,26 @@ export function createInMemoryAgentRunQueue(options: { }; } }, - emit: (event) => emitRunEvent(event, current), + // B6b 观测面:提醒在这里累进运行记录。提醒不是模型的一步(不进 trace、不占步号),但 + // 「被劝过几次、劝的是什么」必须留在记录里——否则回放页(读的是库)永远看不到这一行, + // 换个 worker 接手也不知道已经劝过。累加不额外触发一次写:随这次运行既有的落盘路径 + // (终态/失败的 persistRunWithTrace)一并进 agent_runs.reminders_json。 + emit: (event) => { + if (event.type === eventTypes.agentRunReminded) { + const facts = readAgentRunReminderFacts(event.data); + const live = runs.get(current.run_id); + // 与 recordStep 同款守卫:run 已被取消/租约被回收就不再往记录里写。 + if (facts && live?.status === "running" && (live.reminders?.length ?? 0) < AGENT_RUN_REMINDER_CAP) { + current = updateRun({ + ...live, + // 同 recordStep:recordUsage 只写 current 不写 runs map,spread live 会把最新用量盖掉。 + usage: current.usage, + reminders: [...(live.reminders ?? []), facts] + }); + } + } + return emitRunEvent(event, current); + }, now }; const result = loop2Mode === "off" From c454e6620c855a052c10c73e4759bacb0434b535 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:29:12 +0800 Subject: [PATCH 19/23] =?UTF-8?q?fix(api):=20=E5=8F=98=E6=9B=B4=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=B8=A4=E6=9D=A1=E6=B6=88=E6=81=AF=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E5=B7=A5=E4=BD=9C=E9=A1=B9=E3=80=8D=E4=B9=9F=E6=94=B6?= =?UTF-8?q?=E6=88=90=E3=80=8C=E4=BB=BB=E5=8A=A1=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grep「事项 / 工单」漏了 glossary §11 里同为退役别名的「工作项」。proposals 路由的两条 415 消息(在线预览 / 逐行比对都拿不到文本)用户看得见,同样搬进词典再改词。 英文侧此前不存在(这两条只有中文),按词典口径补上。 ui-i18n 棘轮基线 2768 → 2766,相对本工包开工点净减 29、零新增。 Co-Authored-By: Claude Fable 5.1 --- apps/api/src/routes/proposals.ts | 8 ++++++-- apps/api/src/services/locales.ts | 8 ++++++-- scripts/dev/ui-i18n-baseline.json | 2 -- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/proposals.ts b/apps/api/src/routes/proposals.ts index 5643bf7f..b781091b 100644 --- a/apps/api/src/routes/proposals.ts +++ b/apps/api/src/routes/proposals.ts @@ -880,7 +880,11 @@ export function createProposalRoutes(deps: ProposalRoutesDependencies = {}) { } const text = change.machine_summary?.generated_content_md; if (!text) { - throw new ProposalServiceError(415, "proposal_change_preview_unsupported", "这条变更没有可在线预览的文本。采纳后可到工作项或网盘查看正式版。"); + throw new ProposalServiceError( + 415, + "proposal_change_preview_unsupported", + serviceT("zh-CN", "changePreviewUnsupported") + ); } const filename = change.target_ref.path?.split("/").filter(Boolean).pop() ?? change.target_ref.entity_type; const maxPreviewChars = 200000; @@ -915,7 +919,7 @@ export function createProposalRoutes(deps: ProposalRoutesDependencies = {}) { throw new ProposalServiceError( 415, "proposal_change_diff_unsupported", - "这条变更没有可比对的文本内容。采纳后可到工作项或网盘查看正式版。" + serviceT("zh-CN", "changeDiffUnsupported") ); } // 解析 base 全文:created 变更 base 天然为空不用读;其它类型从快照读,读不到/DB 未就绪时 fail-open diff --git a/apps/api/src/services/locales.ts b/apps/api/src/services/locales.ts index e74f27a4..5dfc38ac 100644 --- a/apps/api/src/services/locales.ts +++ b/apps/api/src/services/locales.ts @@ -140,7 +140,9 @@ const zh = { agentRunNotStartableStatus: "当前任务状态不能启动 AI,请刷新后再试。", approvalWithoutTaskContext: "无任务上下文的审批只能路由给自己。", approvalsCappedMore: "待审批的任务比这里显示的更多——去审批页看完整清单。", - planDispatchAuditableFlow: "后续执行会继续进入可审计的任务流。" + planDispatchAuditableFlow: "后续执行会继续进入可审计的任务流。", + changePreviewUnsupported: "这条变更没有可在线预览的文本。采纳后可到任务或网盘查看正式版。", + changeDiffUnsupported: "这条变更没有可比对的文本内容。采纳后可到任务或网盘查看正式版。" } as const; const en = { @@ -259,7 +261,9 @@ const en = { agentRunNotStartableStatus: "This task's current status can't start AI. Refresh and try again.", approvalWithoutTaskContext: "An approval with no task context can only be routed to you.", approvalsCappedMore: "There are more pending approvals than shown here — open Approvals for the full list.", - planDispatchAuditableFlow: "Execution will continue in the auditable task flow." + planDispatchAuditableFlow: "Execution will continue in the auditable task flow.", + changePreviewUnsupported: "There is no text in this change to preview. Once accepted, the official version is on the task or in the drive.", + changeDiffUnsupported: "There is no text in this change to compare. Once accepted, the official version is on the task or in the drive." } as const satisfies Record; export type ServiceCopyKey = keyof typeof zh; diff --git a/scripts/dev/ui-i18n-baseline.json b/scripts/dev/ui-i18n-baseline.json index 54968fdc..1448befa 100644 --- a/scripts/dev/ui-i18n-baseline.json +++ b/scripts/dev/ui-i18n-baseline.json @@ -289,8 +289,6 @@ "这份计划已经处理过了,请刷新后再看。": 1, "这是一份可审计的交付物变更申请。": 1, "这是一份可审计的任务计划提议。": 1, - "这条变更没有可在线预览的文本。采纳后可到工作项或网盘查看正式版。": 1, - "这条变更没有可比对的文本内容。采纳后可到工作项或网盘查看正式版。": 1, "这次变更留有回滚快照,需要恢复时可人工处理。": 1, "这次变更缺少可用回滚快照。": 1, "需要修改": 1 From 84c11ccc553364c4836c0ec6e54c042c8b7ffbe8 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:29:46 +0800 Subject: [PATCH 20/23] =?UTF-8?q?docs:=20F3=20Agent=20Note=20=E8=A1=A5?= =?UTF-8?q?=E3=80=8C=E5=B7=A5=E4=BD=9C=E9=A1=B9=E3=80=8D=E8=BF=99=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E4=B8=8E=E6=9C=80=E7=BB=88=E5=9F=BA=E7=BA=BF=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...-09-06-routes-copy-and-desktop-error-codes.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/2026-09-06-routes-copy-and-desktop-error-codes.md b/.agents/notes/implemented/2026-09-06-routes-copy-and-desktop-error-codes.md index c9cfa220..932272dc 100644 --- a/.agents/notes/implemented/2026-09-06-routes-copy-and-desktop-error-codes.md +++ b/.agents/notes/implemented/2026-09-06-routes-copy-and-desktop-error-codes.md @@ -25,7 +25,12 @@ M8 的 Note 结尾留了两条互不相干、却都卡在「没人接手」的 门禁按「文件 + 归一化片段」记棘轮(`scripts/dev/check-ui-i18n.ts`),就地改一句话会同时产生 「基线条目已消失」与「新增违规」——净效果是门禁变红而账没清。所以 21 处**每一处**都先搬进 `apps/api/src/services/locales.ts`(M8 建的那份),调用点改成 `serviceT(...)`,再改词。 -基线只减:2795 → 2768(净减 27,零新增;片段计数 > 站点数是因为同一句话在同一文件里出现多次)。 +基线只减:2795 → 2766(净减 29,零新增;片段计数 > 站点数是因为同一句话在同一文件里出现多次)。 + +**「工作项」也是同一个词的退役别名**。工位给的 grep 是「事项|工单」,收口之后再扫一遍才发现 +`proposals.ts` 的两条 415 消息(在线预览 / 逐行比对拿不到文本)说的是「工作项」——glossary §11 +把「工作项、事项、工单、需求单」并列为同一个退役别名集。同一批一起收,否则下一个人还得再扫一遍。 +这两条此前只有中文,英文侧按词典口径补齐。 **词典从「服务层的」变成「API 层的」**。routes 与 services 各写一份等价词典是第二份会漂移的表: 「没有找到这个任务。」这一句在 routes 里出现 6 次、在 services 里已经有一条 `taskNotFound`。 @@ -101,14 +106,15 @@ MCP 区不渲、清单一次都不拉,而插件区照渲。 全仓没有断言依赖这三处(唯一一条断言产线消息的 `agent-runs.test.ts` 已同步)。 - **`services/locales.ts` 里 `taskCommentsNotFound` 与 `taskNotFound` 现在只差一条英文尾巴**。 前者的唯一调用点在 `work-item-comments.ts`(不在本工位的允许改动范围),合并成一条留给下一批。 -- **「事项 / 工单」没有门禁**。`check-copy-terms.ts` 的禁词表里没有这两个词,所以这次收口全靠 - 人扫一遍 grep,明天就可能被一句新代码带回来。把它们加进禁词表会一次性暴露 60 余处存量 - (`packages/agent` 的提示词、`packages/cuu`、`apps/web` 等),那是一批独立的账,不在这里开。 +- **「事项 / 工单 / 工作项」没有门禁**。`check-copy-terms.ts` 的禁词表里没有这几个词,所以这次 + 收口全靠人扫一遍 grep(并且第一遍就漏了「工作项」),明天就可能被一句新代码带回来。 + 把它们加进禁词表会一次性暴露 60 余处存量(`packages/agent` 的提示词、`packages/cuu`、 + `apps/web` 等),那是一批独立的账,不在这里开——但它是这条纪律唯一真正的保险。 - **`packages/agent` 的「工单」一个字没动**:那是模型可见文本,与 golden 基线绑在一起, 按工位纪律与 golden 一起走。`routes/drive.ts` 与 `routes/meetings.ts` 各有一处 `requireUuidParam(…, "事项")` 也没动——两个文件不在本工位的允许改动清单里。 - **模型可见文本零变化**:`pnpm gen:expected` 两次运行都无 diff。 -- **棘轮基线只减**:ui-i18n 2795 → 2768(净减 27,零新增);禁词基线 49 条不变。 +- **棘轮基线只减**:ui-i18n 2795 → 2766(净减 29,零新增);禁词基线 49 条不变。 - **桌面端真机未验**(浏览器预览没有 Tauri 运行时,`desktop-webview` 渲染不出):一台真的连不上 的服务器,行内那句「原因 + 重连预算 +(诊断)」在窄栏里会比 M7 那版更长,换行值得在 `.app` 上看一眼。 From aa84e166653d2afddf8db6157cd8bc1a7c0201ce Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:30:34 +0800 Subject: [PATCH 21/23] =?UTF-8?q?test(b6b):=20r1=20=E7=9C=9F=20PG=20?= =?UTF-8?q?=E9=97=A8=E6=96=AD=E8=A8=80=200075=20=E7=9A=84=20reminders=5Fjs?= =?UTF-8?q?on=20=E8=90=BD=E5=9C=A8=E5=BA=93=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列不在就等于「Cuu 被劝过几次」只活在 SSE 流里——回放页读的是库,永远看不到那一行。 一并钉住可空、无默认值:null 与空数组同义,既有行不用回填。 本机一次性 postgres:16 容器实跑通过;把列 drop 掉后这条断言按预期报错(非空跑)。 Co-Authored-By: Claude Fable 5.1 --- apps/api/src/qa/r1-pg-agent-run-smoke.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/api/src/qa/r1-pg-agent-run-smoke.ts b/apps/api/src/qa/r1-pg-agent-run-smoke.ts index 5b5b7b2a..3c3a72ce 100644 --- a/apps/api/src/qa/r1-pg-agent-run-smoke.ts +++ b/apps/api/src/qa/r1-pg-agent-run-smoke.ts @@ -296,6 +296,27 @@ async function main() { try { const db = client.db; await ensureDefaultSeed(db); + // R26 批 B6b:迁移 0075 的 agent_runs.reminders_json 必须真落在库里,而且**可空、无默认值**。 + // 列不在,就等于「Cuu 被劝过几次」只活在 SSE 流里——回放页读的是库,永远看不到那一行; + // 列若写成 NOT NULL/带默认,既有行与「这次没被劝过」的新行就无处安放(null 与空数组同义)。 + const remindersColumnResult = await db.execute( + "select data_type, is_nullable, column_default from information_schema.columns" + + " where table_name = 'agent_runs' and column_name = 'reminders_json'" + ); + type RemindersColumnRow = { data_type: string; is_nullable: string; column_default: string | null }; + const remindersColumnRows = (remindersColumnResult as unknown as { rows?: RemindersColumnRow[] }).rows + ?? (remindersColumnResult as unknown as RemindersColumnRow[]); + const remindersColumn = remindersColumnRows[0]; + if ( + !remindersColumn + || remindersColumn.data_type !== "jsonb" + || remindersColumn.is_nullable !== "YES" + || remindersColumn.column_default !== null + ) { + throw new Error( + `Expected migration 0075 to add a nullable jsonb agent_runs.reminders_json with no default, got ${JSON.stringify(remindersColumnRows)}` + ); + } const userRepo = createUserRepository(db); const deviceRepo = createClientDeviceRepository(db); const auth: AuthDependencies = { From 4d02107187f2d6a7162456d9772066a6a2f4992b Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:35:44 +0800 Subject: [PATCH 22/23] =?UTF-8?q?docs(b6b):=20Agent=20Note=20=E8=AE=B0?= =?UTF-8?q?=E3=80=8C=E6=8F=90=E9=86=92=E8=BF=9B=E8=BF=90=E8=A1=8C=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E5=BA=93=E3=80=8D=E7=9A=84=E5=8F=96=E8=88=8D?= =?UTF-8?q?=E4=B8=8E=E8=90=BD=E7=9B=98=E8=8A=82=E5=A5=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写清三件容易被后人当成漏接线的取舍:为什么存 agent_runs 一列 jsonb 而不是新开表/塞 trace; 为什么累加不额外发一次写(run 行一次执行只写三次,中途崩掉的 run 会从头重跑,上一轮提醒 本就是过期数据);以及新引入的 persistence↔runner ESM 环为什么无 TDZ 风险。 Co-Authored-By: Claude Fable 5.1 --- .../2026-09-06-b6b-reminders-persist.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .agents/notes/implemented/2026-09-06-b6b-reminders-persist.md diff --git a/.agents/notes/implemented/2026-09-06-b6b-reminders-persist.md b/.agents/notes/implemented/2026-09-06-b6b-reminders-persist.md new file mode 100644 index 00000000..1d035fdc --- /dev/null +++ b/.agents/notes/implemented/2026-09-06-b6b-reminders-persist.md @@ -0,0 +1,106 @@ +# 重复动作提醒进运行记录与库(agent_runs.reminders_json) + +- Status: implemented +- Date: 2026-09-06 +- Owner: claude-code + +## Problem + +B6(`.agents/notes/implemented/2026-09-06-b6-reminder-events.md`)把「先劝再断」的前两档做成了 +`agent_run.reminded` 事件,契约面开了 `agentRunReminderFactsSchema`,三个运行 VM 各留了一个 +additive optional 的 `reminders`,`packages/ui` 与桌面 Spotlight 也都写好了渲染。唯独中间那段没接: +**没有人把事件攒进运行记录**,于是 `reminders` 恒为缺席,两端时间线的表现与改动前一字不差。 + +这段缺口有两个后果,第二个更要命: + +1. 回放页读的是库,不是 SSE 流。运行结束之后再打开回放,提醒那一行永远不存在——「劝过」这件事 + 在人能回头看的地方从来没发生过。 +2. 事件是转瞬即逝的。worker 换人接手(租约回收/重排)、或进程重启之后,已经发生的提醒既不在 + 内存里也不在库里,连「这次运行已经被劝过一档了」都无从得知。 + +B6 那份档案自己把这一跳记成了遗留(「这一段要连持久化一起做,否则 worker 重启后已发生的提醒会丢」)。 +这一批就是来补的。 + +## Decision + +**存成 `agent_runs` 上的一列 jsonb(迁移 0075 `reminders_json`),不新开表、不塞 trace。** +形状与同表的 `handoff_json` / `budget_decision_json` 完全同款:一列 jsonb 装一个数组,每个元素就是 +contracts 的 `agentRunReminderFactsSchema`(`step_no` / `tier` / `repeats` / `shape` / `tool_id?` / `tool_ids?`)。 +提醒天然是 run 的附属事实、条数以个位数计、永远随 run 一起读写,够不上一张表。 + +**不落 `agent_steps`**——这条是 B6 已经拍过的否决项,这批照旧执行:提醒不是模型的一步,给它发一个 +步号会让「跑了几步」这个数字开始撒谎;而且 `AgentRunTraceStepRecord` 只有六个标量列,塞提醒得先给 +`agentStepPhases` 加枚举值再加迁移,语义和成本双输。渲染层按 `step_no` 把提醒插在**所属步骤之后**, +它自己不占号。 + +**列可空、无默认值;`null` 与空数组同义。** 既有行不用回填,新行没被劝过就是 `null`。写入侧空数组 +不落列(`toPersistenceRun` 里 `run.reminders?.length` 才带这个键),读取侧空数组读回缺席 +(`queueReminders` 空则 `undefined`)。理由是别让「这一列有没有内容」多出一个没有语义差别的第三态: +`undefined` / `null` / `[]` 三者在界面上都是「时间线不渲提醒行」,那就只保留一种表达。 +省略键还有一个附带好处:drizzle 在 UPDATE 里跳过 `undefined`,一次不带提醒的进度写不会把已存的 +`reminders_json` 清成 `null`。 + +**在事件出门的那一处累加,不额外发一次写。** 运行器把 `emit` 从 `(event) => emitRunEvent(event, current)` +改成先认一下 `agent_run.reminded`、用 `readAgentRunReminderFacts` 解析(事件专属的 `run_id` / +`work_item_id` 由它自动剥掉)再追加进 `current.reminders`,然后照旧发事件。累加**不触发**任何新的 +DB 写:它随这次运行既有的落盘路径(终态/失败的 `persistRunWithTrace`)一起进库,与 `usage`、`handoff` +的落盘节奏完全一致。 + +这条取舍值得写清楚,因为它决定了持久化的粒度:`agent_runs` 这一行在一次执行里只被写三次 +(enqueue 建行、起跑转 running、终态),中途每步只写 `agent_steps`(`replaceTrace`)和心跳。为了让提醒 +在**每一步之后**就进库,要么给每条提醒多发一次 run 行的 UPDATE,要么把它搭到 `replaceTrace` 的事务里 +去顺手改另一张表。前者是为一年也见不到几次的事实加一条常驻写放大,后者让 `replaceTrace` 名不副实。 +两条都不划算:中途崩掉的 run 会被 `requeueExpiredClaims` 重排并**从头重跑**,trace 整个被 `replaceTrace` +换掉——上一轮的提醒本来就是过期数据,特意为它保命反而会让时间线出现两轮混在一起的提醒。所以「随 +既有落盘路径」既是省一次写,也是对的语义。 + +**累加带守卫,且封顶 32 条。** 守卫与 `recordStep` 同款:从 `runs` map 取 live 记录、`status !== "running"` +(被取消/租约被回收)就不写;spread `live` 时显式带上 `current.usage`,否则 `recordUsage` 只写 `current` +不写 map 的最新用量会被盖掉(这个坑 `recordStep` 里已经踩过一次并留了注释)。 +`AGENT_RUN_REMINDER_CAP = 32` 纯属防御——正常一次运行最多两条(第一档一次、第二档一次,第三档改走 +`agent_run.escalated`),真出现病态序列时别让一列 jsonb 无限长。到顶后**丢新的留旧的**:最早那两条才是 +解释这次运行为什么会升级的证据,后面的重复只是噪声。读取侧用同一个常量、同一个方向截断。 + +**读回宽容到条,不宽容到列。** `queueReminders` 逐条走 `readAgentRunReminderFacts`,解析不出来的整条 +丢掉(宁可少一行,也绝不把半截数据编成一句话);整列不是数组(历史脏数据/手改)则整列当缺席,不猜、 +不半读。两端渲染层本来就共用同一份宽容读取,服务端这层是同一条纪律往前挪了一站。 + +**两个 VM 各加一行透传,仍然是 additive optional。** `toAgentRunLiveVm`(实时页)与 +`buildReplayTracePage`(回放页)都只在 `reminders?.length` 时带这个键。第三个 VM +`agentRunTraceVmSchema` 仓内没有任何生产者(纯契约面),这批不造一个出来。 + +## Alternatives considered + +- **每条提醒多发一次 run 行 UPDATE**,让提醒在下一步之前就落库:见 Decision——为极低频事实加常驻写 + 放大,而中途崩掉的 run 会从头重跑、上一轮提醒本就是过期数据,保住它反而让时间线更乱。 +- **把 `reminders_json` 的写搭进 `replaceTrace` 的事务**(那是每步都会跑的既有写):一次调用改两张表, + `replaceTrace` 这个名字就不再说实话;而且每步都要重写整列,写放大反而更大。 +- **另起一张 `agent_run_reminders` 表**:条数以个位数计、永远随 run 一起读写、没有任何独立查询需求, + 一张表换来的是一次 join、一份迁移、一套仓储方法。`handoff_json` 当年也是同样的判断。 +- **让前端从 SSE 流自己攒**:B6 已经否过一次——仓内没有任何前端保留 run 事件用于展示(回放页收到事件 + 是触发整页重拉),只发事件等于观测面停在「有一条 SSE,没人渲」。这批只是把结论落到服务端。 +- **提醒也塞进 trace 步骤**(复用现成的 `AgentStep[]` 渲染):B6 已否——占步号会让「跑了几步」撒谎。 +- **读取侧不做上限**:读回的本来就是写入侧写的,理论上不会超。但脏数据/手改是真实存在的运维面, + 一列畸形 JSON 不该能把整页时间线撑爆,所以两侧同一个常量各截一次。 + +## Consequences + +- **迁移 0075**(`0075_agent_run_reminders.sql`,journal `idx:75`、`when: 1783929007000`,严格大于 0074)。 + 全 additive:`ADD COLUMN IF NOT EXISTS`、可空、无默认,migration-audit 的整链 replay 重跑安全。 +- **`AgentRunQueueRecord` 多一个可选字段**。所有 spread 式的记录构造(`finalizeExecutedRun`、 + `recordStep`、恢复路径的 `toQueueRun`)天然带着它走,无需逐处补写。 +- **落盘节奏 = 与 `usage` / `handoff` 同档**:run 行的写发生在 enqueue / 起跑 / 终态三处。运行**结束后** + 的回放页一定看得到提醒;运行**进行中**换 worker 接手则看不到上一轮的提醒——但那一轮本来就要从头重跑。 + 这是刻意的取舍,不是漏接线。 +- **`agent_run.reminded` 事件本身一字未动**,`packages/agent` / `packages/contracts` / `packages/ui` / + 桌面端全部零改动(B6 已定型)。openapi 的 `agentRunLive` / `replayTrace` 两个响应 schema 在 B6 就已经 + 声明了 `reminders`(两者都是 `additionalProperties: false`,漏声明等于把这一列宣布成非法字段), + 这批不用再动。 +- **模型可见文本零变化**:`buildDoomLoopReminder` 没碰,`pnpm gen:expected` 跑完无 diff。 +- **真 PG 门加了一条断言**:`qa:r1-pg-agent-run-smoke` 在迁移跑完后查 `information_schema`,要求 + `agent_runs.reminders_json` 是**可空、无默认**的 jsonb。列不在就等于「被劝过几次」只活在 SSE 流里。 + 本机一次性 `postgres:16` 容器实跑通过;把该列 drop 掉后这条断言按预期报错(验证非空跑)。 +- **`agent-run-persistence.ts` 现在从 `workers/agent-runner.js` 取一个值**(`AGENT_RUN_REMINDER_CAP`), + 与既有的反向 import(agent-runner → `getDefaultAgentRunPersistence`)构成一个 ESM 环。两边都只在函数体里 + 用对方的绑定、模块顶层不读,因此没有 TDZ 风险;`apps/api` 全量测试与 typecheck 已覆盖两种加载顺序。 + 真要拆环,该常量应该搬到一个两边都能引的小模块,那超出本批范围。 From b05eb13811698fdac64299d5fbb149066c52cd10 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 6 Sep 2026 03:35:54 +0800 Subject: [PATCH 23/23] =?UTF-8?q?fix(api):=20=E7=BD=91=E7=9B=98=E4=B8=8E?= =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E8=B7=AF=E7=94=B1=E7=9A=84=20404=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E3=80=8C=E4=BA=8B=E9=A1=B9=E3=80=8D=E6=8C=89=E8=AF=8D?= =?UTF-8?q?=E8=A1=A8=E6=94=B6=E6=88=90=E3=80=8C=E4=BB=BB=E5=8A=A1=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- apps/api/src/routes/drive.ts | 3 ++- apps/api/src/routes/meetings.ts | 3 ++- apps/api/src/services/locales.ts | 2 ++ scripts/dev/ui-i18n-baseline.json | 2 -- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/drive.ts b/apps/api/src/routes/drive.ts index 4e7b6ae4..f308340c 100644 --- a/apps/api/src/routes/drive.ts +++ b/apps/api/src/routes/drive.ts @@ -20,6 +20,7 @@ import { } from "../services/drive-pages.js"; import { jsonObjectFromText, readJsonObject } from "./json-body.js"; import { isUuidParam } from "./uuid-param.js"; +import { serviceT } from "../services/locales.js"; export type DriveRoutesDependencies = { auth?: AuthDependencySource; @@ -547,7 +548,7 @@ export function createDriveRoutes(deps: DriveRoutesDependencies = {}) { const data = await drivePages.draftToProposal({ actor: c.var.actor, locale, - workItemId: requireUuidParam(c.req.param("workItemId"), "事项") + workItemId: requireUuidParam(c.req.param("workItemId"), serviceT("zh-CN", "taskLabel")) }); return c.json(pageEnvelope(data, locale)); } catch (error) { diff --git a/apps/api/src/routes/meetings.ts b/apps/api/src/routes/meetings.ts index 94b2409c..0005bf40 100644 --- a/apps/api/src/routes/meetings.ts +++ b/apps/api/src/routes/meetings.ts @@ -16,6 +16,7 @@ import { } from "../services/meeting-pages.js"; import { isUuidParam } from "./uuid-param.js"; import { readJsonObject } from "./json-body.js"; +import { serviceT } from "../services/locales.js"; // R10-P2-2:导入会议转写请求体。 const importMeetingTranscriptSchema = z.object({ @@ -146,7 +147,7 @@ export function createMeetingRoutes(deps: MeetingRoutesDependencies = {}) { const data = await meetingPages.draftToProposal({ actor: c.var.actor, locale, - workItemId: requireUuidParam(c.req.param("workItemId"), "事项") + workItemId: requireUuidParam(c.req.param("workItemId"), serviceT("zh-CN", "taskLabel")) }); return c.json(pageEnvelope(data, locale)); } catch (error) { diff --git a/apps/api/src/services/locales.ts b/apps/api/src/services/locales.ts index 5dfc38ac..80343d31 100644 --- a/apps/api/src/services/locales.ts +++ b/apps/api/src/services/locales.ts @@ -105,6 +105,7 @@ const zh = { taskEditForbidden: "你没有权限修改这个任务。", taskDeliverableEditForbidden: "你没有权限修改这个任务的正式交付物。", taskNotFound: "没有找到这个任务。", + taskLabel: "任务", taskCannotRefinalize: "这个任务的当前状态不允许重新定稿;若刚提交过新的澄清回答,请刷新后重试。", knowledgeSearchScopeRequired: "请在具体任务或项目内检索。", @@ -230,6 +231,7 @@ const en = { taskEditForbidden: "You don't have permission to edit this task.", taskDeliverableEditForbidden: "You don't have permission to edit this task's accepted deliverables.", taskNotFound: "That task wasn't found.", + taskLabel: "task", taskCannotRefinalize: "This task's current status doesn't allow re-finalizing. If you just submitted new answers, refresh and try again.", knowledgeSearchScopeRequired: "Search inside a specific task or project.", diff --git a/scripts/dev/ui-i18n-baseline.json b/scripts/dev/ui-i18n-baseline.json index 1448befa..c21f8921 100644 --- a/scripts/dev/ui-i18n-baseline.json +++ b/scripts/dev/ui-i18n-baseline.json @@ -160,7 +160,6 @@ }, "apps/api/src/routes/drive.ts": { "JSON 上传必须包含文件内容 parsed_text;如果要上传本地文件,请使用文件选择器。": 1, - "事项": 1, "你没有权限管理这个项目网盘。": 1, "文件": 4, "没有找到这个 。": 1, @@ -181,7 +180,6 @@ "没有找到可访问的项目。": 1 }, "apps/api/src/routes/meetings.ts": { - "事项": 1, "会议": 1, "会议洞察": 2, "没有找到这个 。": 1,