feat(adopt): 邮件可投进平台上已存在的会话(TUI 与邮箱同一入口)

人在平台界面(pi TUI / opencode / DSH GUI)里开的会话,此前无法被邮件投进去。
补全早就把它们列为候选(agent_platform_sessions 镜像,插件心跳上报),
但投递侧的 FindNamedSessionFor 只查 sessions 表 —— 选中后只能得到 404。
候选列表在承诺一件做不到的事。

TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。

## Gateway

sessions 表加 platform_id 列 + 部分索引。resolveTarget 的 SessionNamed 分支
本侧查不到时再查镜像,命中则「接管」:本侧建一条会话并绑定 platform_id,
之后每次投递都在 SSE 事件里带 platform_session_id。

- FindPlatformSession(agent, slug, workspace) 查镜像
- FindSessionByPlatformID 防重复接管(一条平台会话只能被接管一次,
  否则同一条对话在邮箱里裂成多条互不相干的线索)
- AdoptPlatformSession 建会话 + 绑定 + 别名复用平台 slug(撞名自动加后缀)
- PlatformIDOf 供 notifyRecipients 读

三处语义决定:
- workspace 以平台会话为准(它的 cwd 创建时就定了)。地址 path 位不同则不命中,
  否则邮件会投进另一个项目的会话
- 主题优先用平台侧标题(它代表整条对话在谈什么,也是补全里显示的)
- 接管计入 AllowNewSession 速率限制 —— 镜像里可能有几百条 slug,
  不计的话它是绕过限流的后门

## 插件

字段解析与失败话术抽成共用模块 lib/adopt.js(三方逐字节相同 + 进同源校验):
字段名各写一遍时少个下划线就静默退化成「每封邮件新开一条」,而那个错误不抛异常。

- opencode:session.get 确认存在 → 照常 promptAsync(服务端持有会话,单一写者)
- DSH:复用 startAgent 的 resume 分支,会话 id 换成平台自己那个;
  界面上正开着时直接 followup(两个 handle 会各自写日志,replay 过不去)
- pi:SessionManager.open(file) → 跑一轮 → dispose,不放进长期缓存

pi 必须短暂持有:SDK 无任何锁机制(flock/lockfile 命中 0),活着的
SessionManager 不 watch 文件 —— 外部追加的行看不见,算出的 parentId 指向
对方不知道的 entry,会话树分叉。写入是纯 append 所以文件不会坏。
配套三处:isStreaming 时不释放(否则杀掉排队中的下一封)、兜底计时器
(轮次超时 ×2,unref)、接管会话跳过命名同步。

最后一条是实测撞出来的:别名撞名时 Gateway 加后缀,而定稿别名又回写进 pi
会话文件 → 下次心跳上报的 slug 变成带后缀那个,人从补全里选的名字凭空消失。
opencode/DSH 无此环(它们的 slug 只读不写)。

接管后必须加入 mailDriven 集合,否则邮件投进去了却永远没有回音。

## 迁移顺序

idx_sessions_platform 不能写在 init_sqlite.sql 里:那个脚本在
addMissingColumns 之前执行,而已部署的库里 sessions 表已存在
(CREATE TABLE IF NOT EXISTS 不补列)→ 索引建在不存在的列上,
整个迁移中断、服务起不来(生产实测)。依赖补出来的列的索引一律放
migrate.go 的 sqliteAddIndexes。PG 侧用 ALTER TABLE ADD COLUMN IF NOT EXISTS。

## 生产验证

- pi × 2(agent-only-chain / mail-probe-alias)、opencode(glowing-moon)、
  dsh(查看工程与插件适配指南)四条链路接管成功
- dsh 那次回信准确说出了界面上聊过的内容 → 上下文确实装回来了
- 第二封复用同一条本侧会话,平台侧无新增改名条目
- 回归:opencode 普通 .new + 别名续谈 + used_rounds=0(免配额通道未受影响)

## 其他

pi-mail-bridge 补 systemd 单元(此前是 setsid 裸进程,重启机器不会拉起):
陈锁清理 ExecStartPre、MemoryMax=4G、TimeoutStopSec=10。
配置目录必须与 opencode 分开(共用会让后起的读到对方密钥或撞单实例锁)。

PLUGIN-CONTRACT.md 加 B-3.7 / B-3.8 + new_mail 字段表 + 检查清单验收项。

测试:repo +10 例(adopt_test.go);三插件各 +7 例(adopt.test.mjs)
This commit is contained in:
2026-09-04 11:14:44 +08:00
parent e4052f8e84
commit 255c799a40
20 changed files with 1176 additions and 22 deletions

View File

@ -0,0 +1,228 @@
package repo
import (
"context"
"testing"
"time"
"github.com/agentmail/gateway/internal/db"
)
func seedPlatformMirror(t *testing.T, agentName string, list []PlatformSession) {
t.Helper()
if err := ReplacePlatformSessions(context.Background(), agentName, list); err != nil {
t.Fatalf("上报镜像: %v", err)
}
}
// 补全把平台会话列为候选,投递侧必须能命中同一条。
// 此前 FindNamedSessionFor 只查 sessions 表 —— 候选列表在承诺一件做不到的事。
func TestFindPlatformSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
now := time.Now()
seedPlatformMirror(t, "pi", []PlatformSession{
{PlatformID: "pi-sess-1", Workspace: "/home/program/agentmail",
Slug: "设计文档-项目定位", Title: "邮件驱动·多智能体协作平台", UpdatedAt: &now},
{PlatformID: "pi-sess-2", Workspace: "/tmp/other",
Slug: "别处的会话", Title: "无关", UpdatedAt: &now},
})
t.Run("按 slug + workspace 命中", func(t *testing.T) {
pid, ws, title, err := FindPlatformSession(ctx, "pi", "设计文档-项目定位", "/home/program/agentmail")
if err != nil {
t.Fatalf("查找: %v", err)
}
if pid != "pi-sess-1" {
t.Errorf("platform_id = %q", pid)
}
if ws != "/home/program/agentmail" {
t.Errorf("workspace = %q", ws)
}
if title != "邮件驱动·多智能体协作平台" {
t.Errorf("title = %q", title)
}
})
// 地址省略 path 位时不限工作区
t.Run("workspace 为空时不限", func(t *testing.T) {
if pid, _, _, err := FindPlatformSession(ctx, "pi", "别处的会话", ""); err != nil || pid != "pi-sess-2" {
t.Errorf("得到 %q err=%v", pid, err)
}
})
// workspace 不匹配时不该命中 —— 那会让邮件投进另一个项目的会话
t.Run("workspace 不匹配不命中", func(t *testing.T) {
if _, _, _, err := FindPlatformSession(ctx, "pi", "别处的会话", "/home/program/agentmail"); err == nil {
t.Error("workspace 不同却命中了")
}
})
t.Run("别的 Agent 的镜像不串", func(t *testing.T) {
if _, _, _, err := FindPlatformSession(ctx, "dsh", "设计文档-项目定位", ""); err == nil {
t.Error("dsh 命中了 pi 的会话")
}
})
t.Run("空参数返回 not found 而不是 panic", func(t *testing.T) {
if _, _, _, err := FindPlatformSession(ctx, "", "x", ""); err != ErrSessionNotFound {
t.Errorf("空 agent 应给 ErrSessionNotFound,得到 %v", err)
}
if _, _, _, err := FindPlatformSession(ctx, "pi", "", ""); err != ErrSessionNotFound {
t.Errorf("空 slug 应给 ErrSessionNotFound,得到 %v", err)
}
})
}
// 接管后本侧有正式身份:可寻址(别名)、绑定 platform_id、workspace 用会话真实的。
func TestAdoptPlatformSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "设计文档-项目定位",
"/home/program/agentmail", "邮件驱动·多智能体协作平台")
if err != nil {
t.Fatalf("接管: %v", err)
}
// 别名复用平台 slug:人在补全里看到的就是那个名字,换掉会让他找不到
if alias := SessionAliasOf(ctx, id); alias != "设计文档-项目定位" {
t.Errorf("别名 = %q,期望复用平台 slug", alias)
}
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-1" {
t.Errorf("platform_id = %q", pid)
}
// workspace 取平台会话的真实 cwd
var ws string
if err := db.DB.QueryRowContext(ctx,
`SELECT workspace FROM sessions WHERE session_id = $1`, id).Scan(&ws); err != nil {
t.Fatalf("读 workspace: %v", err)
}
if ws != "/home/program/agentmail" {
t.Errorf("workspace = %q", ws)
}
}
// 普通会话的 platform_id 必须是空串(不是接管来的)。
func TestPlatformIDOfEmptyForNormalSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
id, err := CreateSession(ctx, nil, "pi", "普通邮件会话", "/tmp/x")
if err != nil {
t.Fatalf("建会话: %v", err)
}
if pid := PlatformIDOf(ctx, id); pid != "" {
t.Errorf("普通会话的 platform_id 应为空,得到 %q", pid)
}
}
// 一条平台会话只能被接管一次。
//
// 第二次投递必须复用第一次建的本侧会话 —— 否则同一条 TUI 对话会在邮箱里
// 裂成多条互不相干的线索:人看到三个同名会话,而回信只落在其中一条上。
func TestFindSessionByPlatformIDPreventsDoubleAdopt(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "某会话", "/tmp/ws", "标题")
if err != nil {
t.Fatalf("接管: %v", err)
}
// 接管后还没有邮件 —— 此时反查不到(EXISTS 子句要求有本 Agent 参与的邮件)
if _, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-1"); err == nil {
t.Log("注意:无邮件时也能反查到")
}
// 投一封进去,让参与关系成立
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/tmp/ws", "主题", "正文", nil); err != nil {
t.Fatalf("建邮件: %v", err)
}
got, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-1")
if err != nil {
t.Fatalf("反查: %v", err)
}
if got != id {
t.Errorf("反查到 %v,期望 %v", got, id)
}
// 别的 platform_id 查不到
if _, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-999"); err != ErrSessionNotFound {
t.Errorf("不存在的 platform_id 应给 ErrSessionNotFound,得到 %v", err)
}
// 别的 Agent 查不到(参与关系不成立)
if _, err := FindSessionByPlatformID(ctx, "dsh", "pi-sess-1"); err != ErrSessionNotFound {
t.Errorf("dsh 不该查到 pi 的接管会话,得到 %v", err)
}
}
// 整表替换镜像后,已接管的本侧会话不受影响。
//
// 镜像是平台当前状态的快照、会被整表替换;而 sessions.platform_id 是本侧的
// 持久绑定。平台侧那条会话被删掉之后,本侧线索与历史邮件仍然要在。
func TestAdoptedSessionSurvivesMirrorReplace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
now := time.Now()
seedPlatformMirror(t, "pi", []PlatformSession{
{PlatformID: "pi-sess-1", Workspace: "/tmp/ws", Slug: "会话甲", UpdatedAt: &now},
})
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "会话甲", "/tmp/ws", "标题")
if err != nil {
t.Fatalf("接管: %v", err)
}
// 平台侧删了那条会话(新快照里没有它)
seedPlatformMirror(t, "pi", []PlatformSession{
{PlatformID: "pi-sess-2", Workspace: "/tmp/ws", Slug: "会话乙", UpdatedAt: &now},
})
// 本侧绑定与别名都还在
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-1" {
t.Errorf("镜像替换后 platform_id 丢了:%q", pid)
}
if alias := SessionAliasOf(ctx, id); alias != "会话甲" {
t.Errorf("别名丢了:%q", alias)
}
// 但镜像里查不到了(补全不再列它,符合预期)
if _, _, _, err := FindPlatformSession(ctx, "pi", "会话甲", ""); err != ErrSessionNotFound {
t.Errorf("镜像里应已消失,得到 %v", err)
}
}
// 接管用的 slug 与本侧某条无关会话撞名时要自动加后缀(别名全局唯一)。
func TestAdoptHandlesAliasCollision(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
// 先占掉这个别名
taken := "撞名的别名"
if _, err := CreateSession(ctx, &taken, "pi", "已存在", "/tmp/a"); err != nil {
t.Fatalf("建占位会话: %v", err)
}
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-x", taken, "/tmp/b", "标题")
if err != nil {
t.Fatalf("接管: %v", err)
}
alias := SessionAliasOf(ctx, id)
if alias == "" {
t.Fatal("接管后没有别名 —— 这条会话将无法寻址")
}
if alias == taken {
t.Errorf("别名与已存在的重复了:%q", alias)
}
// 绑定仍然正确
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-x" {
t.Errorf("platform_id = %q", pid)
}
}