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:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -0,0 +1,218 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
test('renderNameSuggestions 只给名字并指向下一步', () => {
// 此时还不知道 path 与 session硬拼裸名字地址会投到「默认会话」——
// 那不一定是调用方想要的那条。
const got = renderNameSuggestions(['opencode', 'admin']);
assert.match(got, /opencode/);
assert.match(got, /admin/);
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
});
test('renderNameSuggestions 空列表给明确文案', () => {
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
});
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
// 不解释的话模型会卡在这一步,或者编一个路径出来。
const got = renderPathSuggestions([], 'admin');
assert.match(got, /可以留空/);
assert.match(got, /admin/);
});
test('renderPathSuggestions 列出目录并指向下一步', () => {
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
assert.match(got, /\/home\/program\/agentmail/);
assert.match(got, /最近使用/);
assert.match(got, /suggest_address\(name="opencode", path="/);
});
const sessionData = {
kind: 'session',
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
addresses: [
'opencode@/home.silent-harbor',
'opencode@/home.happy-tiger',
'opencode@/home.new',
],
candidates: [
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
{ alias: 'new', title: '新建会话', source: 'new' },
],
};
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
// 插件自己拼过一次,拼错了(空 path 时漏掉 @。addresses 与 suggestions
// 同序由服务端保证,直接用。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /opencode@\/home\.happy-tiger/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('renderSessionSuggestions 带出标题与未读数', () => {
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /联调 llmsproxy/);
assert.match(got, /2 封未读/);
});
test('不变量new 不与已存在会话混列,且带警告', () => {
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
const lines = got.split('\n');
const newLineIdx = lines.findIndex(l => l.includes('.new'));
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
});
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
// 这是关键引导:开新会话时传 session_alias之后才能按名字续谈。
// 不传的话服务端会自动命名,但模型不知道那个名字。
const got = renderSessionSuggestions(
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
'dsh', '/tmp',
);
assert.match(got, /还没有可续谈的会话/);
assert.match(got, /session_alias/);
});
const participantData = {
session_id: 'f3d824ce',
session_alias: 'silent-harbor',
participants: [
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
],
};
test('renderParticipants 给出每个参与方的地址', () => {
const got = renderParticipants(participantData);
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /admin@\.silent-harbor/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('不变量:标出「尚未回应」的人', () => {
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
// 正是为了让这个判断成立。
const got = renderParticipants(participantData);
const line = got.split('\n').find(l => l.includes('opencode'));
assert.match(line, /尚未回应/);
// 自己不该被标「尚未回应」—— 自己正在处理这封
const selfLine = got.split('\n').find(l => l.includes('dsh'));
assert.ok(!selfLine.includes('尚未回应'));
assert.match(selfLine, /就是你/);
});
test('renderParticipants 用中文角色标签', () => {
// 模型读到「抄送方」比读到 cc 更容易判对分工。
const got = renderParticipants(participantData);
assert.match(got, /抄送方/);
assert.match(got, /发件人/);
});
test('renderParticipants 无地址时说明原因', () => {
const got = renderParticipants({
session_alias: '',
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
});
assert.match(got, /尚未命名/);
});
test('renderParticipants 空会话不崩', () => {
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
assert.match(renderParticipants({}), /还没有参与方/);
});
test('renderContacts 未读优先排序', () => {
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
const got = renderContacts({
contacts: [
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
],
});
const lines = got.split('\n').filter(l => l.startsWith('- '));
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
assert.match(lines[0], /3 封未读/);
});
test('renderContacts 带出剩余预算', () => {
const got = renderContacts({
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
});
assert.match(got, /剩 3\/20 个来回/);
});
test('renderContacts 未命名会话说明只能 reply_to', () => {
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
assert.match(got, /reply_to/);
});
test('renderContacts 空列表', () => {
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
});
const threadData = {
anchor_mail_id: 'm-2',
total: 3,
hidden: 1,
nodes: [
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
],
};
test('renderThread 用缩进表示层级', () => {
const got = renderThread(threadData, 'dsh');
const lines = got.split('\n');
const l1 = lines.find(l => l.includes('m-1'));
const l2 = lines.find(l => l.includes('m-2'));
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
});
test('不变量detached 必须标出来', () => {
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
const got = renderThread(threadData, 'dsh');
const line = got.split('\n').find(l => l.includes('m-3'));
assert.match(line, /父邮件无权查看/);
});
test('renderThread 标出自己发的与当前这封', () => {
const got = renderThread(threadData, 'dsh');
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
});
test('renderThread 报告不可见数量', () => {
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
// 不说的话模型会以为自己看到了全貌。
assert.match(renderThread(threadData), /另有 1 封无权查看/);
});
test('renderThread 有更多时给出 offset', () => {
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
assert.match(got, /offset=60/);
});
test('renderThread 空线索不崩', () => {
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
assert.match(renderThread({}), /没有可见的邮件/);
});