## 别名替换(让 .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),未手工拼写
219 lines
8.8 KiB
JavaScript
219 lines
8.8 KiB
JavaScript
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({}), /没有可见的邮件/);
|
||
});
|