Files
MailUI4Agents/plugins/opencode-mail-bridge/test/addressing.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

146 lines
6.1 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.

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, '');
});