Files
MailUI4Agents/plugins/dsh-mail-bridge/lib/rename-proposal.js
JianFeeeee 2996f9af9c fix(plugins): 409 时当场表态 + DSH 补投按会话串行
**409 = 永远不会成功**(没有人类可路由)。原来三个插件都在失败时让位给
平台本地 UI —— 但邮件驱动的会话**没有 TUI**,让位之后 waterfall 跑到尾
依旧无人应答,仍是无声挂死。

HTTP 客户端必须把 err.status 与 err.body 挂到 error 上:只看 message
字符串分不出「暂时失败(502,该重试)」与「永远不会成功(409)」,
两种都会被当成前者,而前者会永久挂住会话。

三平台表态方式不同但语义统一:
- opencode: output.status = "deny" + output.reason 带服务端原文
- dsh: return 'rejected'(ApprovalOutcome 只认 allowed-once/rejected/
  cancelled,写 'denied' 不报错而是被当未知值静默失效)
- pi: return { block: true, reason }

其余失败(502 等)保持原行为,让位本地 UI。

---

**DSH 补投并发**(同一文件,故并入本次提交)

生产日志:`补投 5 封(共 16 封未读)`,9 秒后三封失败
`message "undefined" is already pending`。串行 for...of 并未真正串行 ——
awaitFirstTurn 在**首个 token** 就放行,turn 尚未结束下一封已 followup。

新增 waitForTurnEnd(等 turn/end 而非首 chunk)与 sessionLocks/locked()
按会话串行化。live-agent 路径原来直接 followup 就返回,现在也进锁。
120s 超时兜底,模型完全无响应时不会把后续邮件永久卡住。

权限场景下锁会持有到人类决策完 —— 这是正确行为:两封都需要授权时
第二封排队,比同时弹两个授权请求更合理。

顺带把 rename-proposal 纳入 check-shared-libs.sh 的同源校验。
2026-09-03 21:10:48 +08:00

131 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 会话改名提议 —— 所有平台插件共用。
*
* # 这是什么
*
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
*
* # 为什么是「提议」而不是直接改
*
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
* 人上一秒记住的地址下一秒就 404`session` 位三态语义要求指向不存在的会话直接报
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
*
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
*
* | | 谁发起 | 何时 | 是否打扰人 |
* |---|---|---|---|
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
*
* # 为什么载体是 HTML 注释
*
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
*
* - react-markdown 默认不解析 raw HTML万一服务端没剥掉它在页面上也只是
* 一行不显眼的转义文本,不会破版
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
* - 不与 Markdown 语法冲突,格式化工具不会改写它
*
* # 为什么必须共用
*
* 标记格式是**服务端正则的镜像**`gateway/internal/handler/rename_proposal.go`)。
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
*/
/**
* 服务端能识别的别名字符集。
*
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
* 「我提议的名字」与实际入库的不一致。
*
* @param {string} alias
* @returns {boolean}
*/
export function isProposableAlias(alias) {
const a = String(alias ?? '').trim();
if (!a) return false;
if (a === 'new') return false;
// 双引号是标记本身的定界符,含它会截断标记
if (/[.\s/@"]/.test(a)) return false;
// 服务端 VARCHAR(128),按字节算
if (Buffer.byteLength(a, 'utf8') > 128) return false;
return true;
}
/**
* 把改名提议标记追加到正文末尾。
*
* 格式必须与服务端正则逐字符对应:
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
* 界面上的提示条就少了那句解释)。
*
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
*
* @param {string} body 原始正文
* @param {string} [alias] 提议的别名
* @param {string} [reason] 提议理由,一句话
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
*/
export function appendRenameProposal(body, alias, reason) {
const text = String(body ?? '');
if (!isProposableAlias(alias)) return { body: text, proposed: false };
const a = String(alias).trim();
// 理由里的双引号会截断标记去掉而不是转义HTML 注释里没有转义机制
const r = String(reason ?? '').replace(/"/g, '').trim();
const reasonAttr = r ? ` reason="${r}"` : '';
return {
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
proposed: true,
};
}
/**
* 提议提交后回给模型的那句话。
*
* **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑
* `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8
* 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
*
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
*
* @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed`
* @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明
* @param {boolean} [proposed] appendRenameProposal 的返回值
* @returns {string} 空串表示没有需要追加的说明
*/
export function renameProposalNote(serverAlias, requestedAlias, proposed) {
const server = String(serverAlias ?? '').trim();
const wanted = String(requestedAlias ?? '').trim();
// 服务端确认收到了:用它给的最终值
if (server) {
const changed = wanted && wanted !== server
? `(你提的 "${wanted}" 被规范化成了这个)`
: '';
return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` +
`在那之前继续用原别名寻址。`;
}
if (!wanted) return '';
// 本地就判定不合法,标记没发出去
if (!proposed) {
return `(改名提议 "${wanted}" 未提交:别名不可为 new不可含 . 空白 / @ 或双引号。)`;
}
// 标记发出去了但服务端没回 rename_proposed它那侧的校验也拒了
return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`;
}