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),未手工拼写
This commit is contained in:
145
plugins/dsh-mail-bridge/test/addressing.test.mjs
Normal file
145
plugins/dsh-mail-bridge/test/addressing.test.mjs
Normal file
@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatAddress,
|
||||
roleOf,
|
||||
replyAddressFor,
|
||||
selfAddressFor,
|
||||
participantsOfMail,
|
||||
} from '../lib/addressing.js';
|
||||
|
||||
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
|
||||
// 「拼出来的东西还能不能被正确解析回三段」上。
|
||||
|
||||
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
|
||||
// 生产事故:朴素拼接得到 admin.silent-harbor,没有 @,
|
||||
// 整串被 ParseAddress 当成名字,session 位静默丢失。
|
||||
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('formatAddress: 省略 session 位', () => {
|
||||
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
|
||||
// 名字与 path 都有但都不带会话 → 默认会话语义
|
||||
assert.equal(formatAddress('dsh', '', ''), 'dsh');
|
||||
});
|
||||
|
||||
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
|
||||
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
|
||||
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
|
||||
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
|
||||
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
|
||||
});
|
||||
|
||||
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
|
||||
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
|
||||
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
|
||||
assert.equal(formatAddress('', '/p', 'a'), '');
|
||||
assert.equal(formatAddress(null, '/p', 'a'), '');
|
||||
});
|
||||
|
||||
test('formatAddress: 去掉首尾空白', () => {
|
||||
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
|
||||
});
|
||||
|
||||
const ccMail = {
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
};
|
||||
|
||||
test('roleOf: 区分主收件人与抄送方', () => {
|
||||
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
|
||||
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
|
||||
assert.equal(roleOf(ccMail, 'dsh'), 'to');
|
||||
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
|
||||
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
|
||||
});
|
||||
|
||||
test('roleOf: 名字为空时不猜', () => {
|
||||
assert.equal(roleOf(ccMail, ''), 'unknown');
|
||||
assert.equal(roleOf(ccMail, undefined), 'unknown');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
|
||||
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
|
||||
const addr = replyAddressFor(ccMail);
|
||||
assert.equal(addr, 'admin@.silent-harbor');
|
||||
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 发件人一侧不带 path', () => {
|
||||
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
|
||||
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
|
||||
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
|
||||
assert.equal(replyAddressFor(mail), 'dsh@.x');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 无别名时退回默认会话形式', () => {
|
||||
const mail = { from_name: 'admin', session_alias: '' };
|
||||
const addr = replyAddressFor(mail);
|
||||
assert.equal(addr, 'admin');
|
||||
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
|
||||
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
|
||||
});
|
||||
|
||||
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
|
||||
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path,
|
||||
// 「我是谁」这句话就指向了别人的目录。
|
||||
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
|
||||
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
|
||||
|
||||
assert.equal(byName.opencode.path, '/home');
|
||||
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
|
||||
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
|
||||
// 发件人 path 留空,理由同 replyAddressFor
|
||||
assert.equal(byName.admin.address, 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
|
||||
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名,
|
||||
// 否则「回给抄收方」这个动作每次都会新开会话。
|
||||
for (const p of participantsOfMail(ccMail, 'dsh')) {
|
||||
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('participantsOfMail: 自己被标记而不是被剔除', () => {
|
||||
// 剔掉的话模型无法确认这封信是不是也发给了自己,
|
||||
// 也就无法判断自己该不该回。
|
||||
const parts = participantsOfMail(ccMail, 'opencode');
|
||||
const me = parts.find(p => p.name === 'opencode');
|
||||
assert.ok(me, '自己应出现在参与方列表里');
|
||||
assert.equal(me.is_self, true);
|
||||
assert.equal(parts.filter(p => p.is_self).length, 1);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
|
||||
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 无抄送时只有两方', () => {
|
||||
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 跳过空名字条目', () => {
|
||||
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
|
||||
const mail = {
|
||||
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
|
||||
cc_list: [{ name: '', path: '/x' }, {}],
|
||||
session_alias: 'a',
|
||||
};
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
for (const p of parts) assert.notEqual(p.address, '');
|
||||
});
|
||||
Reference in New Issue
Block a user