Files
MailUI4Agents/web/test/components/replyTarget.test.tsx
JianFeeeee 55b3f9bc4e fix(web): 地址显示按「人 / Agent」分维度 —— 别名跟 Agent 走,人只显示名字
## 症状

单封邮件的元信息三行都不对(生产实测那封 12:12:12):

    发件  jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
    收件  pi@/home/program/agentmail
    抄送  pi@/home/program/agentmail.new

人指定的是「投进 pi 的那条会话」,而界面把会话别名拼给了**发件人**。

## 三处错

**1. 别名拼错了一方。** `name@path.session` 三段才唯一确定「哪个 Agent、
在哪个目录、哪条线索」—— 别名必须跟 Agent 走。拼给发件人之后收件人变成
`pi@/home/program/agentmail`,那指向**默认会话**而不是人指定的那条。

**2. 人不该有目录和会话位。** 人没有工作目录,发给人就是进收件箱。
`jianf.某会话` 是把 Agent 的三维语义硬套在人身上,而且因为 from_workspace
为空,拼出来的形态连 ParseAddress 都还原不了 —— 没有 `@` 时整串被当成
**名字**(实测 name="jianf.某会话别名"),投递必然 404。

**3. 抄送残留 `.new`。** 它是一次性动作,建完会话就失效;留着会让人以为
再发一次还能投进同一条会话,实际会开出第三条。

## 修法

`identityAddress` → `participantAddress(name, workspace, alias)`,
判据是有没有 workspace:

    Agent → pi@/home/program/agentmail.日程提醒:…    三段齐全
    人    → jianf                                     裸名字

`ccAddress` 按同一判据分流;`.new` 换成当前会话别名。
六处手工拼接(MailView / MailList ×2 / ThreadView)统一走这两个函数。

## 顺带修掉 `dsh@dsh`

改的时候实测发现:**`mails.from_workspace` 对 Agent 存的是 Agent 名而不是
路径**(历史遗留,见 db/migrate.go 里 sessions.workspace 的注释)。
拿它当路径拼,Agent 发来的信显示成 `dsh@dsh`。

会话的 workspace 才是权威来源 → `models.Mail` 新增 `SessionWorkspace`,
六处查询补 `s.workspace`:GetMailByID / ListInbox / GetSessionMails /
GetSessionMailByID / ListSentBy / threadCols。

## formatAddress 与后端对齐

第一版我改成「path 为空时舍弃 session 返回裸名字」,对着后端 ParseAddress
跑了一遍才发现搞反了 —— **正确形态是保留 `@`**:

    jianf@.任务  → name=jianf path="" session=任务   ✓
    jianf.任务   → name="jianf.任务"                  ✗

现在两端六个 case 逐例一致(这个分支只在内部逻辑上用得到;
展示一律走 participantAddress,人根本不带会话位)。

## 取舍

列表行与对话树节点**不带会话位**:列表的分组头已单独显示别名,
树的每个节点都在同一条线索上 —— 重复无信息量,而 92 字节的别名会把那行挤没。

## homeagent 日程工具的两个修复(同批)

**查询串手拼吃掉了时区。** RFC3339 的 `+08:00` 里那个 `+` 在查询串里正是
空格的转义形式,服务端 ParseQuery 还原成空格 → time.Parse 失败 →
AgentListCalendarEvents **静默退回默认区间**(不报错)。表现为「明明有日程
却说一条都没有」。改走 url.Values.Encode()。

**默认窗口 3 个月太窄。** yearly / lunar_yearly 的下一次触发随时落在窗口外,
模型问「我建过什么」得到空结果,然后照着空结果再建一条重复的。改成 14 个月。
空结果的话术也从「你还没有建过日程」改成说出实际查询区间 —— 前者在窗口外
有事件时是假话。

## 验收

- web 182 例(replyTarget 24 → 46);tsc 无错;Gateway 7 包全过
- 新增 test/manual/addr-verify.mjs:真渲染两个方向都验过
    人 → Agent:jianf / pi@/home/program/agentmail.日程提醒:…
    Agent → 人:dsh@/home/program/agentmail.查看工程与插件适配指南 / jianf
  判据含「Agent 的 path 必须是真路径而不是 Agent 名」(锁 dsh@dsh 那个 bug)
2026-09-04 13:49:16 +08:00

426 lines
17 KiB
TypeScript
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.

import { describe, it, expect } from 'vitest';
import {
formatAddress,
mailCounterpart,
sessionCounterpart,
sessionReplyTarget,
mailReplyTarget,
replyAllCC,
participantAddress,
ccAddress
} from '../../src/lib/replyTarget';
import type { Address, 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`,
...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' });
});
it('我发出的 → 回给收件人(不是回给自己)', () => {
const m = mail({
from_name: 'jianf',
from_workspace: '',
to_name: 'pi',
to_workspace: '/home/program/llmsproxy'
});
// 这一条就是生产 bug原代码在这里返回 jianf
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
});
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');
});
});
describe('sessionCounterpart', () => {
it('按会话定对端,不受最后一封是谁发的影响', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', 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_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:00:00Z' })
];
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
});
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', 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 = [
// 首封的 to_workspace 是空的
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
// 后一封才带上目录
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
];
// 同名 Agent 在不同目录是不同的活path 不能丢
expect(sessionCounterpart(mails, 'jianf')).toEqual({
name: 'pi',
path: '/home/program/llmsproxy'
});
});
it('同刻邮件用 mail_id 定序,结果稳定', () => {
const ts = '2026-09-03T09:00:00Z';
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: ts });
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: ts });
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
});
it('全是自己的会话返回 null 而不是自己', () => {
const mails = [mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf' })];
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
});
it('空会话返回 null', () => {
expect(sessionCounterpart([], 'jianf')).toBeNull();
});
it('created_at 解析失败不影响确定性', () => {
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '不是时间' });
const good = mail({ mail_id: 'good', from_name: 'jianf', from_workspace: '', to_name: 'dsh', 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_workspace: '', to_name: 'pi', 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_workspace: '', to_name: 'pi', to_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_workspace: '', to_name: 'pi', 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_workspace: '', to_name: 'jianf', 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_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', '/home/program/agentmail', '我的任务'))
.toBe('pi@/home/program/agentmail.我的任务');
});
it('Agent 无会话别名时退到 name@path默认会话', () => {
expect(participantAddress('pi', '/home/program/agentmail', null))
.toBe('pi@/home/program/agentmail');
expect(participantAddress('pi', '/home/program/agentmail'))
.toBe('pi@/home/program/agentmail');
});
it('人只显示名字,即便传了会话别名也不拼', () => {
// 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上
expect(participantAddress('jianf', '', '某会话')).toBe('jianf');
expect(participantAddress('jianf', null, '某会话')).toBe('jianf');
expect(participantAddress('jianf')).toBe('jianf');
});
it('人的地址里不含 @ 也不含 .', () => {
const addr = participantAddress('jianf', '', '邮件驱动·多智能体协作平台-完整设计文档');
expect(addr).toBe('jianf');
expect(addr).not.toContain('@');
expect(addr).not.toContain('.');
});
it('workspace 只有空白时按人处理', () => {
expect(participantAddress('jianf', ' ', '某会话')).toBe('jianf');
});
});
describe('ccAddress', () => {
const cc = (over: Partial<Address> = {}): Address => ({
name: 'pi',
path: '/home/program/agentmail',
session: '',
raw: 'pi@/home/program/agentmail',
...over
});
it('.new 换成当前会话别名', () => {
const a = cc({ session: 'new', raw: 'pi@/home/program/agentmail.new' });
expect(ccAddress(a, '我的任务')).toBe('pi@/home/program/agentmail.我的任务');
});
it('不知道真别名时省略会话位,不保留 .new', () => {
const a = cc({ session: 'new', raw: 'pi@/home/program/agentmail.new' });
// `name@path` 至少指向默认会话;`.new` 一定会建新的
expect(ccAddress(a, null)).toBe('pi@/home/program/agentmail');
expect(ccAddress(a)).toBe('pi@/home/program/agentmail');
});
it('已有具体别名时原样保留(那是用户写下的原文)', () => {
const a = cc({ session: 'other-task', raw: 'pi@/home.other-task' });
expect(ccAddress(a, '我的任务')).toBe('pi@/home.other-task');
});
it('无 raw 时按结构化字段重拼', () => {
const a = cc({ session: 'x', raw: '' });
expect(ccAddress(a, null)).toBe('pi@/home/program/agentmail.x');
});
it('name 缺失时退回 raw 而不是抛错', () => {
expect(ccAddress({ name: '', path: '', session: '', raw: '原文' } as Address)).toBe('原文');
});
});
describe('replyAllCC 的 .new 处理', () => {
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.当前任务']);
});
it('不传别名时抄送退到默认会话而不是 .new', () => {
const m = mail({
from_name: 'jianf',
from_workspace: '',
to_name: 'pi',
to_workspace: '/home',
cc_list: [{ name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new' }]
});
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
});
it('人类发件人(无 workspace在抄送里是裸名字不带会话位', () => {
const m = mail({
from_name: 'jianf',
from_workspace: '',
to_name: 'pi',
to_workspace: '/home',
session_alias: '某个很长的会话别名'
});
// replyAllCC 里的 from/to 走 formatAddress 且**不传 session** ——
// 抄送清单是「还要发给谁」,会话由主收件人的地址决定,
// 每个抄送方都带一遍会话位是冗余的(且回复时后端按 reply_to 定位会话)
expect(replyAllCC(m, 'dsh', 'pi', '某个很长的会话别名')).toEqual(['jianf']);
});
});
describe('ccAddress 对人与 Agent 的分流', () => {
it('抄送给人时只显示名字(不带会话位)', () => {
const human = { name: 'jianf', path: '', session: '', raw: 'jianf' } as Address;
expect(ccAddress(human, '某会话')).toBe('jianf');
});
it('抄送给 Agent 时带完整三段', () => {
const agent = {
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
} as Address;
expect(ccAddress(agent, '当前任务')).toBe('dsh@/opt.当前任务');
});
});