422 lines
17 KiB
TypeScript
422 lines
17 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
formatAddress,
|
||
mailCounterpart,
|
||
sessionCounterpart,
|
||
sessionReplyTarget,
|
||
mailReplyTarget,
|
||
replyAllCC,
|
||
participantAddress
|
||
} from '../../src/lib/replyTarget';
|
||
import type { Mail, Session } from '../../src/types';
|
||
|
||
/**
|
||
* 「这封回复该发给谁」。
|
||
*
|
||
* 锁的是一次生产事故:判据写死 `from_name === 'human'`(单用户时代的遗留),
|
||
* 多用户下登录名是 jianf,判据恒为假 → 对端取成 from_name(自己)→ 信发给自己。
|
||
* 数据库里的链条:
|
||
* pi → jianf 权限请求
|
||
* jianf → pi Re: 权限请求 (对的,锚点是 pi 发来的)
|
||
* pi → jianf 权限请求
|
||
* jianf → jianf Re: Re: 权限请求 (错的,锚点是自己发的)
|
||
*/
|
||
|
||
let seq = 0;
|
||
function mail(over: Partial<Mail> = {}): Mail {
|
||
seq += 1;
|
||
return {
|
||
mail_id: `m${String(seq).padStart(3, '0')}`,
|
||
session_id: 's1',
|
||
parent_mail_id: null,
|
||
from_name: 'pi',
|
||
from_workspace: '/home/program/llmsproxy',
|
||
to_name: 'jianf',
|
||
to_workspace: '',
|
||
cc_list: [],
|
||
subject: '主题',
|
||
body: '正文',
|
||
mail_type: 'normal',
|
||
permission_options: null,
|
||
permission_result: null,
|
||
status: 'read',
|
||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||
// 默认值:会话工作目录、发件方是 Agent、收件方是人 ——
|
||
// 对应「pi 在 /home/program/llmsproxy 干活 → jianf」这条最常见的信
|
||
session_workspace: '/home/program/llmsproxy',
|
||
from_human: false,
|
||
to_human: true,
|
||
...over
|
||
};
|
||
}
|
||
|
||
const session = (alias: string | null): Session => ({
|
||
session_id: 's1',
|
||
session_alias: alias,
|
||
from_agent: 'jianf',
|
||
subject: '关于llmsproxy工程的联合审查',
|
||
status: 'active',
|
||
created_at: '2026-09-03T09:00:00Z',
|
||
updated_at: '2026-09-03T10:00:00Z'
|
||
});
|
||
|
||
describe('formatAddress', () => {
|
||
it('三段齐全', () => {
|
||
expect(formatAddress('pi', '/home', 'deploy')).toBe('pi@/home.deploy');
|
||
});
|
||
it('无会话段时省略', () => {
|
||
expect(formatAddress('pi', '/home')).toBe('pi@/home');
|
||
expect(formatAddress('pi', '/home', null)).toBe('pi@/home');
|
||
});
|
||
it('path 与 session 都为空时返回裸名字(裸名字 = 默认会话)', () => {
|
||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||
});
|
||
it('path 为空但有 session 时必须保留 @', () => {
|
||
// `jianf@.任务` 能被 ParseAddress 还原(按最后一个 . 切分);
|
||
// 漏掉 @ 的 `jianf.任务` 会被整串当成名字 —— 那是个不存在的 Agent
|
||
expect(formatAddress('jianf', '', '任务')).toBe('jianf@.任务');
|
||
});
|
||
it('名字为空给空串而不是拼出 @', () => {
|
||
expect(formatAddress('', '/home')).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('mailCounterpart', () => {
|
||
it('别人发来的 → 回给发件人', () => {
|
||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||
});
|
||
|
||
it('我发出的 → 回给收件人(不是回给自己)', () => {
|
||
const m = mail({
|
||
from_name: 'jianf',
|
||
from_human: true,
|
||
from_workspace: '',
|
||
to_name: 'pi',
|
||
to_human: false,
|
||
to_workspace: '/home/program/llmsproxy'
|
||
});
|
||
// 这一条就是生产 bug:原代码在这里返回 jianf
|
||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||
});
|
||
|
||
it('登录名未知时退化为回给发件人,不会回给自己', () => {
|
||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||
expect(mailCounterpart(m, '').name).toBe('pi');
|
||
});
|
||
|
||
it('不把 human 当特殊值', () => {
|
||
// 老判据写死 human;现在它只是个普通名字
|
||
const m = mail({ from_name: 'human', to_name: 'pi' });
|
||
expect(mailCounterpart(m, 'jianf').name).toBe('human');
|
||
expect(mailCounterpart(m, 'human').name).toBe('pi');
|
||
});
|
||
|
||
it('path 从 session_workspace 取,不用 from_workspace(后者存的是 Agent 名)', () => {
|
||
// from_workspace 存的是 Agent 名而不是路径(历史遗留),
|
||
// 从 session_workspace 取路径才不会拼出 dsh@dsh
|
||
const m = mail({
|
||
from_name: 'dsh',
|
||
from_workspace: 'dsh', // Agent 名,不是路径
|
||
to_name: 'jianf',
|
||
to_human: true,
|
||
session_workspace: '/home/program/webui4frpc'
|
||
});
|
||
const peer = mailCounterpart(m, 'jianf');
|
||
expect(peer.path).toBe('/home/program/webui4frpc');
|
||
expect(peer.path).not.toBe('dsh');
|
||
});
|
||
});
|
||
|
||
describe('sessionCounterpart', () => {
|
||
it('按会话定对端,不受最后一封是谁发的影响', () => {
|
||
const mails = [
|
||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }),
|
||
// 最后一封是我自己发的 —— 原代码在这里会把自己当对端
|
||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:00:00Z' })
|
||
];
|
||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||
});
|
||
|
||
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
|
||
const mails = [
|
||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||
mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
|
||
];
|
||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||
});
|
||
|
||
it('补上首次出现时缺失的 path', () => {
|
||
const mails = [
|
||
// 首封的 session_workspace 是空的
|
||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
|
||
// 后一封才带上目录
|
||
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', session_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T09:30:00Z' })
|
||
];
|
||
// 同名 Agent 在不同目录是不同的活,path 不能丢
|
||
expect(sessionCounterpart(mails, 'jianf')).toEqual({
|
||
name: 'pi',
|
||
path: '/home/program/llmsproxy',
|
||
isHuman: false
|
||
});
|
||
});
|
||
|
||
it('同刻邮件用 mail_id 定序,结果稳定', () => {
|
||
const ts = '2026-09-03T09:00:00Z';
|
||
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: ts });
|
||
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: ts });
|
||
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
|
||
});
|
||
|
||
it('全是自己的会话返回 null 而不是自己', () => {
|
||
const mails = [mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true })];
|
||
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
|
||
});
|
||
|
||
it('空会话返回 null', () => {
|
||
expect(sessionCounterpart([], 'jianf')).toBeNull();
|
||
});
|
||
|
||
it('created_at 解析失败不影响确定性', () => {
|
||
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '不是时间' });
|
||
const good = mail({ mail_id: 'good', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: '2026-09-03T09:00:00Z' });
|
||
expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe(
|
||
sessionCounterpart([good, bad], 'jianf')?.name
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('sessionReplyTarget', () => {
|
||
it('带上会话别名:不带会落到该 Agent 的默认会话', () => {
|
||
const mails = [
|
||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy' })
|
||
];
|
||
expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe(
|
||
'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查'
|
||
);
|
||
});
|
||
|
||
it('会话未命名时省略会话段', () => {
|
||
const mails = [
|
||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '/home' })
|
||
];
|
||
expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home');
|
||
});
|
||
|
||
it('生产链条重现:回复自己发的那封仍指向 pi', () => {
|
||
const mails = [
|
||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }),
|
||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
|
||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }),
|
||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:56:34Z' })
|
||
];
|
||
const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf');
|
||
expect(target.startsWith('pi@')).toBe(true);
|
||
expect(target.startsWith('jianf@')).toBe(false);
|
||
});
|
||
|
||
it('找不到对端时给空串(调用方据此禁用发送)', () => {
|
||
expect(sessionReplyTarget([], session('x'), 'jianf')).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('mailReplyTarget', () => {
|
||
it('单封视图带上该封的会话别名', () => {
|
||
const m = mail({ from_name: 'pi', to_name: 'jianf', session_alias: 'deploy-review' });
|
||
expect(mailReplyTarget(m, 'jianf')).toBe('pi@/home/program/llmsproxy.deploy-review');
|
||
});
|
||
});
|
||
|
||
describe('replyAllCC', () => {
|
||
it('去掉自己与主收件人', () => {
|
||
const m = mail({
|
||
from_name: 'pi',
|
||
from_workspace: '/home',
|
||
to_name: 'jianf',
|
||
to_workspace: '',
|
||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||
});
|
||
// 主收件人是 pi(回复对象),自己是 jianf —— 都不该出现在抄送里
|
||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||
});
|
||
|
||
it('自己是 jianf 时不会把自己抄送进去(老判据只挡 human)', () => {
|
||
const m = mail({ from_name: 'pi', from_workspace: '/home', to_name: 'jianf', to_workspace: '' });
|
||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual([]);
|
||
});
|
||
|
||
it('cc_list 取 raw 保留会话段', () => {
|
||
const m = mail({
|
||
from_name: 'pi',
|
||
from_workspace: '/home',
|
||
to_name: 'jianf',
|
||
cc_list: [{ name: 'dsh', path: '/opt', session: 'audit', raw: 'dsh@/opt.audit' }]
|
||
});
|
||
// 重新拼 name@path 会丢掉 .audit
|
||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.audit']);
|
||
});
|
||
|
||
it('同一个人既在 to 又在 cc 时只出现一次', () => {
|
||
const m = mail({
|
||
from_name: 'pi',
|
||
from_workspace: '/home',
|
||
to_name: 'dsh',
|
||
to_human: false,
|
||
to_workspace: '/opt',
|
||
session_workspace: '/opt',
|
||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||
});
|
||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* ─── 地址展示 ───
|
||
*
|
||
* 锁的是一次生产事故:单封邮件视图的「发件」行显示成
|
||
* jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
|
||
* 而人指定的收件方是 pi@/home/program/agentmail 的那条会话。三处错叠在一起:
|
||
*
|
||
* 1. 会话别名被拼给了**发件人** —— 别名是会话的属性、两端共有,不属于任何一方
|
||
* 2. from_workspace 为空(人类没有工作目录)时拼出 `jianf.<别名>`,
|
||
* 按三维寻址「最后一个 . 切分」规则,那是 path 位为空的**非法地址**
|
||
* 3. 抄送显示 `pi@/home/program/agentmail.new` —— `.new` 建完会话就失效了,
|
||
* 留着会让人以为再发一次还能投进同一条会话
|
||
*/
|
||
describe('formatAddress 边界', () => {
|
||
it('path 为空时保留 @,不能拼成 name.session', () => {
|
||
// bug 现场:jianf 没有工作目录,界面拼出了 `jianf.某会话别名`。
|
||
// 那会被 ParseAddress 整串当成名字(实测 name="jianf.某会话别名")——
|
||
// 一个不存在的 Agent。正确形式是 `jianf@.某会话别名`。
|
||
expect(formatAddress('jianf', '', '某会话别名')).toBe('jianf@.某会话别名');
|
||
expect(formatAddress('jianf', '', '某会话别名')).not.toBe('jianf.某会话别名');
|
||
});
|
||
|
||
it('无 session 时 path 为空返回裸名字(不留孤零零的 @)', () => {
|
||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||
expect(formatAddress('jianf', ' ')).toBe('jianf');
|
||
});
|
||
|
||
it('name 为空返回空串而不是残缺地址', () => {
|
||
expect(formatAddress('', '/home')).toBe('');
|
||
expect(formatAddress(' ', '/home', 'x')).toBe('');
|
||
});
|
||
|
||
it('三段齐全时正常拼接', () => {
|
||
expect(formatAddress('pi', '/home/program/agentmail', 'my-task'))
|
||
.toBe('pi@/home/program/agentmail.my-task');
|
||
});
|
||
|
||
it('去首尾空白', () => {
|
||
expect(formatAddress(' pi ', ' /home ', ' t ')).toBe('pi@/home.t');
|
||
});
|
||
|
||
it('path 含 . 与 / 时仍按最后一个 . 切分(与后端同构)', () => {
|
||
// 拼出来的地址必须能被后端 ParseAddress 还原
|
||
const addr = formatAddress('pi', '/home/a.b/c', 'sess');
|
||
expect(addr).toBe('pi@/home/a.b/c.sess');
|
||
expect(addr.slice(addr.lastIndexOf('.') + 1)).toBe('sess');
|
||
});
|
||
});
|
||
|
||
describe('participantAddress', () => {
|
||
/**
|
||
* 人与 Agent 的地址维度不同:
|
||
* Agent 要三段(name@path.session)才唯一确定「哪个 Agent、哪个目录、哪条线索」
|
||
* 人只要名字(没有工作目录,也不需要指定会话)
|
||
*/
|
||
it('Agent 带完整三段 —— 少任何一段都不是可投递地址', () => {
|
||
expect(participantAddress('pi', false, '/home/program/agentmail', '我的任务'))
|
||
.toBe('pi@/home/program/agentmail.我的任务');
|
||
});
|
||
|
||
it('Agent 无会话别名时退到 name@path(默认会话)', () => {
|
||
expect(participantAddress('pi', false, '/home/program/agentmail', null))
|
||
.toBe('pi@/home/program/agentmail');
|
||
expect(participantAddress('pi', false, '/home/program/agentmail'))
|
||
.toBe('pi@/home/program/agentmail');
|
||
});
|
||
|
||
it('人只显示名字,即便传了会话别名也不拼', () => {
|
||
// 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上
|
||
expect(participantAddress('jianf', true, '', '某会话')).toBe('jianf');
|
||
expect(participantAddress('jianf', true, null, '某会话')).toBe('jianf');
|
||
expect(participantAddress('jianf', true)).toBe('jianf');
|
||
});
|
||
|
||
it('人的地址里不含 @ 也不含 .', () => {
|
||
const addr = participantAddress('jianf', true, '', '邮件驱动·多智能体协作平台-完整设计文档');
|
||
expect(addr).toBe('jianf');
|
||
expect(addr).not.toContain('@');
|
||
expect(addr).not.toContain('.');
|
||
});
|
||
|
||
it('人是 Agent 时 workspace 为空也不会拼出裸 @', () => {
|
||
// 边界:Agent 名在库里但 session_workspace 为空(数据不完整),
|
||
// 仍然要留 @ 而不是裸名字 —— 否则按最后一个 . 切分会错
|
||
expect(participantAddress('pi', false, '', 'sess'))
|
||
.toBe('pi@.sess');
|
||
});
|
||
});
|
||
|
||
describe('replyAllCC 保留 cc_list 的原始意图', () => {
|
||
// `.new` 是**原始意图**,不该被替换成主收件人的别名。
|
||
//
|
||
// 每个抄送方的 `.new` 是独立的:`pi@/x.new` 给 pi 开一条会话、
|
||
// `dsh@/x.new` 给 dsh 开另一条,各有自己的别名。把它们统一换成主收件人
|
||
// 那条会话的别名,等于把三条不同的线索说成同一条 —— 而数据库里 cc_list
|
||
// 存的就是原文,显示原文没有任何问题。
|
||
it('抄送里的 .new 原样保留', () => {
|
||
const m = mail({
|
||
from_name: 'jianf',
|
||
from_workspace: '',
|
||
to_name: 'pi',
|
||
to_workspace: '/home/program/agentmail',
|
||
cc_list: [{
|
||
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
|
||
}]
|
||
});
|
||
// 自己是 jianf、主收件人是 pi,剩下 dsh —— `.new` 是原文,不动
|
||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.new']);
|
||
});
|
||
|
||
it('已有具体别名的抄送也原样保留', () => {
|
||
const m = mail({
|
||
from_name: 'jianf',
|
||
from_workspace: '',
|
||
to_name: 'pi',
|
||
to_workspace: '/home',
|
||
cc_list: [{ name: 'dsh', path: '/opt', session: 'other-task', raw: 'dsh@/opt.other-task' }]
|
||
});
|
||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.other-task']);
|
||
});
|
||
|
||
it('抄送无 raw 时按结构化字段重拼(含会话位)', () => {
|
||
const m = mail({
|
||
from_name: 'jianf',
|
||
from_workspace: '',
|
||
to_name: 'pi',
|
||
to_workspace: '/home',
|
||
cc_list: [{ name: 'dsh', path: '/opt', session: 'x', raw: '' }]
|
||
});
|
||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.x']);
|
||
});
|
||
|
||
it('人类发件人(无 workspace)在抄送里是裸名字,不带会话位', () => {
|
||
const m = mail({
|
||
from_name: 'jianf',
|
||
from_human: true,
|
||
from_workspace: '',
|
||
to_name: 'pi',
|
||
to_human: false,
|
||
to_workspace: '/home',
|
||
session_alias: '某个很长的会话别名'
|
||
});
|
||
// replyAllCC 里的 from/to 走 formatAddress 且**不传 session** ——
|
||
// 抄送清单是「还要发给谁」,会话由主收件人的地址决定,
|
||
// 每个抄送方都带一遍会话位是冗余的(且回复时后端按 reply_to 定位会话)
|
||
expect(replyAllCC(m, 'dsh', 'pi')).toEqual(['jianf']);
|
||
});
|
||
});
|