From c2f35d1023f471a38d28f62bc749bdce20727794 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 17:12:40 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E5=BB=BA=E8=AE=AE=E7=9A=84=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=B1=82=EF=BC=88=E8=AF=A6=E6=83=85=E9=A1=B5=E7=BC=BA=E7=9A=84?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E5=9D=97=EF=BC=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=E7=AB=AF=E7=82=B9=E9=BD=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计发现详情页缺三块功能之二:**Agent 的改名建议条**(WebUI `MailView.tsx:324` 的 `RenameProposalBar`)。这一批做**接口层**,UI 接线下一批。 ## 为什么这件事不只是"少个提示条" WebUI 那段的注释写得很清楚: > Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。 > 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。 也就是**寻址稳定性**的设计 —— 别名是人的寻址入口,Agent 只能**提议**。 ## 做了什么 1. `model/SessionRename.ets` —— `RenameProposal`(`alias` / `reason`, 字段名与服务端 json tag **逐字对齐**) 2. `api/SessionApi.ets` —— 三个动作: · `getRenameProposal(sessionId)` → `{"proposal": {...}}` 或 `{"proposal": null}` · `acceptRename(id, alias)` → **`PUT /sessions/{id}/alias`** · `dismissRename(id)` → `POST /sessions/{id}/rename-proposal/dismiss` 3. 判据(`harmony-logic`,+1 条):字段名、响应外壳容忍 `null`、 接受走别名端点、驳回走专用端点、方法名与 WebUI store 同名、+ 变异自检。 ★ **接受为什么不另开端点**(服务端注释原话): 「那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方」。 所以"接受建议"与"人手改别名"是**同一条路**,而"驳回"是另一条 (它不是改别名,是"别再问了"——服务端记下被驳回的别名, 否则每次打开会话都要重新点一次「忽略」)。 ## 端到端验证(真数据,不是形态检查) 造了一封带标记的真邮件投进一个真会话: POST /mail/send {reply_to: …, body: "内容正文。"} → 200 {"rename_proposed":"rename-verify", …} 然后: GET /sessions/{id}/rename-proposal → {"proposal":{"alias":"rename-verify","reason":"验证改名建议链路"}} ✓ 形状与我的一致 SELECT body FROM mails … → "内容正文。" ✓ 标记被剥掉 两件事都验到了:**服务端识别建议**,且**标记从人读的正文里剥离** (HTML 注释在 Markdown 渲染器里会变成可见文本,所以必须剥,不能指望渲染器吞掉)。 ## 判据 `run-all.mjs` → `checks=510 pass=510 fail=0 skip=0 red=0 broken=0 unreported=0`。 `harmony-logic` 30 → 31。`hvigorw assembleHap` 成功;前端重建 + 重打包。 **未做**:UI 接线(详情页的提示条)。接口层已完成并验证, 但"页面上真的显示建议条并能点接受/驳回"要下一批。 --- client/electron/test/harmony-logic.test.mjs | 58 ++++++++++++++++++ client/electron/test/run-all.mjs | 2 +- .../entry/src/main/ets/api/SessionApi.ets | 59 +++++++++++++++++++ .../src/main/ets/model/SessionRename.ets | 19 ++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 client/harmony/entry/src/main/ets/api/SessionApi.ets create mode 100644 client/harmony/entry/src/main/ets/model/SessionRename.ets diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index bfcaa69..2a07cde 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -671,3 +671,61 @@ test('★ 行为:手势触发的翻页与按钮触发的翻页走同一个函 assert.match(page, /\.onClick\(\(\)\s*=>\s*this\.shiftMonth\(-1\)\)/, '上一页按钮'); assert.match(page, /\.onClick\(\(\)\s*=>\s*this\.shiftMonth\(1\)\)/, '下一页按钮'); }); + +/* ───────── 会话改名建议:契约形状与两端行为一致(纯逻辑层) ───────── */ + +test('★ 改名建议:字段名与两端契约一致,接受/驳回走对端点(一个走别名、一个走专用端点)', () => { + /* + * 2026-09-19 审计发现:鸿蒙详情页**没有**改名建议条(WebUI 有, + * `MailView.tsx:324` 的 `RenameProposalBar`)。那件事对**寻址稳定性**很重要 + * (WebUI 注释原话:「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」)。 + * + * 这一批先把**接口层**做完(`api/SessionApi.ets` + `model/SessionRename.ets`), + * 这条判据钉住它与服务端的契约 —— 形状错了 UI 接上也读不出东西。 + * + * 服务端契约(`server/internal/handler/sessions.go:222`): + * GET → {"proposal": {"alias": "...", "reason": "..."}} 或 {"proposal": null} + * POST /rename-proposal/dismiss ← 驳回 + * PUT /sessions/{id}/alias ← 接受(**与"人手改别名"同一个端点**) + * + * ★ 为什么"接受"不另开端点(服务端注释原话): + * 「那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方」。 + */ + const model = code(join(HARMONY_ETS, 'model/SessionRename.ets')); + const api = code(join(HARMONY_ETS, 'api/SessionApi.ets')); + + /* ① 字段名逐字对齐(服务端是 alias / reason,不是 newAlias / why) */ + assert.match(model, /alias: string = ''/, + '★ 模型字段要叫 `alias`(与服务端 `RenameProposal.Alias` 的 json tag 一致)'); + assert.match(model, /reason: string = ''/, + '★ 模型字段要叫 `reason`(服务端 json tag 是 `reason,omitempty`)'); + assert.ok(!/newAlias|why:/.test(model), + '★ 不许自造字段名 —— 服务端不认识的名字会被静默忽略(读出来永远是空)'); + + /* ② 响应外壳:服务端把 proposal 包在一层里(且用 `null` 表示"没有建议") */ + assert.match(api, /proposal: RenameProposal \| null = null/, + '★ 响应外壳必须容忍 `null` —— 服务端在有/无建议时分别返回对象与 `null`,' + + '而不是 404("没有建议"是正常状态)'); + + /* ③ 接受走**别名端点**(与手改别名同一条路) */ + assert.match(api, /\/sessions\/' \+ sessionId \+ '\/alias'/, + '★ 接受建议要走 `PUT /sessions/{id}/alias` —— ' + + '服务端特意不另开端点(唯一性校验与 409 处理只该有一处)'); + assert.match(api, /session_alias: alias/, + '★ 别名端点的请求体字段是 `session_alias`(服务端 json tag)'); + + /* ④ 驳回走**专用端点**(它不是改别名,是"别再问了") */ + assert.match(api, /rename-proposal\/dismiss/, + '★ 驳回要走专用端点 `/rename-proposal/dismiss` —— ' + + '服务端记下被驳回的别名,否则每次打开会话都要重新点一次「忽略」'); + + /* ⑤ 方法名与语义一致(accept 不是 update、dismiss 不是 delete) */ + assert.match(api, /async acceptRename\(/, '要有 `acceptRename`(与 WebUI store 同名)'); + assert.match(api, /async dismissRename\(/, '要有 `dismissRename`(与 WebUI store 同名)'); + + /* ⑥ 锚点自检:把服务端路径改错必须能被抓到 */ + const mutated = api.replace("/sessions/' + sessionId + '/alias'", "/sessions/' + sessionId + '/alias-wrong'"); + assert.notEqual(mutated, api, '变异要有实际效果(锚点必须命中)'); + assert.ok(!/\/sessions\/' \+ sessionId \+ '\/alias'/.test(mutated), + '★ 路径写错时上面那条断言必须能判红'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index dda0e64..1c7f1c7 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -78,7 +78,7 @@ const SUITE = [ // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 // 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。 ['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], - ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30], + ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31], ['test/harmony-system-api.test.mjs', [], 5], // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27], diff --git a/client/harmony/entry/src/main/ets/api/SessionApi.ets b/client/harmony/entry/src/main/ets/api/SessionApi.ets new file mode 100644 index 0000000..9df13d4 --- /dev/null +++ b/client/harmony/entry/src/main/ets/api/SessionApi.ets @@ -0,0 +1,59 @@ +/** + * 会话级接口(`/sessions/{id}/...`)。 + * + * 为什么单独一个文件:会话与**邮件**是两个生命周期不同的东西 —— + * 邮件是内容,会话是"一条任务"(有别名、有往返预算、有权限档)。 + * 混进 `MailApi` 会让"这条路径属于谁"变得含糊(本仓已有一次 + * `MeApi` 被塞进 `AdminApi.ets` 的教训,那是历史原因,不重复)。 + */ +import { ApiClient } from './ApiClient'; +import { RenameProposal } from '../model/SessionRename'; + +/** `GET /sessions/{id}/rename-proposal` 的响应外壳 */ +class RenameProposalEnvelope { + /** `null` = 没有待处理的建议(服务端用 null 而不是 404,是"正常状态") */ + proposal: RenameProposal | null = null; +} + +export class SessionApi { + private client: ApiClient; + + constructor(client: ApiClient) { + this.client = client; + } + + /** + * 读一条会话的**改名建议**(Agent 在正文里提的)。 + * + * 服务端从正文里解析 `` + * 标记(`rename_proposal.go`),并把标记从正文剥掉 —— 所以页面拿到的是 + * "建议"与"干净正文"两件事。 + */ + async getRenameProposal(sessionId: string): Promise { + const resp: RenameProposalEnvelope = + await this.client.get('/sessions/' + sessionId + '/rename-proposal'); + return resp.proposal; + } + + /** + * **接受**建议。 + * + * ★ 接受走的是 `PUT /sessions/{id}/alias`(与"人手改别名"**同一个端点**)—— + * WebUI 侧也是这么做的(`sessionStore.acceptRename` 调 `updateSessionAlias`)。 + * 不为"接受建议"单开一个端点:那会让同一件事有两条实现, + * 而别名冲突(409)的判定只需一处。 + */ + async acceptRename(sessionId: string, alias: string): Promise { + await this.client.put('/sessions/' + sessionId + '/alias', { session_alias: alias }); + } + + /** + * **驳回**建议。服务端记下来,不再反复弹同一个。 + * + * 与"接受"不同,这条有**专用端点**(`/rename-proposal/dismiss`)—— + * 因为"驳回"这件事在别名端点里没有对应物(它不是改别名,是"别再问了")。 + */ + async dismissRename(sessionId: string): Promise { + await this.client.post('/sessions/' + sessionId + '/rename-proposal/dismiss', {}); + } +} diff --git a/client/harmony/entry/src/main/ets/model/SessionRename.ets b/client/harmony/entry/src/main/ets/model/SessionRename.ets new file mode 100644 index 0000000..6c1deaf --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/SessionRename.ets @@ -0,0 +1,19 @@ +/** + * 会话改名建议(Agent 在正文里提的)。 + * + * 服务端形状见 `server/internal/handler/rename_proposal.go`: + * 正文里的 HTML 注释标记 + * `` + * 被解析成这个结构,标记本身从正文剥掉。 + * + * ★ 为什么这件事重要(WebUI 注释里的原话): + * 「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」—— + * 所以是**提议 + 人确认**,而不是 Agent 单方面改。 + * 人的确认是**寻址稳定性**的最后一道。 + */ +export class RenameProposal { + /** 建议的新别名(不带点;页面上显示成 `.xxx`) */ + alias: string = ''; + /** Agent 给的理由(可空) */ + reason: string = ''; +}