Files
MailUI4Agents/plugins/dsh-mail-bridge/test/session-snapshot.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

327 lines
12 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.

/**
* 平台会话快照的纯函数测试。
*
* 这些函数的产出直接决定「写信时能不能选到某条会话」slug 错了就填出一个
* 送不到的 session 位(三态语义下会 404workspace 错了就归到别的工作区去。
*
* node --test test/
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
snapshotOpencodeSessions,
snapshotDshSessions,
snapshotPiSessions,
isUnusableName,
slugFromTitle,
MAX_REPORTED,
} from '../lib/session-snapshot.js';
// ─── opencode ───
const ocSession = (over = {}) => ({
id: 'ses_abc',
slug: 'witty-planet',
title: '重构导入路径',
directory: '/home/program/agentmail',
path: '',
time: { created: 1788300000000, updated: 1788344476744 },
...over,
});
test('opencode 快照取 directory 作为 workspace', () => {
const [got] = snapshotOpencodeSessions([ocSession()]);
assert.equal(got.workspace, '/home/program/agentmail');
});
test('不变量workspace 取 directory 而不是 path', () => {
// opencode 的 path 是项目内的子路径通常是空串cwd 在 directory 上。
// 取错的后果是所有会话的 workspace 都变成空串,一条都匹配不上。
const [got] = snapshotOpencodeSessions([
ocSession({ directory: '/home/real/cwd', path: 'src/sub' }),
]);
assert.equal(got.workspace, '/home/real/cwd');
});
test('opencode 快照带出 slug 与标题', () => {
const [got] = snapshotOpencodeSessions([ocSession()]);
assert.equal(got.slug, 'witty-planet');
assert.equal(got.title, '重构导入路径');
assert.equal(got.platform_id, 'ses_abc');
});
test('不变量:无 slug 的会话不上报', () => {
// slug 是填进 session 位的值。没有它,这一项在补全里点下去
// 只能得到 `name@path.` —— 一个空的 session 段。
const got = snapshotOpencodeSessions([
ocSession({ id: 'a', slug: '' }),
ocSession({ id: 'b', slug: undefined }),
ocSession({ id: 'c', slug: 'good-name' }),
]);
assert.equal(got.length, 1);
assert.equal(got[0].slug, 'good-name');
});
test('无 id 的条目被跳过', () => {
const got = snapshotOpencodeSessions([ocSession({ id: '' }), ocSession({ id: undefined })]);
assert.equal(got.length, 0);
});
test('mail_driven 由回调判定', () => {
const got = snapshotOpencodeSessions(
[ocSession({ id: 'driven' }), ocSession({ id: 'manual' })],
id => id === 'driven'
);
assert.equal(got.find(s => s.platform_id === 'driven').mail_driven, true);
assert.equal(got.find(s => s.platform_id === 'manual').mail_driven, false);
});
test('updated_at 由毫秒时间戳转 ISO', () => {
const [got] = snapshotOpencodeSessions([ocSession()]);
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
});
test('没有 updated 时退回 created', () => {
const [got] = snapshotOpencodeSessions([
ocSession({ time: { created: 1788300000000 } }),
]);
assert.equal(got.updated_at, new Date(1788300000000).toISOString());
});
test('时间完全缺失时 updated_at 为 undefined 而不是崩', () => {
const [got] = snapshotOpencodeSessions([ocSession({ time: undefined })]);
assert.equal(got.updated_at, undefined);
});
test('按最近活跃降序排列', () => {
const got = snapshotOpencodeSessions([
ocSession({ id: 'old', slug: 'old', time: { updated: 1000 } }),
ocSession({ id: 'new', slug: 'new', time: { updated: 9000 } }),
ocSession({ id: 'mid', slug: 'mid', time: { updated: 5000 } }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['new', 'mid', 'old']);
});
test('截断到 MAX_REPORTED', () => {
const many = Array.from({ length: MAX_REPORTED + 50 }, (_, i) =>
ocSession({ id: `s${i}`, slug: `slug-${i}`, time: { updated: i } })
);
assert.equal(snapshotOpencodeSessions(many).length, MAX_REPORTED);
});
test('非数组输入不崩', () => {
assert.deepEqual(snapshotOpencodeSessions(undefined), []);
assert.deepEqual(snapshotOpencodeSessions(null), []);
assert.deepEqual(snapshotOpencodeSessions({}), []);
});
// ─── DSH ───
test('DSH 快照从标题派生 slug', () => {
const [got] = snapshotDshSessions([
{ id: 'mail-1', cwd: '/home/x', title: '缓存层选型评估', updatedAt: 1788344476744 },
]);
assert.equal(got.slug, '缓存层选型评估');
assert.equal(got.title, '缓存层选型评估');
assert.equal(got.workspace, '/home/x');
});
test('DSH 无标题时不上报(派生不出别名)', () => {
const got = snapshotDshSessions([
{ id: 'a', cwd: '/home/x', title: '' },
{ id: 'b', cwd: '/home/x' },
]);
assert.equal(got.length, 0);
});
// ─── slugFromTitle ───
test('slugFromTitle 空白转连字符', () => {
assert.equal(slugFromTitle('处理新邮件 任务'), '处理新邮件-任务');
assert.equal(slugFromTitle('a b c'), 'a-b-c');
});
test('不变量slug 不含寻址分隔符', () => {
// `.` 是 session 位的分隔符、`@` 是 path 位的分隔符。留在 slug 里
// 会让别名自己被解析器切开 —— 填进去的地址会指向一个完全不同的目标。
const slug = slugFromTitle('修 a.b@c/d 的问题');
for (const ch of ['.', '@', '/', '\\', ':']) {
assert.ok(!slug.includes(ch), `slug 里不该有 ${ch}${slug}`);
}
});
test('slugFromTitle 压缩连续连字符', () => {
assert.equal(slugFromTitle('a...b'), 'ab');
assert.equal(slugFromTitle('a - b'), 'a-b');
});
test('slugFromTitle 去掉首尾连字符', () => {
assert.equal(slugFromTitle(' 中间 '), '中间');
assert.equal(slugFromTitle('--x--'), 'x');
});
test('slugFromTitle 截断到 48 字符且不留尾部连字符', () => {
const long = 'a'.repeat(60);
assert.equal(slugFromTitle(long).length, 48);
// 第 48 个字符正好落在空格上时,截断后不该留下尾部 -
const tricky = `${'b'.repeat(47)} tail`;
const slug = slugFromTitle(tricky);
assert.ok(!slug.endsWith('-'), `尾部残留连字符:${slug}`);
});
test('slugFromTitle 保留中文', () => {
// 不转拼音huancunceng-xuanxing 既不好读也不好打,
// 而三维地址按最后一个 . 切分,中文不影响解析。
assert.equal(slugFromTitle('缓存选型'), '缓存选型');
});
test('slugFromTitle 纯符号标题返回空串', () => {
assert.equal(slugFromTitle('...'), '');
assert.equal(slugFromTitle('@@@'), '');
assert.equal(slugFromTitle(' '), '');
});
test('slugFromTitle 容错非字符串', () => {
assert.equal(slugFromTitle(undefined), '');
assert.equal(slugFromTitle(null), '');
assert.equal(slugFromTitle(42), '42');
});
// ─── DSHsubagent 过滤与 slug 去重 ───
test('不变量subagent 子会话不上报origin 判据)', () => {
// 它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
const got = snapshotDshSessions([
{ id: 'child', cwd: '/w', title: 'You are auditing ONE file', origin: 'subagent' },
{ id: 'top', cwd: '/w', title: '正常会话' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].platform_id, 'top');
});
test('不变量subagent 子会话不上报delegationDepth 判据)', () => {
const got = snapshotDshSessions([
{ id: 'child', cwd: '/w', title: '子任务', delegationDepth: 1 },
{ id: 'top', cwd: '/w', title: '顶层', delegationDepth: 0 },
]);
assert.equal(got.length, 1);
assert.equal(got[0].platform_id, 'top');
});
test('不变量slug 撞名只留最近那条', () => {
// 别名是寻址用的:同一个 slug 对应多条会话时服务端只能取其中一条,
// 上报一堆同名项只会让补全列表里出现几个点哪个都不确定的候选。
const got = snapshotDshSessions([
{ id: 'old', cwd: '/w', title: '同一个标题', updatedAt: 1000 },
{ id: 'new', cwd: '/w', title: '同一个标题', updatedAt: 9000 },
{ id: 'mid', cwd: '/w', title: '同一个标题', updatedAt: 5000 },
]);
assert.equal(got.length, 1, `应去重到 1 条,实际 ${got.length}`);
assert.equal(got[0].platform_id, 'new', '应保留最近活跃的那条');
});
test('不同标题不受去重影响', () => {
const got = snapshotDshSessions([
{ id: 'a', cwd: '/w', title: '标题一', updatedAt: 2000 },
{ id: 'b', cwd: '/w', title: '标题二', updatedAt: 1000 },
]);
assert.equal(got.length, 2);
});
// ─── pi名字来自会话文件的 session_info ───
const piSession = (over = {}) => ({
id: '01a064cc-df57-7b2d-bebb-736776105485',
cwd: '/home/program/agentmail',
name: '重构导入路径',
messageCount: 6,
created: new Date(1788300000000),
modified: new Date(1788344476744),
...over,
});
test('pi 快照取 cwd 与 session_info 名字', () => {
const [got] = snapshotPiSessions([piSession()]);
assert.equal(got.workspace, '/home/program/agentmail');
assert.equal(got.title, '重构导入路径');
assert.equal(got.slug, '重构导入路径');
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
});
test('不变量pi 无名会话不上报', () => {
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
// 「你收到一封新邮件AgentMail…」—— 拿它当别名毫无区分度,且条条撞名。
const got = snapshotPiSessions([
piSession({ id: 'named', name: '有名字' }),
piSession({ id: 'anon', name: undefined }),
piSession({ id: 'blank', name: '' }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['named']);
});
test('不变量pi 老会话的空 cwd 照实上报', () => {
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
// 那条会话在补全里挂到一个它其实不属于的工作区下。
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
assert.equal(got.workspace, '');
});
test('不变量pi 的 updated_at 取 modified文件 mtime', () => {
const [got] = snapshotPiSessions([piSession()]);
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
});
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
const got = snapshotPiSessions([
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
]);
assert.equal(got.length, 1);
assert.equal(got[0].platform_id, 'new');
});
test('pi 快照标记邮件驱动的会话', () => {
const got = snapshotPiSessions(
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
(id) => id === 'mail-one'
);
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
});
// ─── isUnusableNamepi-web 标题生成器的思维链泄漏 ───
test('不变量:思维链泄漏的标题判废', () => {
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
assert.equal(
isUnusableName('我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短Opencode源测试。或者更简'),
true
);
});
test('isUnusableName 放过正常标题', () => {
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
assert.equal(isUnusableName('查看Agent接入群聊'), false);
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
assert.equal(isUnusableName('homeagent-gateway'), false);
assert.equal(isUnusableName('重构导入路径'), false);
assert.equal(isUnusableName('Fix flaky auth test'), false);
});
test('isUnusableName 判废空名字', () => {
assert.equal(isUnusableName(''), true);
assert.equal(isUnusableName(' '), true);
assert.equal(isUnusableName(undefined), true);
});
test('判废的名字不进快照', () => {
const got = snapshotPiSessions([
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
piSession({ id: 'clean', name: '正常标题' }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
});