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)
This commit is contained in:
2026-09-04 13:49:16 +08:00
parent 390fef8941
commit 55b3f9bc4e
11 changed files with 531 additions and 73 deletions

View File

@ -5,9 +5,11 @@ import {
sessionCounterpart,
sessionReplyTarget,
mailReplyTarget,
replyAllCC
replyAllCC,
participantAddress,
ccAddress
} from '../../src/lib/replyTarget';
import type { Mail, Session } from '../../src/types';
import type { Address, Mail, Session } from '../../src/types';
/**
* 「这封回复该发给谁」。
@ -62,8 +64,13 @@ describe('formatAddress', () => {
expect(formatAddress('pi', '/home')).toBe('pi@/home');
expect(formatAddress('pi', '/home', null)).toBe('pi@/home');
});
it('path 为空仍保留 @(人类没有工作目录', () => {
expect(formatAddress('jianf', '')).toBe('jianf@');
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('');
@ -239,3 +246,180 @@ describe('replyAllCC', () => {
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.当前任务');
});
});

View File

@ -0,0 +1,98 @@
/**
* 邮件详情页地址行的真实渲染验收。
*
* 锁的是那次事故:发件行显示成 `jianf.<会话别名>` —— 别名拼给了发件人,
* 而且 path 为空时拼出了 ParseAddress 会整串当成名字的非法形态。
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
const { browser, page } = await openApp(WIDE);
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) console.log(` 通过 ${name}`);
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
};
// 走**发件**箱:报这个 bug 的那封信是人发出去的jianf → pi抄送 pi@….new
// 收件箱里只有 Agent 的回信 —— 而回信没有抄送、也不带 `.new`
// 探不到要验的那三处。
await page.waitForSelector('button', { timeout: 20000 });
await page.click('button:has-text("发件")');
await page.waitForTimeout(2000);
// 收件箱/发件箱按会话分组:先点组头展开,再点里面的邮件行。
// 两者 className 都含 `w-full text-left`,靠「点完有没有出现含『发件』的 dl」区分。
let opened = false;
for (let round = 0; round < 3 && !opened; round++) {
const btns = await page.$$('button.w-full.text-left');
for (const b of btns) {
const t = await b.innerText().catch(() => '');
if (!t) continue;
await b.click().catch(() => {});
await page.waitForTimeout(350);
const hasMeta = await page.evaluate(() =>
[...document.querySelectorAll('dl')].some(dl => dl.textContent.includes('发件')));
if (hasMeta) { opened = true; break; }
}
}
check('打开了一封邮件', opened);
await page.waitForTimeout(800);
// 读元信息 dl
const meta = await page.evaluate(() => {
const dls = [...document.querySelectorAll('dl')];
for (const dl of dls) {
const pairs = [];
const kids = [...dl.children];
for (const div of kids) {
const dt = div.querySelector('dt'), dd = div.querySelector('dd');
if (dt && dd) pairs.push([dt.textContent.trim(), dd.textContent.trim()]);
}
if (pairs.some(([k]) => k === '发件')) return Object.fromEntries(pairs);
}
return null;
});
if (!meta) {
check('找到元信息区', false, '没有含「发件」的 dl');
} else {
console.log(' 元信息:', JSON.stringify(meta));
const from = meta['发件'] || '';
const to = meta['收件'] || '';
const cc = meta['抄送'] || '';
// 判「哪一方是人」:人的地址里既无 @ 也无 .
const isHuman = a => a && !a.includes('@') && !a.includes('.');
const human = isHuman(from) ? from : (isHuman(to) ? to : '');
const agent = isHuman(from) ? to : from;
check('有一方是人(裸名字,无 @ 无 .', !!human, `发件=${from} 收件=${to}`);
check('人的地址不含会话位', !human || !/[@.]/.test(human), `人=${human}`);
// Agent 必须三段齐全name@path.session
const threeSeg = /^[^@]+@\/[^@]*\.[^.@]+$/.test(agent);
check('Agent 带完整三段 name@path.session', threeSeg, `agent=${agent}`);
// path 不能是 Agent 名(`dsh@dsh` 那个 bug
if (agent.includes('@')) {
const [n, rest] = [agent.slice(0, agent.indexOf('@')), agent.slice(agent.indexOf('@') + 1)];
check('Agent 的 path 是真路径而不是 Agent 名',
rest.startsWith('/'), `${n}@${rest}`);
}
// 别名不能挂在人身上
check('会话别名跟着 Agent 而不是人',
!human || !agent || agent.includes('.'), `人=${human} agent=${agent}`);
// 抄送里不能残留 .new
check('抄送里没有 .new',
!cc.split('、').some(a => a.trim().endsWith('.new')), `抄送=${cc}`);
if (cc) console.log(` 抄送实际值: ${cc}`);
// 不该再有独立的「会话」行(别名已在 Agent 地址里)
check('没有多余的「会话」行', !('会话' in meta), `会话=${meta['会话']}`);
}
await browser.close();
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
process.exit(fail ? 1 : 0);