Files
MailUI4Agents/plugins/pi-mail-bridge/test/naming.test.mjs
JianFeeeee e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
2026-09-03 12:09:12 +08:00

186 lines
9.2 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.

/**
* 会话命名一致性的测试。
*
* 这是 pi 接入里最容易做错的一块,因为「两边各自命名然后指望撞上」看起来能用:
* 单条会话、不撞名、没人手工改过名时,两边确实一致。上面任何一条不成立就分叉。
*
* 因此这里的每个 test 都对应一条**分叉场景**。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { planNamingSync, planWriteBack } from '../src/naming.mjs';
// ─── 向 Gateway 提交W-7───
test('平台名字正常时派生别名并原样带标题', () => {
const got = planNamingSync({ platformName: '排查连接泄漏', mailSubject: '别用我', lastSynced: '' });
assert.equal(got.skip, false);
assert.equal(got.alias, '排查连接泄漏');
assert.equal(got.title, '排查连接泄漏');
assert.equal(got.source, 'platform');
});
test('不变量:标题原样提交,不清洗不派生', () => {
// I-4插件只搬运。标题那一列不负责寻址没有字符限制
// 改写它等于让邮箱里显示的和 pi 里显示的是两个东西。
const name = 'Fix: cache TTL (v2) — 缓存/过期';
const got = planNamingSync({ platformName: name, mailSubject: '', lastSynced: '' });
assert.equal(got.title, name);
// 别名要按寻址规则剥掉分隔符
assert.doesNotMatch(got.alias, /[.@/]/);
});
test('不变量:内容没变就不重复提交', () => {
// setSessionName 会触发 session_info_changed钩子又去 sync
// 不判「与上次相同」就是自激循环 —— 每 30 秒刷一次 Gateway。
const first = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: '' });
assert.equal(first.skip, false);
const again = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: first.signature });
assert.equal(again.skip, true);
assert.equal(again.reason, 'unchanged');
});
test('不变量pi 没有名字时退到邮件主题SDK 路径的常态)', () => {
// 桥用 SDK 起的会话不经过 pi-web 的标题生成器sessionName 一直是 undefined。
// 只等平台命名的话别名永远是空的,`name@path.<别名>` 续谈无从下手 ——
// 第一次端到端跑通时就是这个结果sessions.session_alias 是空串)。
const got = planNamingSync({ platformName: undefined, mailSubject: '主链路验证', lastSynced: '' });
assert.equal(got.skip, false);
assert.equal(got.alias, '主链路验证');
assert.equal(got.source, 'mail-subject');
});
test('不变量:无名字 + 主题未变时也要判 unchanged', () => {
// 用平台名字本身充当「上次提交了什么」的记录时,名字为空就无法区分
// 「还没提交过」与「提交过、内容没变」,于是每轮心跳都白打一次 sync。
const first = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: '' });
const again = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: first.signature });
assert.equal(again.skip, true, '空名字场景同样要能判出「没变化」');
});
test('思维链泄漏的名字退到邮件主题派生别名', () => {
// pi-web 的标题生成器不防这个cleanSessionName 只取首行 + 截 60 字符)。
// 本机 81 条会话里实测捞到过这条。
const got = planNamingSync({
platformName: 'The user is asking me to generate a title for a coding-agent',
mailSubject: '排查连接泄漏',
lastSynced: '',
});
assert.equal(got.skip, false);
assert.equal(got.alias, '排查连接泄漏');
assert.equal(got.source, 'mail-subject');
});
test('不变量:退到邮件主题时不写标题', () => {
// 标题那一列的语义是「平台生成的会话标题」。把邮件主题填进去会让
// 邮箱里看起来像是 pi 生成了这个标题,而 pi 侧其实是另一个名字(或没有)。
const got = planNamingSync({
platformName: '我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试。简短:测试。或者更简',
mailSubject: '压测报告',
lastSynced: '',
});
assert.equal(got.title, '');
assert.equal(got.alias, '压测报告');
});
test('不变量无可用名字时什么都不写W-7.2', () => {
// 宁可让会话保持无别名session_alias 允许 NULL也不要写 "session-123"
// 这种占位值 —— 它对人毫无指代作用,而且一旦落库就把 alias 位占住了,
// 真正的名字来了只能追 -2 后缀。
const got = planNamingSync({ platformName: '', mailSubject: '', lastSynced: '' });
assert.equal(got.skip, true);
assert.equal(got.reason, 'no-usable-name');
});
test('纯符号名字:写标题但不写别名', () => {
// slugFromTitle('...') 是空串作为别名非法Gateway 会 400
// 但这个名字本身是平台产出,标题列该照实反映。
const got = planNamingSync({ platformName: '...', mailSubject: '', lastSynced: '' });
assert.equal(got.skip, false);
assert.equal(got.alias, '');
assert.equal(got.title, '...');
assert.equal(got.source, 'platform-title-only');
});
test('主题也派生不出别名时不写', () => {
const got = planNamingSync({ platformName: 'The user is asking me to', mailSubject: '@@@', lastSynced: '' });
assert.equal(got.skip, true);
});
// ─── 回写进 piD-5 / 一致性的关键)───
test('不变量:撞名后缀必须回写进 pi', () => {
// Gateway 侧别名负有寻址唯一性义务partial unique index撞名自动追 -2。
// pi 侧没有这个约束。不回写的话:邮箱里是 fix-leak-2、pi-web 里是 fix-leak
// 用户按界面上看到的名字发信会 404。
const got = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
assert.equal(got.write, true);
assert.equal(got.name, 'fix-leak-2');
assert.equal(got.reason, 'diverged');
});
test('不变量manual 别名(人手工改过)优先,回写进 pi', () => {
// SyncSessionAlias 遇到 alias_source='manual' 时不覆盖,**原样返回当前别名**。
// 于是「人在 AgentMail 界面上定的名字」赢pi 侧要跟着改 —— 这是有意的:
// 人的意图优先于模型生成的标题。
const got = planWriteBack({ finalAlias: '紧急排查', currentPiName: 'connection-leak' });
assert.equal(got.write, true);
assert.equal(got.name, '紧急排查');
});
test('规范化改写过的别名也要回写', () => {
// normalizeAlias 把 . / @ 空白换成 -。提议 "a.b c" 会变成 "a-b-c"。
const got = planWriteBack({ finalAlias: 'a-b-c', currentPiName: 'a.b c' });
assert.equal(got.write, true);
assert.equal(got.name, 'a-b-c');
});
test('两边已经一致就不回写', () => {
// 回写会 append 一条 session_info 并触发 session_info_changed。
// 无条件回写 = 每轮多一条无意义的历史条目 + 一次多余的 sync。
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: 'fix-leak' });
assert.equal(got.write, false);
assert.equal(got.reason, 'already-equal');
});
test('pi 侧还没有名字时也要回写', () => {
// 桥用 SDK 起的会话没有名字pi-web 的生成器不在这条链路上),
// 此时 Gateway 定稿的别名就是这条会话的第一个名字。
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: undefined });
assert.equal(got.write, true);
assert.equal(got.reason, 'pi-unnamed');
});
test('不变量:响应没带别名时不回写', () => {
// 本次只同步了标题planNamingSync 的 platform-title-only 分支)→ 没有定稿值。
// 拿空串去 setSessionName 是**清除**语义(实测 appendSessionInfo(" ")
// 之后 getSessionName() 变 undefined会把 pi 侧原有的名字抹掉。
assert.equal(planWriteBack({ finalAlias: '', currentPiName: 'keep-me' }).write, false);
assert.equal(planWriteBack({ finalAlias: undefined, currentPiName: 'keep-me' }).write, false);
assert.equal(planWriteBack({ finalAlias: ' ', currentPiName: 'keep-me' }).write, false);
});
// ─── 端到端的一致性推理 ───
test('完整链路:提议 → 撞名定稿 → 回写 → 再观测不再动', () => {
// 这个 test 钉住「不会自激循环」这条性质,它是分四步的:
// 1. pi 有了名字 fix-leak提交
// 2. Gateway 撞名,定稿 fix-leak-2
// 3. 回写进 pipi 的名字变成 fix-leak-2
// 4. session_info_changed 再次触发 → 必须 skip否则无限循环
const step1 = planNamingSync({ platformName: 'fix-leak', mailSubject: '', lastSynced: '' });
assert.equal(step1.alias, 'fix-leak');
const step3 = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
assert.equal(step3.write, true);
// 桥在回写后把指纹更新成「定稿别名当作平台名字」会算出的那个值,
// 因此第 4 步(回写触发的事件)看到的指纹与它相同。
const afterWriteBack = `platform:${step3.name}|${step3.name}`;
const step4 = planNamingSync({ platformName: 'fix-leak-2', mailSubject: '', lastSynced: afterWriteBack });
assert.equal(step4.skip, true, '回写触发的事件必须被指纹挡住,否则无限循环');
});