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

审计发现详情页缺三块功能之二:**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

@ -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 = '';
}