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:
@ -88,6 +88,9 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
// 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的,
|
||||
// 而反过来(默认成 event_time)会让正在等的提醒永远发不出去。
|
||||
{"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"},
|
||||
// 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」,
|
||||
// 与新建会话的语义一致,不需要数据迁移。
|
||||
{"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"},
|
||||
// 派给该 Agent 的新任务默认多少个来回。
|
||||
// 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
|
||||
// 不能直接搬过来当单任务预算。
|
||||
@ -97,6 +100,8 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||||
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
|
||||
var sqliteAddIndexes = []string{
|
||||
// 接管平台会话时按 platform_id 反查(依赖上面补的列)
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''",
|
||||
// 人类决策后要按 mail_id 反查上游 permission id
|
||||
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
|
||||
}
|
||||
|
||||
@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
workspace VARCHAR(512) NOT NULL DEFAULT '',
|
||||
from_agent VARCHAR(64) NOT NULL,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
-- 接管的平台侧会话 id(见 init_sqlite.sql 的说明)
|
||||
platform_id VARCHAR(256) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
owner_user_id UUID REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@ -399,3 +401,13 @@ CREATE TABLE IF NOT EXISTS calendar_attachments (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||||
ON calendar_attachments(event_id);
|
||||
|
||||
-- 接管平台会话时按 platform_id 反查本侧会话。
|
||||
--
|
||||
-- 先 ALTER 再建索引:CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,
|
||||
-- 而这个脚本在已部署的库上也要能跑。PG 支持 ADD COLUMN IF NOT EXISTS,
|
||||
-- 所以这里不需要像 SQLite 那样绕到代码里去(见 migrate.go 的 sqliteAddIndexes)。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS platform_id VARCHAR(256) NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_platform
|
||||
ON sessions(platform_id) WHERE platform_id <> '';
|
||||
|
||||
@ -85,6 +85,17 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
|
||||
-- 绑定到平台侧的哪条会话(agent_platform_sessions.platform_id)。
|
||||
--
|
||||
-- 空 = 这条会话由邮件创建,平台侧的会话是桥按邮件开的。
|
||||
-- 非空 = 这条会话**接管**了一条平台上已经存在的会话(人在 TUI 里开的那种)。
|
||||
--
|
||||
-- 为什么需要它:TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。
|
||||
-- 人在 TUI 里聊了一半想转到邮件上继续,或者想把一封邮件投进正在谈的那条
|
||||
-- 会话 —— 补全早就把平台会话列为候选(agent_platform_sessions),
|
||||
-- 但投递侧没有这一跳,选中后只能得到 404。这一列就是那一跳的落点。
|
||||
platform_id TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||
rename_dismissed TEXT,
|
||||
|
||||
@ -427,3 +438,9 @@ CREATE TABLE IF NOT EXISTS calendar_attachments (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||||
ON calendar_attachments(event_id);
|
||||
|
||||
-- 注意:idx_sessions_platform 不在这里。
|
||||
-- 这个脚本在 addMissingColumns **之前**执行,而已部署的库里 sessions 表已经
|
||||
-- 存在 —— CREATE TABLE IF NOT EXISTS 不会给它补 platform_id 列,于是这里建
|
||||
-- 索引会以 "no such column" 失败,整个迁移中断(实测过一次)。
|
||||
-- 依赖补出来的列的索引一律放 migrate.go 的 sqliteAddIndexes。
|
||||
|
||||
@ -17,8 +17,8 @@ import (
|
||||
// ---------- Mail ----------
|
||||
|
||||
type sendMailRequest struct {
|
||||
To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在)
|
||||
CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session
|
||||
To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在)
|
||||
CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
@ -122,19 +122,87 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
|
||||
|
||||
default: // models.SessionNamed
|
||||
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
if err == nil {
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, nil
|
||||
|
||||
// 本侧没有这条别名 —— 再看平台会话镜像。
|
||||
//
|
||||
// TUI 与邮箱是同一个 Agent 的两个入口,人在平台界面上开的会话
|
||||
// 早就被补全列为候选(agent_platform_sessions),此前投递侧却没有
|
||||
// 这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
|
||||
//
|
||||
// 命中就**接管**它:本侧建一条会话并绑定 platform_id,插件收到投递
|
||||
// 事件时据此 resume 那条平台会话而不是新建。
|
||||
if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil {
|
||||
return adopted, nil, nil
|
||||
} else if !errors.Is(aErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, aErr
|
||||
}
|
||||
|
||||
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
}
|
||||
}
|
||||
|
||||
// adoptFromPlatform 把地址里的 session 位当作**平台会话的 slug** 来解析,
|
||||
// 命中则接管那条会话。
|
||||
//
|
||||
// 返回 repo.ErrSessionNotFound 表示镜像里也没有,调用方据此回 404。
|
||||
//
|
||||
// # 为什么接管而不是直接投
|
||||
//
|
||||
// 平台会话在本侧没有身份:没有 session_id、没有预算、没法归档,
|
||||
// 也无处记录「谁往里投过什么」。接管一次之后它就是一条正常的本侧会话,
|
||||
// 只是多带一个 platform_id 告诉插件「别新建,去 resume 那条」。
|
||||
//
|
||||
// # 为什么一条平台会话只能被接管一次
|
||||
//
|
||||
// 第二次投递必须复用第一次建的本侧会话。否则同一条 TUI 对话会在邮箱里
|
||||
// 裂成多条互不相干的线索 —— 人看到三个同名会话,而回信只落在其中一条上。
|
||||
func adoptFromPlatform(r *http.Request, addr models.Address, fromAgent, subject, byAgent string) (uuid.UUID, error) {
|
||||
platformID, realWorkspace, title, err := repo.FindPlatformSession(
|
||||
r.Context(), addr.Name, addr.Session, addr.Path)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
// 已被接管过 → 复用,不再建新的
|
||||
if existing, fErr := repo.FindSessionByPlatformID(r.Context(), addr.Name, platformID); fErr == nil {
|
||||
repo.TouchSession(r.Context(), existing)
|
||||
return existing, nil
|
||||
} else if !errors.Is(fErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, fErr
|
||||
}
|
||||
|
||||
// 接管等于新开一条本侧线索,计入速率限制 —— 否则它成了绕过
|
||||
// AllowNewSession 的后门(镜像里有几百条 slug 可选)。
|
||||
if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok {
|
||||
return uuid.Nil, errRateLimited(fmt.Sprintf(
|
||||
"新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
|
||||
repo.SessionRateLimit(), retry))
|
||||
}
|
||||
|
||||
// 主题优先用平台侧标题:它是那条对话在谈什么,比这封邮件的主题更能
|
||||
// 代表整条会话。人在补全里看到的也是这个标题。
|
||||
sub := strings.TrimSpace(title)
|
||||
if sub == "" {
|
||||
sub = subject
|
||||
}
|
||||
id, err := repo.AdoptPlatformSession(
|
||||
r.Context(), addr.Name, platformID, addr.Session, realWorkspace, sub)
|
||||
if err != nil {
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/send
|
||||
func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
@ -346,6 +414,9 @@ func notifyRecipients(ctx context.Context, to models.Address, cc []models.Addres
|
||||
// 仍可能为空的情形:命名写入失败(已吐日志)。此时退回省略 session 位,
|
||||
// 而不是把 "new" 写进去 —— 后者会让参与方反复建新会话。
|
||||
alias := repo.SessionAliasOf(ctx, sessionID)
|
||||
// 这条会话是否接管了一条平台侧已存在的会话(人在 TUI 里开的那种)。
|
||||
// 插件据此决定 resume 还是新建 —— 空串就是过去的行为。
|
||||
platformID := repo.PlatformIDOf(ctx, sessionID)
|
||||
|
||||
payload := func(role, workspace, forName string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
@ -367,6 +438,13 @@ func notifyRecipients(ctx context.Context, to models.Address, cc []models.Addres
|
||||
"reply_address": models.FormatAddress(from, "", alias),
|
||||
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
|
||||
"self_address": models.FormatAddress(forName, workspace, alias),
|
||||
// platform_session_id 非空时,这封邮件要投进**平台侧已经存在的
|
||||
// 那条会话**(TUI 与邮箱是同一个 Agent 的两个入口)。
|
||||
//
|
||||
// 插件必须 resume 而不是新建:新建会让人在 TUI 里看不到这封邮件
|
||||
// 带来的对话,而那正是接管这条会话的目的。
|
||||
// 空串 = 照旧按邮件新开一条平台会话。
|
||||
"platform_session_id": platformID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
228
gateway/internal/repo/adopt_test.go
Normal file
228
gateway/internal/repo/adopt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,13 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 平台会话镜像 ----------
|
||||
@ -100,8 +103,8 @@ type SessionCandidate struct {
|
||||
// SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。
|
||||
//
|
||||
// 两个来源合并:
|
||||
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
|
||||
// 2. 平台会话镜像里带 slug 的那些
|
||||
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
|
||||
// 2. 平台会话镜像里带 slug 的那些
|
||||
//
|
||||
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
|
||||
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
|
||||
@ -220,3 +223,105 @@ func SetSessionWorkspace(ctx context.Context, sessionID interface{ String() stri
|
||||
ws, sessionID.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 接管平台会话 ----------
|
||||
//
|
||||
// TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。
|
||||
// 人在平台界面上开的会话,应该也能被邮件投进去 —— 补全早就把它们列为候选,
|
||||
// 缺的只是投递侧这一跳。
|
||||
//
|
||||
// 「接管」= 在本侧建一条会话并把 platform_id 记上。之后:
|
||||
// - 这条会话在 sessions 表里有正式身份(可寻址、有预算、能归档)
|
||||
// - 插件收到投递事件时看到 platform_id,就去 resume 那条平台会话
|
||||
// 而不是新建一条
|
||||
//
|
||||
// 一条平台会话只能被接管一次:第二次投递复用第一次建的本侧会话,
|
||||
// 否则同一条 TUI 对话会在邮箱里裂成多条互不相干的线索。
|
||||
|
||||
// FindPlatformSession 按 (agent, slug, workspace) 找一条平台会话镜像。
|
||||
//
|
||||
// workspace 为空表示不限(地址省略 path 位时)。返回 platform_id 与它的
|
||||
// 真实 workspace —— 后者是权威的:**会话的 cwd 在它创建时就定了**,
|
||||
// 地址里的 path 位若与之不同,以会话为准。人是从候选列表里选的,
|
||||
// 他要的是「那条会话」而不是「那个目录」。
|
||||
func FindPlatformSession(ctx context.Context, agentName, slug, workspace string) (platformID, realWorkspace, title string, err error) {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
slug = strings.TrimSpace(slug)
|
||||
if agentName == "" || slug == "" {
|
||||
return "", "", "", ErrSessionNotFound
|
||||
}
|
||||
ws := strings.TrimSpace(workspace)
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
SELECT platform_id, workspace, title
|
||||
FROM agent_platform_sessions
|
||||
WHERE agent_name = $1 AND slug = $2
|
||||
AND ($3 = '' OR workspace = $3)
|
||||
ORDER BY COALESCE(updated_at, reported_at) DESC
|
||||
LIMIT 1
|
||||
`, agentName, slug, ws).Scan(&platformID, &realWorkspace, &title)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", "", ErrSessionNotFound
|
||||
}
|
||||
return platformID, realWorkspace, title, err
|
||||
}
|
||||
|
||||
// FindSessionByPlatformID 找出已经接管了某条平台会话的本侧会话。
|
||||
//
|
||||
// 返回 ErrSessionNotFound 表示还没被接管。归档的也算 —— 让归档过的会话
|
||||
// 重新被接管会造出第二条本侧会话,同一条 TUI 对话在邮箱里就裂成两截。
|
||||
// 需要恢复的话人应该去取消归档。
|
||||
func FindSessionByPlatformID(ctx context.Context, agentName, platformID string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT s.session_id
|
||||
FROM sessions s
|
||||
WHERE s.platform_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.to_name = $2 OR m.from_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
|
||||
)
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT 1
|
||||
`, platformID, agentName).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, ErrSessionNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// AdoptPlatformSession 接管一条平台会话:建本侧会话并绑定 platform_id。
|
||||
//
|
||||
// alias 用平台自己的 slug —— 「别名复用平台命名」是既定决策,而且人在补全里
|
||||
// 看到的就是那个 slug,投递后别名换成别的会让他找不到自己刚发的信。
|
||||
//
|
||||
// workspace 用平台会话的真实 cwd 而不是地址里的 path 位,理由见
|
||||
// FindPlatformSession 的注释。
|
||||
func AdoptPlatformSession(ctx context.Context, agentName, platformID, slug, workspace, subject string) (uuid.UUID, error) {
|
||||
// slug 可能与本侧某条无关会话撞名(别名全局唯一)。撞了就加后缀 ——
|
||||
// EnsureSessionAlias 已有这套逻辑,这里先建后命名即可。
|
||||
id, err := CreateSession(ctx, nil, agentName, subject, workspace)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET platform_id = $1 WHERE session_id = $2`,
|
||||
platformID, id); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
// 别名尽量用 slug;撞名时 EnsureSessionAlias 自动加后缀
|
||||
_, _ = EnsureSessionAlias(ctx, id, slug)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PlatformIDOf 读一条本侧会话绑定的平台会话 id(空 = 不是接管来的)。
|
||||
//
|
||||
// 投递时要把它放进 SSE 事件:插件据此决定 resume 还是新建。
|
||||
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 ""
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user