跨端: 会话改名建议的接口层(详情页缺的第二块,服务端三个端点齐)

审计发现详情页缺三块功能之二:**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: "内容正文。<!-- agentmail:rename-session alias=\"rename-verify\" reason=\"验证改名建议链路\" -->"}

    → 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 接线(详情页的提示条)。接口层已完成并验证,
但"页面上真的显示建议条并能点接受/驳回"要下一批。
This commit is contained in:
2026-09-19 17:12:40 +08:00
parent b7c5d8b1e7
commit c2f35d1023
4 changed files with 137 additions and 1 deletions

View File

@ -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),
'★ 路径写错时上面那条断言必须能判红');
});

View File

@ -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],

View File

@ -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 在正文里提的)。
*
* 服务端从正文里解析 `<!-- agentmail:rename-session alias="…" reason="…" -->`
* 标记(`rename_proposal.go`),并把标记从正文剥掉 —— 所以页面拿到的是
* "建议"与"干净正文"两件事。
*/
async getRenameProposal(sessionId: string): Promise<RenameProposal | null> {
const resp: RenameProposalEnvelope =
await this.client.get<RenameProposalEnvelope>('/sessions/' + sessionId + '/rename-proposal');
return resp.proposal;
}
/**
* **接受**建议。
*
* ★ 接受走的是 `PUT /sessions/{id}/alias`(与"人手改别名"**同一个端点**)——
* WebUI 侧也是这么做的(`sessionStore.acceptRename` 调 `updateSessionAlias`)。
* 不为"接受建议"单开一个端点:那会让同一件事有两条实现,
* 而别名冲突(409)的判定只需一处。
*/
async acceptRename(sessionId: string, alias: string): Promise<void> {
await this.client.put('/sessions/' + sessionId + '/alias', { session_alias: alias });
}
/**
* **驳回**建议。服务端记下来,不再反复弹同一个。
*
* 与"接受"不同,这条有**专用端点**(`/rename-proposal/dismiss`)——
* 因为"驳回"这件事在别名端点里没有对应物(它不是改别名,是"别再问了")。
*/
async dismissRename(sessionId: string): Promise<void> {
await this.client.post('/sessions/' + sessionId + '/rename-proposal/dismiss', {});
}
}

View File

@ -0,0 +1,19 @@
/**
* 会话改名建议(Agent 在正文里提的)。
*
* 服务端形状见 `server/internal/handler/rename_proposal.go`:
* 正文里的 HTML 注释标记
* `<!-- agentmail:rename-session alias="fix-login-leak" reason="…" -->`
* 被解析成这个结构,标记本身从正文剥掉。
*
* ★ 为什么这件事重要(WebUI 注释里的原话):
* 「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」——
* 所以是**提议 + 人确认**,而不是 Agent 单方面改。
* 人的确认是**寻址稳定性**的最后一道。
*/
export class RenameProposal {
/** 建议的新别名(不带点;页面上显示成 `.xxx`) */
alias: string = '';
/** Agent 给的理由(可空) */
reason: string = '';
}