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

@ -140,6 +140,19 @@ type Mail struct {
HopLimit int `json:"hop_limit"`
SessionAlias string `json:"session_alias,omitempty"`
// SessionWorkspace 是**这条会话**的工作目录sessions.workspace
//
// 为什么不能用 FromWorkspace / ToWorkspace 代替:
// - 人 → Agentto_workspace 是真路径from_workspace 为空(人没有工作目录)
// - Agent → 人to_workspace 为空,而 **from_workspace 存的是 Agent 名
// 而不是路径**(历史遗留,见 db/migrate.go 的 sessions.workspace 注释)
//
// 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取 ——
// 前端要靠它拼出 `name@path.alias` 这个可投递地址(界面上曾显示成
// `dsh@dsh`,就是拿 from_workspace 当路径拼出来的)。
SessionWorkspace string `json:"session_workspace,omitempty"`
BodyPreview string `json:"body_preview,omitempty"`
// Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空

View File

@ -341,14 +341,14 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias, m.rename_alias, m.rename_reason
m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.mail_id = $1`, id,
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias, &renameAlias, &renameReason)
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason)
if err != nil {
return nil, err
}
@ -381,7 +381,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias
m.status, m.created_at, s.session_alias, s.workspace
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
@ -410,7 +410,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias); err != nil {
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil {
return nil, err
}
if len(ccJSON) > 0 {
@ -451,7 +451,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias
m.status, m.created_at, s.session_alias, s.workspace
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.session_id = $1
@ -469,7 +469,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias); err != nil {
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil {
return nil, err
}
if len(ccJSON) > 0 {
@ -570,13 +570,13 @@ func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*mode
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias
m.status, m.created_at, s.session_alias, s.workspace
FROM mails m JOIN sessions s ON m.session_id = s.session_id
WHERE m.session_id = $1 AND m.mail_id = $2`, sessionID, mailID,
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias)
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace)
if err != nil {
return nil, err
}
@ -1036,7 +1036,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias
m.status, m.created_at, s.session_alias, s.workspace
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.from_name = $1 AND s.status <> 'archived'
@ -1056,7 +1056,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias); err != nil {
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil {
return nil, err
}
if len(ccJSON) > 0 {

View File

@ -50,7 +50,7 @@ const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type,
COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias,
m.status, m.created_at, s.session_alias, s.workspace,
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count`
// ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。
@ -202,7 +202,7 @@ func scanTreeRows(rows interface {
if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID,
&t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace,
&ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult,
&t.Status, &t.CreatedAt, &alias, &t.AttachmentCount, &lvl); err != nil {
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount, &lvl); err != nil {
return nil, err
}
if len(ccJSON) > 0 {

View File

@ -2,6 +2,7 @@ package main
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
@ -103,29 +104,58 @@ func (p *Plugin) handleCreateSchedule(args map[string]interface{}) (interface{},
}
// handleListSchedules 列出自己建的日程。
//
// 查询串必须走 url.Values 编码,**不能手拼**。
//
// RFC3339 的时区偏移带一个 `+`2026-12-11T09:00:00+08:00而 `+` 在查询串里
// 正是空格的转义形式:手拼进 URL 后服务端 ParseQuery 把它还原成空格,
// time.Parse(RFC3339) 随即失败 —— 而 AgentListCalendarEvents 解析失败时是
// **静默退回默认区间**now-24h ~ now+3月不报错。
// 于是这里传的 from/to 全部无效,表现为「明明有日程却说一条都没有」。
// 真实踩过一次:先怀疑了数据库、密钥和权限,最后才发现是这个加号。
func (p *Plugin) handleListSchedules(args map[string]interface{}) (interface{}, error) {
q := "?status=" + argStringOr(args, "status", "active")
q := url.Values{}
q.Set("status", argStringOr(args, "status", "active"))
if v := strings.TrimSpace(argString(args, "from")); v != "" {
if t, err := parseEventTime(v); err == nil {
q += "&from=" + t.Format(time.RFC3339)
q.Set("from", t.Format(time.RFC3339))
}
}
if v := strings.TrimSpace(argString(args, "to")); v != "" {
if t, err := parseEventTime(v); err == nil {
q += "&to=" + t.Format(time.RFC3339)
q.Set("to", t.Format(time.RFC3339))
}
} else {
// 默认往后 14 个月,而不是沿用服务端的 3 个月。
//
// 生日、纪念日这类 yearly / lunar_yearly 的下一次触发随时会落在
// 3 个月之外。窗口太窄时模型问「我建过什么」得到的是空 ——
// 然后它会照着空结果再建一条重复的。14 个月保证任何年度重复
// 都至少露一次脸。
q.Set("to", time.Now().AddDate(1, 2, 0).Format(time.RFC3339))
}
var res struct {
Events []scheduleEvent `json:"events"`
ActiveLimit int `json:"active_limit"`
}
if err := p.get(p.gwURL+"/api/v1/agent/calendar/events"+q, &res); err != nil {
if err := p.get(p.gwURL+"/api/v1/agent/calendar/events?"+q.Encode(), &res); err != nil {
return nil, err
}
if len(res.Events) == 0 {
return textResult("你还没有建过日程。用 create_schedule 建一条。"), nil
// 不能说「你还没有建过日程」——窗口外或别的 status 下还有事件时
// 这是假话,而模型会照着这句话去建一条重复的。
// 把实际查询区间说出来,让它知道该往哪放宽。
from := q.Get("from")
if from == "" {
from = "默认起点(昨天)"
}
return textResult(fmt.Sprintf(
"这个区间内没有日程:%s ~ %sstatus=%s。\n"+
"想找更早/更远的请放宽 from/to或用 status=all 看已暂停与已取消的。\n"+
"确认真的没有再用 create_schedule 建 —— 别照着空结果建重复的。",
from, q.Get("to"), q.Get("status"))), nil
}
var sb strings.Builder

View File

@ -5,6 +5,7 @@ import { useUIStore } from '../stores/uiStore';
import type { Mail } from '../types';
import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups';
import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons';
import { participantAddress } from '../lib/replyTarget';
export default function MailList() {
const viewMode = useUIStore(s => s.viewMode);
@ -133,9 +134,15 @@ function SessionGroup({
minute: '2-digit'
});
const peer = showTo
? `${g.latest.to_name}${g.latest.to_workspace ? '@' + g.latest.to_workspace : ''}`
: `${g.latest.from_name}${g.latest.from_workspace ? '@' + g.latest.from_workspace : ''}`;
// 列表行只显示「跟谁在通信」,**不带会话位**:分组头下面已经单独显示了
// 会话别名,再拼一遍会让长别名(实测 92 字节)把这一行挤没。
//
// workspace 从会话取from_workspace 对 Agent 存的是 Agent 名而非路径。
const peerWs = showTo ? g.latest.to_workspace : g.latest.from_workspace;
const peer = participantAddress(
showTo ? g.latest.to_name : g.latest.from_name,
peerWs ? g.latest.session_workspace || '' : ''
);
// 组内含选中邮件时给个边框,否则展开一个组再滚下去会找不到自己在看哪封
const hasActive = currentMailID ? g.mails.some(m => m.mail_id === currentMailID) : false;
@ -233,9 +240,11 @@ function MailItem({
minute: '2-digit'
});
const peer = showTo
? `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`
: `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}`;
const rowWs = showTo ? mail.to_workspace : mail.from_workspace;
const peer = participantAddress(
showTo ? mail.to_name : mail.from_name,
rowWs ? mail.session_workspace || '' : ''
);
return (
<button

View File

@ -9,7 +9,9 @@ import {
sessionReplyTarget,
mailReplyTarget,
mailCounterpart,
replyAllCC
replyAllCC,
participantAddress,
ccAddress
} from '../lib/replyTarget';
import * as api from '../api/client';
import type { Mail } from '../types';
@ -367,10 +369,29 @@ function Header({
onThread: () => void;
}) {
const time = new Date(mail.created_at).toLocaleString('zh-CN');
const from = `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}${
mail.session_alias ? '.' + mail.session_alias : ''
}`;
const to = `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`;
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
//
// Agent → `pi@/home/program/agentmail.<会话别名>` 三段才唯一确定
// 人 → `jianf` 人没有目录也不需要会话位
//
// 此前这里有两处错:别名被拼给了**发件人**`jianf.<别名>` —— 既指错归属,
// 又因为人没有工作目录而拼出 path 位为空的非法地址),以及收件人**没有**
// 别名(`pi@/home/program/agentmail` 指向默认会话,不是人指定的那条)。
//
// workspace 取 `session_workspace` 而不是 from/to_workspace后者对 Agent
// 存的是 Agent 名而非路径(历史遗留),拿它拼会得到 `dsh@dsh`。
const ws = mail.session_workspace || '';
const from = participantAddress(
mail.from_name,
mail.from_workspace ? ws : '',
mail.session_alias
);
const to = participantAddress(
mail.to_name,
mail.to_workspace ? ws : '',
mail.session_alias
);
return (
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
@ -415,7 +436,11 @@ function Header({
<Row label="发件">{from}</Row>
<Row label="收件">{to}</Row>
{mail.cc_list?.length > 0 && (
<Row label="抄送">{mail.cc_list.map(a => a.raw).join('、')}</Row>
// 抄送走 ccAddress把一次性的 `.new` 换成真实会话别名。
// 留着 `.new` 会让人以为再发一次还能投进同一条会话,实际会开新的。
<Row label="抄送">
{mail.cc_list.map(a => ccAddress(a, mail.session_alias)).join('、')}
</Row>
)}
<Row label="时间">{time}</Row>
</dl>
@ -427,7 +452,8 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
return (
<div className="flex gap-2">
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
<dd className="font-mono text-gray-600 break-all">{children}</dd>
{/* min-w-0 + break-all会话别名可达 128 字节,不给收缩权会把整行撑出容器 */}
<dd className="min-w-0 font-mono text-gray-600 break-all">{children}</dd>
</div>
);
}
@ -467,7 +493,7 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
{mail.cc_list?.length > 0 && (
<span
className="text-[10px] text-gray-400"
title={mail.cc_list.map(c => c.raw || c.name).join(', ')}
title={mail.cc_list.map(c => ccAddress(c, mail.session_alias)).join(', ')}
>
{mail.cc_list.length}
</span>
@ -628,13 +654,14 @@ function ReplyBar({
/**
* 「回复全部」:把原邮件的其他参与方填进抄送。
*
* cc_list 是结构化的 Address后端解析过三维寻址,取 raw 回填 ——
* 那是用户当初写下的原文,重新拼 name@path 会丢掉会话段。
* cc_list 是结构化的 Address后端解析过三维寻址。传当前会话别名进去,
* 让 `.new` 被换成真实别名 —— 原样回填 `.new` 会让这封回复给抄送方
* **另开一条新会话**,于是同一件事裂成两条线索。
*/
const replyAll = () => {
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
// 去自己,同一个遗留判据 —— 去不掉 jianf点「回复全部」会把自己抄送进去。
setCc(replyAllCC(replyTo, me, peer.name).join(', '));
setCc(replyAllCC(replyTo, me, peer.name, replyTo.session_alias).join(', '));
setCcOpen(true);
};

View File

@ -3,6 +3,7 @@ import * as api from '../api/client';
import { useMailStore } from '../stores/mailStore';
import type { ThreadNode } from '../types';
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
import { participantAddress, ccAddress } from '../lib/replyTarget';
import BackButton from './BackButton';
import { useIsNarrow } from '../hooks/useIsNarrow';
@ -236,9 +237,14 @@ function Node({
) : (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
)}
{/* 树节点一行里塞了 from → to、转发标记与时间不带会话位
整棵树本来就在同一条线索上,每个节点重复一遍别名毫无信息量。
workspace 从会话取from_workspace 对 Agent 存的是 Agent 名)。 */}
<span className="text-xs font-mono text-gray-700 truncate">
{node.from_name}
{node.from_workspace && `@${node.from_workspace}`}
{participantAddress(
node.from_name,
node.from_workspace ? node.session_workspace || '' : ''
)}
</span>
<span className="text-[10px] text-gray-400"></span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
@ -275,7 +281,7 @@ function Node({
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
{ccCount > 0 && (
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
{node.cc_list.map(c => c.raw || c.name).join('、')}
{node.cc_list.map(c => ccAddress(c, node.session_alias)).join('、')}
</p>
)}
{node.body_preview && (

View File

@ -1,4 +1,4 @@
import type { Mail, Session } from '../types';
import type { Address, Mail, Session } from '../types';
/**
* 「这封回复该发给谁」。
@ -28,11 +28,64 @@ export interface Counterpart {
path: string;
}
/** 拼三维地址 `name@path.session`。session 为空时省略该段。 */
/**
* 拼三维地址 `name@path.session`。**逐字节对齐后端 `models.FormatAddress`。**
*
* 三个分支缺一不可:
*
* session 为空 + path 为空 → `jianf` (裸名字 = 默认会话)
* session 为空 + 有 path → `pi@/home`
* session 非空 → `pi@/home.任务` / `jianf@.任务`
*
* **最后一个分支在 path 为空时仍要保留 `@`。**
* 地址按**最后一个 `.`** 切分:`jianf@.任务` 能正确还原成
* name=`jianf` / path=`` / session=`任务`,而漏掉 `@` 的 `jianf.任务`
* 会被整串当成**名字**(实测 ParseAddress 返回 name="jianf.任务")——
* 那是个不存在的 Agent投递必然 404。
*
* 人类没有工作目录,所以 path 为空是界面上的常态而非边界情形:
* 给人类回信时若丢掉 `@`,整条地址就废了。
*
* 展示用途请走 `participantAddress` —— 它按「人 / Agent」决定带几段。
*/
export function formatAddress(name: string, path: string, session?: string | null): string {
if (!name) return '';
const base = `${name}@${path || ''}`;
return session ? `${base}.${session}` : base;
const n = (name || '').trim();
if (!n) return '';
const p = (path || '').trim();
const s = (session || '').trim();
if (!s) return p ? `${n}@${p}` : n;
return `${n}@${p}.${s}`;
}
/**
* 一个参与方在**这封邮件所属会话**里的完整地址。
*
* # 人与 Agent 的地址维度不同
*
* **Agent 要三段**`name@path.session` 才唯一确定「哪个 Agent、在哪个目录、
* 哪条线索」。同名 Agent 在不同目录是不同的活,同一目录下不同会话是不同的任务
* —— 少任何一段都不是个可投递的地址。
*
* **人只要名字**:人没有工作目录,也不需要指定会话(发给人就是进他的收件箱)。
* 给人拼 `jianf@.某会话` 或 `jianf.某会话` 都是把 Agent 的维度硬套在人身上。
*
* 判据是有没有 workspaceAgent 一定带工作目录,人一定不带。
*
* # workspace 必须从**会话**取,不能用 from_workspace
*
* `mails.from_workspace` 对 Agent 存的是**Agent 名而不是路径**(历史遗留,
* 见后端 db/migrate.go 里 sessions.workspace 的注释)。拿它当路径拼会得到
* `dsh@dsh` —— 界面上真出现过。会话的 `workspace` 才是权威来源。
*/
export function participantAddress(
name: string,
workspace?: string | null,
sessionAlias?: string | null
): string {
const path = (workspace || '').trim();
// 人(无工作目录):只有名字,不带 path 也不带会话位
if (!path) return formatAddress(name, '');
return formatAddress(name, path, sessionAlias || null);
}
/**
@ -121,12 +174,17 @@ export function mailReplyTarget(mail: Mail, me: string): string {
* 原先用 `!a.startsWith('human')` 去掉自己 —— 同一个遗留判据,
* 结果是点「回复全部」会把自己抄送进去。
*/
export function replyAllCC(mail: Mail, me: string, primaryName: string): string[] {
export function replyAllCC(
mail: Mail,
me: string,
primaryName: string,
currentAlias?: string | null
): string[] {
const raw = [
formatAddress(mail.from_name, mail.from_workspace || ''),
formatAddress(mail.to_name, mail.to_workspace || ''),
// cc_list 取 raw那是用户当初写下的原文重新拼会丢掉会话段
...(mail.cc_list ?? []).map(c => c.raw || c.name)
// cc_list 走 ccAddress它把一次性的 `.new` 换成真实会话别名
...(mail.cc_list ?? []).map(c => ccAddress(c, currentAlias))
];
const seen = new Set<string>();
@ -143,3 +201,24 @@ export function replyAllCC(mail: Mail, me: string, primaryName: string): string[
}
return out;
}
/**
* 一个抄送地址的展示/投递形式,把 `.new` 换成真实会话别名。
*
* `.new` 只在**发信那一刻**有意义:它建完会话就用完了。存档后继续显示
* `pi@/x.new` 会让人以为再发一次还能投进同一条会话,实际会开出第三条 ——
* 与插件侧 SSE 事件把 `.new` 换成真别名(`reply_address`)同一道理。
*
* 不知道真别名时省略会话位而不是保留 `.new``name@path` 至少指向默认会话,
* 而 `.new` 一定会建新的。
*/
export function ccAddress(cc: Address, currentAlias?: string | null): string {
if (!cc?.name) return cc?.raw || '';
// 人(无 path只显示名字 —— 与 participantAddress 同一判据。
// 抄送给人时地址里出现会话位是把 Agent 的维度套在人身上。
if (!(cc.path || '').trim()) return cc.name;
if ((cc.session || '').trim() === 'new') {
return formatAddress(cc.name, cc.path || '', currentAlias || null);
}
return cc.raw || formatAddress(cc.name, cc.path || '', cc.session || null);
}

View File

@ -100,6 +100,18 @@ export interface Mail {
created_at: string;
hop_limit?: number;
session_alias?: string;
/**
* **这条会话**的工作目录sessions.workspace
*
* 不能用 from_workspace / to_workspace 代替:
* - 人 → Agentto_workspace 是真路径from_workspace 为空(人没有工作目录)
* - Agent → 人to_workspace 为空,而 **from_workspace 存的是 Agent 名**
* 而不是路径(历史遗留)
*
* 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取。
* 界面上曾显示成 `dsh@dsh`,就是拿 from_workspace 当路径拼的。
*/
session_workspace?: string;
body_preview?: string;
attachments?: Attachment[];
}

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);