From c29746881969d9b0028192299750409bba1f3ea6 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 19:06:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=20platform=5Fsession=5Fid=20=E6=97=A0?= =?UTF-8?q?=E5=B7=AE=E5=88=AB=E4=B8=8B=E5=8F=91=E5=AF=BC=E8=87=B4=E6=8A=84?= =?UTF-8?q?=E9=80=81=E6=96=B9=E9=82=AE=E4=BB=B6=E9=9D=99=E9=BB=98=E6=B6=88?= =?UTF-8?q?=E5=A4=B1=20+=20homeagent=20=E8=A1=A5=E6=8A=95=E6=BC=8F?= =?UTF-8?q?=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## platform_session_id 只发给归属方(Gateway) `notify.Recipients` 原来对所有参与方推同一个 `platform_session_id`, 而那是**会话级**的一个值。生产实测:会话 16845133 接管了 pi 的平台会话 `01a05a5e-…`,那封邮件抄送了 dsh@/home/program/agentmail.new。DSH 收到 同一个 id,在 ~/.dsh/sessions/ 里查不到(那是 /root/.pi/agent/sessions/ 下的文件),于是走进「平台侧会话已删」那道防线抛错。 那道防线本身是对的(N-8:不能退回新建,否则人在界面上看不到这封邮件带来 的对话),它拦下的却是「别人的会话」。异常被 ctx.logger.error 吞掉,而 DSH 的 logger 不进 journalctl —— 邮件静默消失,日志里一个字都没有。 - 新增 `repo.PlatformSessionFor` 一并返回归属 Agent:以镜像 `agent_platform_sessions.agent_name` 为准,镜像整表替换后退回 `sessions.from_agent`(AdoptPlatformSession 写在那里) - `PlatformIDOf` 变薄封装,保留原签名 - `notify.Recipients` 加 `platformFor(forName)`:归属方以外一律空串; 归属抽不到时(owner 空)也不下发 —— 宁可退回当普通会话处理, 也不让一个抽不到归属的 id 把邮件弄丢 - 归属与收件角色无关:归属方在抄送位上同样拿到 ## homeagent catchUp 漏 deliveredMails 去重 `go p.catchUp(…)` 与 `go p.sseLoop()` 是两个并发 goroutine,重启时窗口 重叠:SSE 推一次 + 补投拉一次 = 同一封邮件注入两遍。homeagent 的回信正文 印证了这一点(「之前的对话时序中已经收到并确认过多次了」)。另三个插件的 catchUp 都有这层双查,只有这里漏了。 去重放在循环内逐封查而不是拉完一批再筛:InjectInputSync 一封要跑几十秒, 那期间 SSE 完全可能已经投过后面那几封。 ## DSH 接管失败改用 console.error DSH 的 ctx.logger 不进 journalctl,投递失败是「发件人等不到回信」的唯一 线索。接管失败点与 SSE 分发的 catch 都改走 console.error,并带上 mail_id 与发件人。 ## 前端 ccAddress 移除(收尾上一轮未提交的改动) cc_list 里的 `.new` 是**原始意图**,不该被替换成主收件人的别名:每个抄送 方的 `.new` 是独立的 —— pi@/x.new 给 pi 开一条、dsh@/x.new 给 dsh 开另一 条,各有自己的别名。数据库存的就是原文。删掉 ccAddress,MailView / ThreadView 直接显示 c.raw。 ## 测试 - `internal/notify/notify_test.go` +3 例:挂真实 SSE 客户端读帧,验 归属方拿到 / 抄送方为空 / 归属方在抄送位也拿到 / 普通会话全空。 负向对照跑过:platformFor 无条件返回时两条用例失败 - `internal/repo/platform_owner_test.go` +3 例:镜像取归属、普通会话、 镜像被清后退回 from_agent - 修好 web/test/components/replyTarget.test.tsx(上一轮遗留的语法损坏), 三条 .new 用例改成断言原样保留 - gateway 7 包全绿;web 176 例 + 主题 26;dsh 219 / pi 250 / opencode 201 ## 生产验证 - 抄送验证:jianf → pi(接管会话)cc dsh。DSH 正常建会话并回信「收到」, pi 走接管续谈 —— 两封回信都落在同一条线索上(此前 DSH 那封不存在) - homeagent 去重:连发两轮,其中一轮在邮件未处理完时重启 homeagent 造出 SSE/catchUp 并发窗口,两轮都只产生一封 Re: - homeagent SSE:换新 plugin.bin 后连续 89 分钟零断连(此前 2 小时 102 次 deadline exceeded 自激振荡) --- gateway/internal/notify/mail.go | 29 +++- gateway/internal/notify/notify_test.go | 171 +++++++++++++++++++ gateway/internal/repo/platform_owner_test.go | 78 +++++++++ gateway/internal/repo/platform_sessions.go | 48 +++++- plugins/dsh-mail-bridge/src/index.ts | 17 +- plugins/homeagent-mail-bridge/plugin.go | 19 +++ web/src/components/MailView.tsx | 11 +- web/src/components/ThreadView.tsx | 5 +- web/src/lib/replyTarget.ts | 30 +--- web/test/components/replyTarget.test.tsx | 88 +++------- 10 files changed, 389 insertions(+), 107 deletions(-) create mode 100644 gateway/internal/notify/notify_test.go create mode 100644 gateway/internal/repo/platform_owner_test.go diff --git a/gateway/internal/notify/mail.go b/gateway/internal/notify/mail.go index 675ce23..065eca1 100644 --- a/gateway/internal/notify/mail.go +++ b/gateway/internal/notify/mail.go @@ -74,9 +74,15 @@ func Recipients(ctx context.Context, m Mail) { // 反复建新会话。 alias := repo.SessionAliasOf(ctx, m.SessionID) - // 这条会话是否接管了一条平台侧已存在的会话(人在 TUI/GUI 里开的那种)。 - // 插件据此决定 resume 还是新建;空串就是过去的行为。 - platformID := repo.PlatformIDOf(ctx, m.SessionID) + // 这条会话是否接管了一条平台侧已存在的会话(人在 TUI/GUI 里开的那种), + // 以及那条平台会话属于哪个 Agent。插件据此决定 resume 还是新建; + // 空串就是过去的行为。 + // + // **owner 必须参与分发判据**:platform_id 是会话级的一个值,而一封邮件 + // 可以有多个参与方。无差别下发会让抄送方拿一个属于别的平台的会话 id + // 去自己磁盘上找文件,找不到就抛「平台侧会话已删」—— 邮件静默消失。 + // 生产实测过:pi 的会话 `01a05a5e-…` 被推给了抄送方 dsh。 + platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID) mailType := m.MailType if mailType == "" { @@ -87,6 +93,18 @@ func Recipients(ctx context.Context, m Mail) { replyTo = m.From } + // platformFor 只把 platform_session_id 给归属方。 + // + // owner 为空(镜像里没这条、sessions.from_agent 也空)时一律不下发: + // 宁可退回「当普通会话处理」(插件新建一条,人在界面上看不到), + // 也不能让一个抽不到归属的 id 把邮件弄丢。 + platformFor := func(forName string) string { + if platformID == "" || platformOwner == "" || forName != platformOwner { + return "" + } + return platformID + } + payload := func(role, workspace, forName string) map[string]interface{} { p := map[string]interface{}{ "mail_id": m.MailID.String(), @@ -112,7 +130,10 @@ func Recipients(ctx context.Context, m Mail) { // // 插件必须 resume 而不是新建:新建会让人在 TUI 里看不到这封邮件 // 带来的对话,而那正是接管这条会话的目的。 - "platform_session_id": platformID, + // + // **只发给归属方**:其余参与方拿到它只会去自己磁盘上找一个 + // 不存在的会话文件,然后按 N-8 报错丢掉这封邮件。 + "platform_session_id": platformFor(forName), } if m.Origin != "" { p["origin"] = m.Origin diff --git a/gateway/internal/notify/notify_test.go b/gateway/internal/notify/notify_test.go new file mode 100644 index 0000000..5902a86 --- /dev/null +++ b/gateway/internal/notify/notify_test.go @@ -0,0 +1,171 @@ +package notify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" + "github.com/agentmail/gateway/internal/sse" + "github.com/google/uuid" +) + +func setupTestDB(t *testing.T) { + t.Helper() + dir := t.TempDir() + if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil { + t.Fatalf("connect: %v", err) + } + if err := db.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(db.Close) +} + +func seedAgent(t *testing.T, name string) { + t.Helper() + if _, err := db.DB.ExecContext(context.Background(), + `INSERT INTO agents (agent_name, secret, platform, status) VALUES ($1, 'x', $1, 'online')`, + name); err != nil { + t.Fatalf("seed agent %s: %v", name, err) + } +} + +// attach 挂一个真实的 SSE 客户端并返回「读出这个 Agent 收到的 new_mail payload」的闭包。 +// +// 走真实的 sse.Default 而不是替换发送函数:要验的正是「谁收到什么」, +// 而分发逻辑就在 Manager 里 —— 把它换掉等于不验。 +func attach(t *testing.T, agentName string) func() map[string]any { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/stream", nil) + c := sse.Default.AddClient(rec, req, agentName, "") + if c == nil { + t.Fatalf("AddClient(%s) 返回 nil", agentName) + } + t.Cleanup(func() { sse.Default.RemoveClient(c.ID) }) + + return func() map[string]any { + // SSE 帧形如 `id: N\nevent: new_mail\ndata: {…}\n\n` + for _, frame := range strings.Split(rec.Body.String(), "\n\n") { + if !strings.Contains(frame, "event: new_mail") { + continue + } + for _, line := range strings.Split(frame, "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &m); err == nil { + return m + } + } + } + return nil + } +} + +// seedAdopted 建一条接管了 owner 的平台会话的本侧会话。 +func seedAdopted(t *testing.T, owner, platformID, workspace string) uuid.UUID { + t.Helper() + ctx := context.Background() + if err := repo.ReplacePlatformSessions(ctx, owner, []repo.PlatformSession{ + {PlatformID: platformID, Workspace: workspace, Slug: "项目定位", Title: "项目定位"}, + }); err != nil { + t.Fatalf("ReplacePlatformSessions: %v", err) + } + id, err := repo.AdoptPlatformSession(ctx, owner, platformID, "项目定位", workspace, "项目定位") + if err != nil { + t.Fatalf("AdoptPlatformSession: %v", err) + } + return id +} + +// platform_session_id 只该发给归属方。 +// +// 生产事故:会话接管了 pi 的 `01a05a5e-…`,而那封邮件抄送了 dsh。DSH 收到同一个 +// id,在 `~/.dsh/sessions/` 里查不到(那是 `/root/.pi/agent/sessions/` 下的文件), +// 于是按 N-8 抛「平台侧会话已删」——邮件静默消失,日志里一个字都没有。 +func TestRecipients_PlatformIDOnlyToOwner(t *testing.T) { + setupTestDB(t) + seedAgent(t, "pi") + seedAgent(t, "dsh") + readPi, readDsh := attach(t, "pi"), attach(t, "dsh") + + sessionID := seedAdopted(t, "pi", "pid-pi-1", "/w") + + Recipients(context.Background(), Mail{ + SessionID: sessionID, + MailID: uuid.New(), + From: "jianf", + To: models.Address{Name: "pi", Path: "/w"}, + CC: []models.Address{{Name: "dsh", Path: "/w"}}, + Subject: "任务", + }) + + pi, dsh := readPi(), readDsh() + if pi == nil { + t.Fatal("归属方 pi 没收到 new_mail") + } + if dsh == nil { + t.Fatal("抄送方 dsh 没收到 new_mail(抄送方必须单独推)") + } + if v := pi["platform_session_id"]; v != "pid-pi-1" { + t.Errorf("归属方 pi 的 platform_session_id = %v, want pid-pi-1", v) + } + if v := dsh["platform_session_id"]; v != "" { + t.Errorf("抄送方 dsh 的 platform_session_id = %v, want 空串(那是 pi 的会话文件)", v) + } +} + +// 归属方在抄送位上也要拿到:归属与收件角色无关。 +func TestRecipients_PlatformIDToOwnerEvenAsCC(t *testing.T) { + setupTestDB(t) + seedAgent(t, "pi") + seedAgent(t, "dsh") + readPi, readDsh := attach(t, "pi"), attach(t, "dsh") + + sessionID := seedAdopted(t, "pi", "pid-pi-2", "/w") + + Recipients(context.Background(), Mail{ + SessionID: sessionID, + MailID: uuid.New(), + From: "jianf", + To: models.Address{Name: "dsh", Path: "/w"}, + CC: []models.Address{{Name: "pi", Path: "/w"}}, + Subject: "任务", + }) + + if v := readPi()["platform_session_id"]; v != "pid-pi-2" { + t.Errorf("抄送位上的归属方 pi = %v, want pid-pi-2", v) + } + if v := readDsh()["platform_session_id"]; v != "" { + t.Errorf("主收件人 dsh = %v, want 空串", v) + } +} + +// 普通(非接管)会话:谁都不该拿到 platform id。 +func TestRecipients_PlainSessionNoPlatformID(t *testing.T) { + setupTestDB(t) + seedAgent(t, "pi") + readPi := attach(t, "pi") + + id, err := repo.CreateSession(context.Background(), nil, "pi", "普通", "/w") + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + Recipients(context.Background(), Mail{ + SessionID: id, MailID: uuid.New(), From: "jianf", + To: models.Address{Name: "pi", Path: "/w"}, Subject: "任务", + }) + + if v := readPi()["platform_session_id"]; v != "" { + t.Errorf("普通会话 = %v, want 空串", v) + } +} diff --git a/gateway/internal/repo/platform_owner_test.go b/gateway/internal/repo/platform_owner_test.go new file mode 100644 index 0000000..a890444 --- /dev/null +++ b/gateway/internal/repo/platform_owner_test.go @@ -0,0 +1,78 @@ +package repo + +import ( + "context" + "testing" +) + +// platform_session_id 必须只发给归属方 —— 生产上 pi 的会话 id 被推给了抄送方 dsh, +// DSH 在自己磁盘上找不到那个文件,按 N-8 抛错,邮件静默消失。 +func TestPlatformSessionFor_ReturnsOwnerFromMirror(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedPlatformAgent(t, "pi") + seedPlatformAgent(t, "dsh") + + // pi 上报一条平台会话 + if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{ + {PlatformID: "pid-pi-1", Workspace: "/w", Slug: "项目定位", Title: "项目定位"}, + }); err != nil { + t.Fatalf("ReplacePlatformSessions: %v", err) + } + + id, err := AdoptPlatformSession(ctx, "pi", "pid-pi-1", "项目定位", "/w", "项目定位") + if err != nil { + t.Fatalf("AdoptPlatformSession: %v", err) + } + + pid, owner := PlatformSessionFor(ctx, id) + if pid != "pid-pi-1" { + t.Errorf("platformID = %q, want pid-pi-1", pid) + } + if owner != "pi" { + t.Errorf("owner = %q, want pi(镜像里 agent_name=pi)", owner) + } +} + +// 未接管的普通会话不该返回任何 platform id。 +func TestPlatformSessionFor_PlainSession(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "dsh", 20) + id, err := CreateSession(ctx, nil, "dsh", "普通会话", "/w") + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + pid, owner := PlatformSessionFor(ctx, id) + if pid != "" || owner != "" { + t.Errorf("got (%q,%q), want ('','')", pid, owner) + } +} + +// 镜像那行被整表替换掉(平台侧删了会话)时退回 sessions.from_agent, +// 而不是让 owner 变空 —— 变空会让归属方也收不到 platform_session_id。 +func TestPlatformSessionFor_MirrorGoneFallsBackToFromAgent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedPlatformAgent(t, "pi") + if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{ + {PlatformID: "pid-pi-2", Workspace: "/w", Slug: "s2", Title: "t2"}, + }); err != nil { + t.Fatalf("ReplacePlatformSessions: %v", err) + } + id, err := AdoptPlatformSession(ctx, "pi", "pid-pi-2", "s2", "/w", "t2") + if err != nil { + t.Fatalf("AdoptPlatformSession: %v", err) + } + // 平台侧删了这条会话 → 心跳整表替换成空 + if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{}); err != nil { + t.Fatalf("ReplacePlatformSessions(empty): %v", err) + } + pid, owner := PlatformSessionFor(ctx, id) + if pid != "pid-pi-2" { + t.Errorf("platformID = %q, want pid-pi-2", pid) + } + if owner != "pi" { + t.Errorf("owner = %q, want pi(退回 sessions.from_agent)", owner) + } +} diff --git a/gateway/internal/repo/platform_sessions.go b/gateway/internal/repo/platform_sessions.go index 5259d80..8b199d8 100644 --- a/gateway/internal/repo/platform_sessions.go +++ b/gateway/internal/repo/platform_sessions.go @@ -328,11 +328,49 @@ func AdoptPlatformSession(ctx context.Context, agentName, platformID, slug, work // PlatformIDOf 读一条本侧会话绑定的平台会话 id(空 = 不是接管来的)。 // // 投递时要把它放进 SSE 事件:插件据此决定 resume 还是新建。 +// +// 只在「已知这条会话只有一个参与方」时用它。有抄送时必须用 +// PlatformSessionFor 拿到归属方 —— 理由见那个函数。 func PlatformIDOf(ctx context.Context, sessionID uuid.UUID) string { - var pid string - if err := db.DB.QueryRowContext(ctx, - `SELECT platform_id FROM sessions WHERE session_id = $1`, sessionID).Scan(&pid); err != nil { - return "" - } + pid, _ := PlatformSessionFor(ctx, sessionID) return pid } + +// PlatformSessionFor 返回一条本侧会话绑定的平台会话 id **及其归属 Agent**。 +// +// # 为什么归属方是必须的 +// +// `platform_id` 是**会话级**的一个值,而一封邮件可以有多个参与方。 +// 把它无差别推给所有人,收到的一方会拿它去自己的磁盘上找会话文件 —— +// 那个 id 属于别的平台。 +// +// 生产实测:会话 `16845133` 接管了 pi 的会话 `01a05a5e-…`,而那封邮件抄送了 +// `dsh@/home/program/agentmail.new`。DSH 收到同一个 platform_session_id, +// 在 `~/.dsh/sessions/` 里查不到(那是 `/root/.pi/agent/sessions/` 下的文件), +// 于是走进「平台侧会话已删」那条防线抛错。那道防线本身是对的(N-8: +// 不能退回新建,否则人在界面上看不到这封邮件带来的对话),它拦下的却是 +// 「别人的会话」—— 邮件因此静默消失,而插件侧的日志走的是不进 journalctl +// 的通道,连线索都没有。 +// +// 归属方以镜像(`agent_platform_sessions.agent_name`,Agent 自己上报的)为准; +// 镜像整表替换,平台侧删了会话那行就没了,此时退回 `sessions.from_agent` —— +// `AdoptPlatformSession` 建会话时把归属 Agent 写在那里,是可靠的第二来源。 +func PlatformSessionFor(ctx context.Context, sessionID uuid.UUID) (platformID, owner string) { + var pid, fromAgent string + var mirrored *string + if err := db.DB.QueryRowContext(ctx, ` + SELECT COALESCE(s.platform_id, ''), COALESCE(s.from_agent, ''), aps.agent_name + FROM sessions s + LEFT JOIN agent_platform_sessions aps + ON aps.platform_id = s.platform_id AND COALESCE(s.platform_id, '') <> '' + WHERE s.session_id = $1`, sessionID).Scan(&pid, &fromAgent, &mirrored); err != nil { + return "", "" + } + if pid == "" { + return "", "" + } + if mirrored != nil && *mirrored != "" { + return pid, *mirrored + } + return pid, fromAgent +} diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 235e16e..be240c3 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -630,9 +630,18 @@ export function apply(ctx: any, config: PluginConfig): void { if (!existing && adoptedID) { const onDisk = await persistedCwd(adoptedID); if (onDisk === undefined) { - // 镜像是快照,可以过期:平台侧那条会话可能已经被人删了。 + // 平台侧那条会话已不在磁盘上。 + // // 不能落到「新开会话」那条路 —— 那会用 `mail-` 另开一条, - // 人在 DSH 界面上看不到这封邮件带来的对话,而那正是接管的目的。 + // 人在 DSH 界面上看不到这封邮件带来的对话,而那正是接管的目的(N-8)。 + // + // 用 console.error 而不是仅靠抛异常:调用方那层的 catch 走 + // `ctx.logger.error`,而 DSH 的 logger **不进 journalctl**。邮件因此会 + // 静默消失:发件人以为送到了,而日志里一个字都没有(实测过)。 + console.error(`[dsh-mail-bridge] 接管失败:会话 ${adoptedID} 不在本机磁盘上` + + `(mail ${data.mail_id},发件人 ${data.from_name || '?'})。` + + `若这个 id 属于另一个平台,说明 Gateway 把别人的 platform_session_id ` + + `推给了本插件。`); throw new Error(adoptMissingMessage(adoptedID, '磁盘上已无这条会话的日志')); } return locked(adoptedID, async () => { @@ -1462,7 +1471,9 @@ export function apply(ctx: any, config: PluginConfig): void { console.error(`[dsh-mail-bridge] ${type} -> ${reused ? '续谈' : '新会话'} ${sessionID}`); }) .catch((e: any) => { - ctx.logger.error(`[dsh-mail-bridge] ${type} 处理失败: ${e?.message || e}`); + // console.error 而不是 ctx.logger:DSH 的 logger 不进 journalctl, + // 而投递失败是「发件人等不到回信」的唯一线索。 + console.error(`[dsh-mail-bridge] ${type} 处理失败(mail ${data?.mail_id || '?'}): ${e?.message || e}`); }); break; case 'permission_decision': diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index 3c87ec3..5fe283a 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -513,6 +513,25 @@ func (p *Plugin) catchUp(pending int) { continue // permission 等非邮件驱动的不补投 } + // B-7.3 / B-7.6:逗封去重。 + // + // `go p.catchUp(…)` 与 `go p.sseLoop()` 是两个并发 goroutine,重启时 + // 窗口重叠:SSE 推一次 + 补投拉一次 = 同一封邮件注入两遍。 + // 生产实测过:homeagent 的回信里写着「之前的对话时序中已经收到并 + // 确认过多次了」。另三个插件的 catchUp 都有这层,只有这里漏了。 + // + // 必须在循环里逗封查而不是拉完一批再筛:InjectInputSync 一封要跑 + // 几十秒,那期间 SSE 完全可能已经投过后面那几封。 + p.sseMu.Lock() + dup := p.deliveredMails[m.MailID] + if !dup { + p.deliveredMails[m.MailID] = true + } + p.sseMu.Unlock() + if dup { + continue + } + // 构造注入消息(与 handleNewMail 一致) prompt := fmt.Sprintf( "你收到一封新邮件(AgentMail)。\n\n"+ diff --git a/web/src/components/MailView.tsx b/web/src/components/MailView.tsx index fdc6f88..861233b 100644 --- a/web/src/components/MailView.tsx +++ b/web/src/components/MailView.tsx @@ -10,8 +10,7 @@ import { mailReplyTarget, mailCounterpart, replyAllCC, - participantAddress, - ccAddress + participantAddress } from '../lib/replyTarget'; import * as api from '../api/client'; import type { Mail } from '../types'; @@ -436,10 +435,8 @@ function Header({ {from} {to} {mail.cc_list?.length > 0 && ( - // 抄送走 ccAddress:把一次性的 `.new` 换成真实会话别名。 - // 留着 `.new` 会让人以为再发一次还能投进同一条会话,实际会开新的。 - {mail.cc_list.map(a => ccAddress(a, mail.session_alias)).join('、')} + {mail.cc_list.map(a => a.raw || `${a.name}@${a.path || ''}`).join('、')} )} {time} @@ -493,7 +490,7 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) {mail.cc_list?.length > 0 && ( ccAddress(c, mail.session_alias)).join(', ')} + title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')} > 抄送 {mail.cc_list.length} @@ -661,7 +658,7 @@ function ReplyBar({ const replyAll = () => { // 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human') // 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。 - setCc(replyAllCC(replyTo, me, peer.name, replyTo.session_alias).join(', ')); + setCc(replyAllCC(replyTo, me, peer.name).join(', ')); setCcOpen(true); }; diff --git a/web/src/components/ThreadView.tsx b/web/src/components/ThreadView.tsx index 917dacd..e180cf8 100644 --- a/web/src/components/ThreadView.tsx +++ b/web/src/components/ThreadView.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import * as api from '../api/client'; import { useMailStore } from '../stores/mailStore'; +import { participantAddress } from '../lib/replyTarget'; 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'; @@ -281,7 +282,7 @@ function Node({ 不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */} {ccCount > 0 && (

- 抄送 {node.cc_list.map(c => ccAddress(c, node.session_alias)).join('、')} + 抄送 {node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')}

)} {node.body_preview && ( diff --git a/web/src/lib/replyTarget.ts b/web/src/lib/replyTarget.ts index 7f8db34..793a64a 100644 --- a/web/src/lib/replyTarget.ts +++ b/web/src/lib/replyTarget.ts @@ -1,4 +1,4 @@ -import type { Address, Mail, Session } from '../types'; +import type { Mail, Session } from '../types'; /** * 「这封回复该发给谁」。 @@ -177,14 +177,13 @@ export function mailReplyTarget(mail: Mail, me: string): string { export function replyAllCC( mail: Mail, me: string, - primaryName: string, - currentAlias?: string | null + primaryName: string ): string[] { const raw = [ formatAddress(mail.from_name, mail.from_workspace || ''), formatAddress(mail.to_name, mail.to_workspace || ''), - // cc_list 走 ccAddress:它把一次性的 `.new` 换成真实会话别名 - ...(mail.cc_list ?? []).map(c => ccAddress(c, currentAlias)) + // cc_list 使用 raw 字段(用户输入的原文,保留 .new 等原始意图) + ...(mail.cc_list ?? []).map(c => c.raw || formatAddress(c.name, c.path || '', c.session || null)) ]; const seen = new Set(); @@ -202,23 +201,4 @@ export function replyAllCC( 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); -} + diff --git a/web/test/components/replyTarget.test.tsx b/web/test/components/replyTarget.test.tsx index aada4d9..ddfd034 100644 --- a/web/test/components/replyTarget.test.tsx +++ b/web/test/components/replyTarget.test.tsx @@ -6,10 +6,9 @@ import { sessionReplyTarget, mailReplyTarget, replyAllCC, - participantAddress, - ccAddress + participantAddress } from '../../src/lib/replyTarget'; -import type { Address, Mail, Session } from '../../src/types'; +import type { Mail, Session } from '../../src/types'; /** * 「这封回复该发给谁」。 @@ -333,44 +332,14 @@ describe('participantAddress', () => { }); }); -describe('ccAddress', () => { - const cc = (over: Partial
= {}): 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 被换成当前会话别名', () => { +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: '', @@ -380,19 +349,30 @@ describe('replyAllCC 的 .new 处理', () => { name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new' }] }); - // 自己是 jianf、主收件人是 pi,剩下 dsh —— 且 .new 已换成真别名 - expect(replyAllCC(m, 'jianf', 'pi', '当前任务')).toEqual(['dsh@/opt.当前任务']); + // 自己是 jianf、主收件人是 pi,剩下 dsh —— `.new` 是原文,不动 + expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.new']); }); - it('不传别名时抄送退到默认会话而不是 .new', () => { + it('已有具体别名的抄送也原样保留', () => { 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' }] + cc_list: [{ name: 'dsh', path: '/opt', session: 'other-task', raw: 'dsh@/opt.other-task' }] }); - expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']); + 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)在抄送里是裸名字,不带会话位', () => { @@ -406,20 +386,6 @@ describe('replyAllCC 的 .new 处理', () => { // 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.当前任务'); + expect(replyAllCC(m, 'dsh', 'pi')).toEqual(['jianf']); }); });