跨端: 会话改名建议的接口层(详情页缺的第二块,服务端三个端点齐)
审计发现详情页缺三块功能之二:**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:
@ -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),
|
||||
'★ 路径写错时上面那条断言必须能判红');
|
||||
});
|
||||
|
||||
@ -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],
|
||||
|
||||
59
client/harmony/entry/src/main/ets/api/SessionApi.ets
Normal file
59
client/harmony/entry/src/main/ets/api/SessionApi.ets
Normal 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', {});
|
||||
}
|
||||
}
|
||||
19
client/harmony/entry/src/main/ets/model/SessionRename.ets
Normal file
19
client/harmony/entry/src/main/ets/model/SessionRename.ets
Normal 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 = '';
|
||||
}
|
||||
Reference in New Issue
Block a user