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

267 lines
9.5 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.

/**
* 收件箱渲染与已读策略的测试。
*
* 每条断言都对应一次真实的错误行为(见 lib/inbox-format.js 里的注释):
* 漏掉 attachment_id 模型就无从下载附件;漏掉抄送它会以为这是私信;
* status=all 时标记已读会让下一轮的新邮件混在历史里认不出来。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatSize,
renderMail,
renderInbox,
idsToMarkRead,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
const mail = (over = {}) => ({
mail_id: 'm-1',
from_name: 'admin',
subject: '缓存选型',
status: 'unread',
session_alias: 'brisk-harbor',
body_preview: '我们需要评估一下缓存层',
...over,
});
// ─── formatSize ───
test('formatSize 分档', () => {
assert.equal(formatSize(512), '512 B');
assert.equal(formatSize(2048), '2.0 KB');
assert.equal(formatSize(3 * 1024 * 1024), '3.0 MB');
});
test('formatSize 容错', () => {
assert.equal(formatSize(undefined), '?');
assert.equal(formatSize(NaN), '?');
assert.equal(formatSize('x'), '?');
});
// ─── renderMail ───
test('renderMail 带出 mail_id 与会话别名', () => {
const got = renderMail(mail());
assert.match(got, /邮件 ID: m-1/);
assert.match(got, /#brisk-harbor/);
assert.match(got, /admin: 缓存选型/);
});
test('无别名时显示「未命名」而不是空', () => {
const got = renderMail(mail({ session_alias: '' }));
assert.match(got, /#未命名/);
});
test('不变量:附件必须带 attachment_id', () => {
// 只说「有附件」模型就无从下载 —— download_attachment 要的正是这个 id。
const got = renderMail(mail({
attachments: [{ filename: 'report.md', size_bytes: 2048, attachment_id: 'att-9' }],
}));
assert.match(got, /id=att-9/, `附件行缺 id${got}`);
assert.match(got, /report\.md/);
assert.match(got, /2\.0 KB/);
assert.match(got, /download_attachment/, '要提示模型用哪个工具下载');
});
test('多个附件都列出来', () => {
const got = renderMail(mail({
attachments: [
{ filename: 'a.md', size_bytes: 10, attachment_id: 'att-1' },
{ filename: 'b.md', size_bytes: 20, attachment_id: 'att-2' },
],
}));
assert.match(got, /att-1/);
assert.match(got, /att-2/);
});
test('不变量:抄送人要显示出来', () => {
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
const got = renderMail(mail({
cc_list: [{ name: 'opencode', raw: 'opencode@/home.new' }],
}));
assert.match(got, /抄送/);
assert.match(got, /opencode@\/home\.new/, '应优先用 raw带路径与会话段');
});
test('无抄送时不出现抄送行', () => {
assert.ok(!renderMail(mail()).includes('抄送'));
assert.ok(!renderMail(mail({ cc_list: [] })).includes('抄送'));
});
test('正文优先取 body_preview缺失时退回 body', () => {
assert.match(renderMail(mail({ body_preview: '预览', body: '全文' })), /内容: 预览/);
assert.match(renderMail(mail({ body_preview: '', body: '全文' })), /内容: 全文/);
});
test('正文按 bodyLimit 截断', () => {
const got = renderMail(mail({ body_preview: 'x'.repeat(500) }), 50);
const line = got.split('\n').find(l => l.startsWith('内容: '));
assert.equal(line.length, '内容: '.length + 50);
});
test('renderMail 容错:字段全缺不崩', () => {
const got = renderMail({});
assert.match(got, /unknown/);
const got2 = renderMail(undefined);
assert.equal(typeof got2, 'string');
});
test('附件字段不是数组时忽略', () => {
const got = renderMail(mail({ attachments: 'oops', cc_list: 'oops' }));
assert.ok(!got.includes('附件:'));
assert.ok(!got.includes('抄送'));
});
// ─── 收件人与身份(只有知道自己是谁才能判定)───
test('不变量:收件人要显示出来', () => {
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
});
test('收件人无工作目录时只显名字', () => {
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
assert.match(got, /收件人: admin$/m);
});
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
const got = renderMail(mail({ to_name: 'dsh' }));
assert.ok(!got.includes('你的身份'));
});
test('不变量:区分收件人与抄送方身份', () => {
// 两者职责不同。不区分的话两方都会以为自己是负责人,
// 或者都以为自己只是旁观者。
const m = mail({
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
});
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
// 不相关的名字不编造身份
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
});
// ─── 可投递地址(「精准发信」的关键)───
const joint = () => mail({
mail_id: 'm-7',
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('不变量:给出每个参与方的可投递地址', () => {
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`
// 而那个地址回过去只会再建一条平行会话。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /可投递地址/);
assert.match(got, /opencode@\/home\.silent-harbor抄送方/);
assert.match(got, /admin@\.silent-harbor发件人/);
});
test('不变量:可投递地址里绝不出现 .new', () => {
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
// 把它当回信地址会让双方各说各话。
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(line, '应有可投递地址行');
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
});
test('可投递地址不列自己', () => {
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
});
test('同时给出 reply_to 这条更稳的路', () => {
// 地址可能拼错reply_to 不会 —— 两条路都告诉模型。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /reply_to=m-7/);
});
test('无会话别名时不给地址(宁可不给不可给错)', () => {
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
// 实际指向默认会话的地址,比不给危险。
const got = renderMail(mail({
to_name: 'dsh', session_alias: '',
cc_list: [{ name: 'opencode', path: '/home' }],
}), 200, 'dsh');
assert.ok(!got.includes('可投递地址'));
});
test('renderInbox 透传 selfName', () => {
const got = renderInbox([joint()], 200, 'opencode');
assert.match(got, /你的身份: 抄送方/);
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor收件人/);
});
// ─── renderInbox ───
test('renderInbox 空收件箱给明确文案', () => {
assert.equal(renderInbox([]), '收件箱为空。');
assert.equal(renderInbox(undefined), '收件箱为空。');
assert.equal(renderInbox(null), '收件箱为空。');
});
test('renderInbox 用空行分隔多封', () => {
const got = renderInbox([mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
assert.match(got, /邮件 ID: a[\s\S]*\n\n[\s\S]*邮件 ID: b/);
});
// ─── idsToMarkRead ───
test('不变量:只标本次列出的那些', () => {
// limit 之外的还没看过,一并标掉等于让它们凭空消失。
const ids = idsToMarkRead('unread', [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
assert.deepEqual(ids, ['a', 'b']);
});
test('不变量status=all 时不标记', () => {
// 那是「回顾历史」的读法。把历史邮件标成已读会让下一轮真正的新邮件
// 混在里面认不出来。
assert.deepEqual(idsToMarkRead('all', [mail({ mail_id: 'a' })]), []);
});
test('status 省略时按默认unread标记', () => {
assert.deepEqual(idsToMarkRead(undefined, [mail({ mail_id: 'a' })]), ['a']);
});
test('idsToMarkRead 过滤掉无 id 的条目', () => {
const ids = idsToMarkRead('unread', [
mail({ mail_id: 'a' }),
mail({ mail_id: '' }),
mail({ mail_id: undefined }),
{ },
]);
assert.deepEqual(ids, ['a']);
});
test('idsToMarkRead 容错非数组', () => {
assert.deepEqual(idsToMarkRead('unread', undefined), []);
assert.deepEqual(idsToMarkRead('unread', 'oops'), []);
});
// ─── 默认值 ───
test('默认只看未读', () => {
// 默认 all 会让模型每轮重读旧邮件,把处理过的和新来的混在一起。
assert.equal(DEFAULT_INBOX_STATUS, 'unread');
});
test('默认条数是个小数字', () => {
// 收件箱一次给几十封会把上下文塞满,而模型一轮通常只处理一两封。
assert.ok(DEFAULT_INBOX_LIMIT > 0 && DEFAULT_INBOX_LIMIT <= 10);
});