diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index 8e73514..5c00b1c 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge) fail=0 for peer in "${PEERS[@]}"; do - for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants; do + for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do if [[ ! -f "$peer/lib/$f.js" ]]; then echo "共用模块缺失:$peer/lib/$f.js" >&2 fail=1 @@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do done # 测试同样要同源:共用模块的行为约定写在测试里, # 只同步实现不同步测试,等于允许一侧偷偷放宽约定。 - for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants; do + for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do if [[ ! -f "$peer/test/$f.test.mjs" ]]; then echo "共用测试缺失:$peer/test/$f.test.mjs" >&2 fail=1 diff --git a/deploy/pi-mail-bridge.service b/deploy/pi-mail-bridge.service index 3ccc614..c1cd7e9 100644 --- a/deploy/pi-mail-bridge.service +++ b/deploy/pi-mail-bridge.service @@ -30,8 +30,27 @@ EnvironmentFile=/etc/agentmail/pi.env Restart=always RestartSec=10 +# 桥用 O_EXCL 建 /pi-bridge.lock 保证单实例 +# (两个实例各自订阅 SSE 会让同一封邮件各起一条会话,发件人收到两封回信, +# 而去重集合在各自内存里、彼此看不到)。 +# +# 被 SIGKILL 或主机掉电时锁文件留在盘上 —— 桥自己会检查锁里的 pid 是否还活着, +# 但那要求 /proc 可读且 pid 未被复用。systemd 重启前清一次更直接: +# ExecStartPre 里的 pid 必然已经死了(systemd 确认进程退出后才重启)。 +# +# 用 `-` 前缀:文件不存在时 rm 失败不该拦住启动。 +ExecStartPre=-/bin/rm -f /root/.agentmail-pi/pi-bridge.lock + # 桥是长驻守护进程,启动即注册 + 立刻打一次心跳 + 订阅 SSE(B-1), # 不像 opencode 那样惰加载,因此不需要 ExecStartPost 预热。 +# pi 会话在内存里持有整条对话,长跑之后常驻几百 MB。给一个上限让它被 +# OOM killer 挑中而不是拖垮整机;Restart=always 会把它拉回来。 +MemoryMax=4G + +# 优雅停机:桥收到 SIGTERM 后要注销 SSE、清锁文件。 +# 默认 90 秒太长(停机时人在等),10 秒足够那两件事。 +TimeoutStopSec=10 + [Install] WantedBy=multi-user.target diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md index c544179..60aa870 100644 --- a/docs/PLUGIN-CONTRACT.md +++ b/docs/PLUGIN-CONTRACT.md @@ -209,8 +209,9 @@ new_mail 到达 ├─ 1. 去重:mail_id 已在 deliveredMails 里 → 丢弃 ├─ 2. 解析 cwd = resolveWorkspaceCwd(to_workspace, 兜底) ├─ 3. 查映射:session_id → 平台会话 id - │ 命中且会话还活着 → 续谈(inject_turn) - │ 否则 → 新建会话(不传占位标题) + │ 命中且会话还活着 → 续谈(inject_turn) + │ platform_session_id 非空 → **接管**那条平台会话(B-3.7) + │ 否则 → 新建会话(不传占位标题) ├─ 4. 按 modelAttemptOrder 逐个尝试,等异步结论(见 D-3) ├─ 5. 注入提示词:告诉模型「调 read_inbox 读正文」「回信不用你自己发」 └─ 6. 登记 mail_id 到 deliveredMails @@ -224,6 +225,8 @@ new_mail 到达 | B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」 | MUST | | B-3.5 | 提示词里带 `mail_id`,让模型能自己查这封 | SHOULD | | B-3.6 | 投递失败要让人看到(日志 + 见 `B-6`) | MUST | +| B-3.7 | `platform_session_id` 非空时**必须**投进那条平台会话,不得新建 | MUST | +| B-3.8 | 那条平台会话已不存在时**必须报错**,不得退回新建 | MUST | > **B-3.1 是踩过最贵的坑之一**(详见第九节):平台按 cwd 给会话分组,用自己拼的 > 临时目录会让所有邮件会话既不属于任何项目、彼此也不同组 —— 界面上全落进 @@ -233,6 +236,55 @@ new_mail 到达 > 而插件在轮次结束时也会自动转发一次 —— 同一件事两封邮件。生产里真实发生过。 > 说了之后仍要保留 `B-5.3` 的去重兜底:提示词是建议,去重是保证。 +#### B-3.7 接管平台会话(MUST) + +**平台界面(TUI/GUI)与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。** +人在界面上开的会话早就被 `C-8` 的会话快照上报成候选,写信时能在补全里选中; +若投递侧不认这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。 + +Gateway 在本侧建一条会话并记下那条平台会话的 id(「接管」),随后**每次**投递 +都在事件里带 `platform_session_id`。插件看到它就去那条平台会话里接着谈。 + +| 平台 | 接管方式 | +|---|---| +| opencode | `session.get` 确认存在 → 照常 `promptAsync({ path: { id } })` | +| DSH | `startAgent` 的 resume 分支(会话 id 换成平台自己那个) | +| pi | `SessionManager.open(file)` → 跑一轮 → **dispose** | +| HomeAgent | N/A(无会话概念,所有邮件注入同一事件循环) | + +字段解析与失败话术走共用模块 `lib/adopt.js`(`adoptedSessionID` / +`adoptMissingMessage`)—— 字段名各写一遍时少个下划线就静默退化成「每封邮件 +新开一条」,而那个错误不抛任何异常。 + +**接管之后必须把该会话加入 `mailDriven` 集合**(`B-5.5` 的判据)。不加的话 +邮件投进去了却永远没有回音:发件人只看到信发出去后再无音讯。 + +**B-3.8:平台侧那条会话已被删时报错,不要退回新建。** 镜像是快照、可以过期。 +退回新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的 —— +与 `N-8`「404 后自动改用 `.new` 是禁止的」是同一条原则。错误话术必须给出 +可执行的下一步(用 `.new`),只说「不存在」的话模型会原地重试同一个地址。 + +##### 文件型会话(pi)必须短暂持有 + +pi 的会话是磁盘上的 `.jsonl`,**没有任何锁机制**(SDK 里 `flock`/`lockfile` +命中为 0),它假定「一个文件一个持有者」。写入是纯 append,所以两个持有者不会 +把文件截断;坏的是**各自的内存索引**:活着的 `SessionManager` 不 watch 文件, +对方追加的行自己看不见,于是算出的 `parentId` 指向一个对方不知道的 entry, +会话树分叉。 + +取舍是 **open → 跑一轮 → dispose,不放进长期缓存**。下一封邮件重新 open, +那一次读到的就是界面那边写的全部内容。窗口是一轮对话的时长。 + +三处配套细节,少一个就有实测过的坏处: + +- **`isStreaming` 时不释放**。同一条会话可能已经排了下一封邮件,此时 dispose + 会把排着的那轮一起杀掉。排着的那轮结束时会再触发轮次结束事件,由它释放。 +- **兜底计时器**(轮次超时的两倍)。轮次结束事件不来就永远握着文件; + 计时器要 `unref()`,否则它会阻止进程退出。 +- **接管会话跳过命名同步(`C-11`)**。它的别名是人从补全里选的那个 slug; + 同步会双向改坏它 —— 别名撞名时 Gateway 加后缀,而定稿别名又回写进平台侧, + 于是下一次快照上报的 slug 变成带后缀那个,人选的名字凭空消失(实测撞出来过)。 + ### B-4 收到 `permission_decision`(MUST) | # | 要求 | 强度 | @@ -794,7 +846,8 @@ Agent 侧只会收到两个事件: "to_workspace": "/home/program/agentmail", "session_alias": "refactor-imports", "reply_address": "admin@.refactor-imports", - "self_address": "pi@/home/program/agentmail.refactor-imports" + "self_address": "pi@/home/program/agentmail.refactor-imports", + "platform_session_id": "" } ``` @@ -806,6 +859,7 @@ Agent 侧只会收到两个事件: | `session_alias` | 这条会话今后的寻址名 | | `reply_address` | 「把回信发回这条会话」的现成地址 | | `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 | +| `platform_session_id` | 非空 = 投进**这条已存在的平台会话**(见 `B-3.7`);空 = 照旧 | > **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`), > 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址 @@ -1059,6 +1113,21 @@ GET /api/v1/attachments/{id} [ ] 发到一个不存在的别名 → 404,且平台侧没有新建任何会话(N-8) +[ ] 接管平台会话(B-3.7):在平台界面里手动开一条会话并聊几句 + → 等一次心跳,sqlite3 "SELECT slug FROM agent_platform_sessions + WHERE agent_name='' AND mail_driven=0" → 出现它的 slug + → 发一封到 @<那条会话的 workspace>.<那个 slug> + → 日志显示「接管/resume」而**不是**「新会话」 + → 让模型回答「这条会话之前在谈什么」→ 答案含界面上聊过的内容(上下文装回来了) + → sqlite3 "SELECT platform_id FROM sessions WHERE session_alias=''" + → 等于那条平台会话的 id + → 收到自动回信(接管后也进 mailDriven,B-5.5) + → 再发第二封:复用**同一条**本侧会话,不再新建(避免线索裂成多条) + → 平台侧会话文件/记录里**没有**新增改名条目(接管会话跳过 C-11) + +[ ] 平台侧删掉那条会话后再投同一个别名 + → 报错且话术含 `.new`,平台侧**没有**新建任何会话(B-3.8) + [ ] 模型主动调 send_mail 回信的那一轮 → 只有一封邮件,没有额外的自动转发(B-5.3) diff --git a/gateway/internal/db/migrate.go b/gateway/internal/db/migrate.go index da6955e..bc47647 100644 --- a/gateway/internal/db/migrate.go +++ b/gateway/internal/db/migrate.go @@ -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)", } diff --git a/gateway/internal/db/migrations/init.sql b/gateway/internal/db/migrations/init.sql index e7b5283..032a2ed 100644 --- a/gateway/internal/db/migrations/init.sql +++ b/gateway/internal/db/migrations/init.sql @@ -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 <> ''; diff --git a/gateway/internal/db/migrations/init_sqlite.sql b/gateway/internal/db/migrations/init_sqlite.sql index 7a32fed..9dce5e4 100644 --- a/gateway/internal/db/migrations/init_sqlite.sql +++ b/gateway/internal/db/migrations/init_sqlite.sql @@ -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。 diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go index 14062d5..6ca93cd 100644 --- a/gateway/internal/handler/mail.go +++ b/gateway/internal/handler/mail.go @@ -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, } } diff --git a/gateway/internal/repo/adopt_test.go b/gateway/internal/repo/adopt_test.go new file mode 100644 index 0000000..10ed6bc --- /dev/null +++ b/gateway/internal/repo/adopt_test.go @@ -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) + } +} diff --git a/gateway/internal/repo/platform_sessions.go b/gateway/internal/repo/platform_sessions.go index e2834c2..fbca293 100644 --- a/gateway/internal/repo/platform_sessions.go +++ b/gateway/internal/repo/platform_sessions.go @@ -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 +} diff --git a/plugins/dsh-mail-bridge/lib/adopt.d.ts b/plugins/dsh-mail-bridge/lib/adopt.d.ts new file mode 100644 index 0000000..594a360 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/adopt.d.ts @@ -0,0 +1,2 @@ +export function adoptedSessionID(data: any): string; +export function adoptMissingMessage(platformID: string, detail?: string): string; diff --git a/plugins/dsh-mail-bridge/lib/adopt.js b/plugins/dsh-mail-bridge/lib/adopt.js new file mode 100644 index 0000000..b1b6827 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/adopt.js @@ -0,0 +1,54 @@ +/** + * 接管平台会话:从投递事件里取出「要投进哪条平台会话」并给出统一的失败话术。 + * + * # 这件事是什么 + * + * TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。人在平台界面上 + * 开的会话(opencode 的 session、DSH 的 agent、pi 的 .jsonl)早就被 + * session-snapshot 上报成候选,写信时能在补全里选中;此前投递侧没有这一跳, + * 选中后只能得到 404 —— 候选列表在承诺一件做不到的事。 + * + * 服务端在本侧建一条会话并记下 `platform_id`(「接管」),随后每次投递都在 + * 事件里带上 `platform_session_id`。插件看到它就去那条平台会话里接着谈, + * **不新建** —— 新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的。 + * + * # 为什么这两个函数要三平台共用 + * + * 字段名与失败话术是**对外契约**:字段名各写一遍,少个下划线就静默退化成 + * 「每封邮件新开一条会话」,而那个错误不报任何异常;话术各写一遍,同一个 + * 处境在三个平台上说三种话,模型学不到「该改用 .new」这个动作。 + * + * 三平台的**接管机制**不共用(服务端持有会话 / 磁盘 replay / 文件 open 各不 + * 相同),只有这两件事共用。 + */ + +/** + * 从投递事件里取出被接管的平台会话 id。 + * + * @param {any} data new_mail / permission_decided 事件的 payload + * @returns {string} 平台会话 id;空串 = 不是接管,照旧按邮件新开一条 + */ +export function adoptedSessionID(data) { + const raw = data?.platform_session_id; + return typeof raw === 'string' ? raw.trim() : ''; +} + +/** + * 平台侧那条会话已经不在了时的错误话术。 + * + * 镜像是快照,可以过期:人可能已经在界面上删了那条会话。 + * + * **不能退回「新建一条」**:那会让人在界面上看不到这封邮件带来的对话, + * 而发件人以为投进去了。静默改语义比报错糟得多(与 N-8「404 后自动改用 + * .new 是禁止的」同一条原则)。 + * + * 话术必须给出可执行的下一步:只说「不存在」的话,模型会原地重试同一个地址。 + * + * @param {string} platformID + * @param {string} [detail] 平台特有的补充说明,如「可能已在界面上删除」 + * @returns {string} + */ +export function adoptMissingMessage(platformID, detail = '可能已被删除') { + return `平台会话 ${platformID} 已不存在(${detail})。` + + `请用 name@path.new 新开一条会话,或换一个仍然存在的会话别名。`; +} diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 40f55fc..235e16e 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -21,6 +21,7 @@ import { noteExplicitSend, shouldSkipAutoRelay, } from '../lib/relay-dedup.js'; +import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js'; import { userMessage, replySubject, @@ -570,10 +571,89 @@ export function apply(ctx: any, config: PluginConfig): void { // ─── 投递邮件到 DSH 会话 ─── + /** + * 投进「被接管的平台会话」时给模型的提示词。 + * + * 与新建会话那份的差别:不自我介绍身份、不解释邮件系统 —— 这条会话里人已经 + * 在谈别的事了,一段「你是 dsh,你收到一封邮件」的开场白会让模型以为上下文 + * 被重置。只说「有封邮件进来了」。 + */ + function adoptPrompt(data: any, kind: string): string { + if (kind === 'permission') { + return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`; + } + return [ + `本会话收到一封新邮件(AgentMail)。`, + ``, + `发件人:${data.from_name || 'unknown'}`, + `主题:${data.subject || '(无主题)'}`, + `邮件 ID:${data.mail_id || 'unknown'}`, + ``, + `请先调用 read_inbox 读取完整正文,然后处理其中的请求。`, + `回信不用你自己发:把这一轮做完、把结论说出来就行,`, + `插件会在轮次结束时把你最后那段话发回给 ${data.from_name || '发件人'}(不消耗配额)。`, + ].join('\n'); + } + + /** + * 记下「这条邮件会话 ↔ 这条平台会话」的绑定与回信上下文。 + * + * mailDrivenSessions 必须加:接管之后这条会话**开始**参与邮件往来,轮次结束 + * 要把总结转回发件人。不加的话邮件投进去了却永远没有回音。 + */ + function bindAdopted(mailSessionID: string, dshSessionId: string, cwd: string, data: any): void { + if (!mailSessionID) return; + sessionMap.set(mailSessionID, { dshSessionId, directory: cwd }); + reverseMap.set(dshSessionId, mailSessionID); + mailDrivenSessions.add(dshSessionId); + mailContexts.set(mailSessionID, { + replyTo: data.from_name || '', + subject: data.subject || '', + mailID: data.mail_id || '', + }); + } + async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> { const mailSessionID = data.session_id; const existing = mailSessionID ? sessionMap.get(mailSessionID) : undefined; + // 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 DSH 界面上 + // 开的那种)—— 投进它而不是新建。 + // + // TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。补全早就把平台 + // 会话列为候选(session-snapshot 上报的那批),这一跳补上投递侧。 + // + // DSH 上不需要新代码路径:`startAgent` 本来就「磁盘上有就 resume」, + // 接管只是把会话 id 从 `mail-` 换成平台自己那个。第一次投递走 + // resume 分支装回上下文,之后与普通续谈完全一样(sessionMap 命中 → followup)。 + const adoptedID = adoptedSessionID(data); + if (!existing && adoptedID) { + const onDisk = await persistedCwd(adoptedID); + if (onDisk === undefined) { + // 镜像是快照,可以过期:平台侧那条会话可能已经被人删了。 + // 不能落到「新开会话」那条路 —— 那会用 `mail-` 另开一条, + // 人在 DSH 界面上看不到这封邮件带来的对话,而那正是接管的目的。 + throw new Error(adoptMissingMessage(adoptedID, '磁盘上已无这条会话的日志')); + } + return locked(adoptedID, async () => { + const promptText = adoptPrompt(data, kind); + const live = ctx.agents.get(adoptedID); + if (live) { + // 界面上正开着这条会话 —— 直接 followup,不要再 resume 一次: + // 同一条会话两个 handle 会各自往日志里写,replay 校验过不去。 + live.followup(userMessage(promptText)); + await waitForTurnEnd(live); + bindAdopted(mailSessionID, adoptedID, onDisk, data); + return { sessionID: adoptedID, reused: true }; + } + const { handle } = await startAgent(adoptedID, onDisk, attemptOrder()[0]); + bindAdopted(mailSessionID, adoptedID, onDisk, data); + handle.agent.followup(userMessage(promptText)); + await waitForTurnEnd(handle.agent); + return { sessionID: adoptedID, reused: true }; + }); + } + if (existing) { const live = ctx.agents.get(existing.dshSessionId); if (live) { diff --git a/plugins/dsh-mail-bridge/test/adopt.test.mjs b/plugins/dsh-mail-bridge/test/adopt.test.mjs new file mode 100644 index 0000000..87c598d --- /dev/null +++ b/plugins/dsh-mail-bridge/test/adopt.test.mjs @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js'; + +// 字段名是对外契约:三平台各写一遍时少个下划线就静默退化成「每封邮件新开一条」, +// 而那个错误不报任何异常。这组测试锁住字段名本身。 +test('adoptedSessionID: 取 platform_session_id', () => { + assert.equal(adoptedSessionID({ platform_session_id: 'ses_abc123' }), 'ses_abc123'); +}); + +test('adoptedSessionID: 去首尾空白', () => { + assert.equal(adoptedSessionID({ platform_session_id: ' ses_abc ' }), 'ses_abc'); +}); + +// 空串是「不是接管」的正常信号(服务端对非接管会话回空串),不是异常 +test('adoptedSessionID: 空串表示不是接管', () => { + assert.equal(adoptedSessionID({ platform_session_id: '' }), ''); + assert.equal(adoptedSessionID({ platform_session_id: ' ' }), ''); +}); + +test('adoptedSessionID: 字段缺失返回空串', () => { + assert.equal(adoptedSessionID({}), ''); + assert.equal(adoptedSessionID({ session_id: 'x' }), ''); +}); + +// 老版本服务端不发这个字段;插件不能因此崩掉整条投递 +test('adoptedSessionID: 非字符串与空输入都退回空串', () => { + assert.equal(adoptedSessionID({ platform_session_id: 123 }), ''); + assert.equal(adoptedSessionID({ platform_session_id: null }), ''); + assert.equal(adoptedSessionID({ platform_session_id: ['a'] }), ''); + assert.equal(adoptedSessionID(undefined), ''); + assert.equal(adoptedSessionID(null), ''); +}); + +// 话术必须给出可执行的下一步:只说「不存在」时模型会原地重试同一个地址 +test('adoptMissingMessage: 带上 id 与 .new 的指引', () => { + const msg = adoptMissingMessage('ses_gone'); + assert.match(msg, /ses_gone/); + assert.match(msg, /\.new/); + assert.match(msg, /可能已被删除/); +}); + +test('adoptMissingMessage: detail 可按平台定制', () => { + const msg = adoptMissingMessage('mail-1', '磁盘上已无这条会话'); + assert.match(msg, /磁盘上已无这条会话/); + assert.match(msg, /\.new/); +}); diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 75f6fff..d5f0dc6 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -33,6 +33,7 @@ import { noteExplicitSend, shouldSkipAutoRelay, } from "./lib/relay-dedup.js"; +import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js"; import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js"; // opencode 原生支持三态权限,免批由它自己记(response:"always"), // 所以这里只借用决策文本的判定,不需要 createGrantStore。 @@ -599,8 +600,13 @@ const pendingPermissions = new Map(); // permission.id -> { sessionID, callID } // 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。 const relayedSummaries = new Map(); // opencode session id -> assistant message id -// 收到邮件后建立的会话,才需要在 idle 时把总结转回去。 -// 用户在 TUI 里自己开的会话不该被搬进邮件系统。 +// 哪些会话参与邮件往来,idle 时要把总结转回去。 +// +// 两个来源:① 收到邮件后新建的会话 ② **被接管的平台会话**(人在 TUI 里开的, +// 但已经有邮件投进来了)。后者从接管那一刻起加入 —— 不加的话邮件投进去了 +// 却永远没有回音,发件人只看到信发出去后再无音讯。 +// +// 没有邮件投进来的 TUI 会话不在这里,它们不该被搬进邮件系统。 const mailDrivenSessions = new Set(); // opencode session id // 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。 @@ -612,6 +618,41 @@ async function resolveSessionForMail(client, directory, data, kind) { const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined; if (bound) return { sessionID: bound, reused: true }; + // 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 TUI 里开的 + // 那种)—— 投进它而不是新建。 + // + // TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界:人在界面上聊了 + // 一半想转到邮件继续,或者想把一封邮件投进正在谈的那条会话。补全早就把平台 + // 会话列为候选,这一跳补上投递侧。 + // + // 新建会让人在 TUI 里看不到这封邮件带来的对话,而那正是接管的目的。 + // + // opencode 上这件事最省力:会话由服务端持有(单一写者),promptAsync 本来 + // 就是「给这个 session id 发一轮」,不区分谁建的。**不需要**校验它是否活着。 + const adoptedID = adoptedSessionID(data); + if (adoptedID) { + // 平台侧那条会话可能已经被人删了(镜像是快照,可以过期)。 + // 校验一次:直接 prompt 一个不存在的 id 会得到一个语焉不详的 HTTP 错误, + // 而这里能给出「它没了,去 .new」这种可操作的话。 + let ok = false; + try { + const got = await client.session.get({ path: { id: adoptedID } }); + ok = Boolean((got?.data ?? got)?.id); + } catch { + ok = false; + } + if (!ok) throw new Error(adoptMissingMessage(adoptedID, "可能已在界面上删除")); + if (mailSessionID) { + sessionMap.set(mailSessionID, adoptedID); + reverseMap.set(adoptedID, mailSessionID); + // 标记为邮件驱动:接管之后这条会话**开始**参与邮件往来, + // 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。 + mailDrivenSessions.add(adoptedID); + } + console.error(`[mail-bridge] 接管平台会话 ${adoptedID}(邮件会话 ${mailSessionID})`); + return { sessionID: adoptedID, reused: true, adopted: true }; + } + // 工作目录取**寻址里的 path 位**,而不是插件启动时那个固定的 directory。 // // 三维地址 name@path.session 的 path 就是「希望它在哪儿干活」。用固定的 diff --git a/plugins/opencode-mail-bridge/lib/adopt.js b/plugins/opencode-mail-bridge/lib/adopt.js new file mode 100644 index 0000000..b1b6827 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/adopt.js @@ -0,0 +1,54 @@ +/** + * 接管平台会话:从投递事件里取出「要投进哪条平台会话」并给出统一的失败话术。 + * + * # 这件事是什么 + * + * TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。人在平台界面上 + * 开的会话(opencode 的 session、DSH 的 agent、pi 的 .jsonl)早就被 + * session-snapshot 上报成候选,写信时能在补全里选中;此前投递侧没有这一跳, + * 选中后只能得到 404 —— 候选列表在承诺一件做不到的事。 + * + * 服务端在本侧建一条会话并记下 `platform_id`(「接管」),随后每次投递都在 + * 事件里带上 `platform_session_id`。插件看到它就去那条平台会话里接着谈, + * **不新建** —— 新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的。 + * + * # 为什么这两个函数要三平台共用 + * + * 字段名与失败话术是**对外契约**:字段名各写一遍,少个下划线就静默退化成 + * 「每封邮件新开一条会话」,而那个错误不报任何异常;话术各写一遍,同一个 + * 处境在三个平台上说三种话,模型学不到「该改用 .new」这个动作。 + * + * 三平台的**接管机制**不共用(服务端持有会话 / 磁盘 replay / 文件 open 各不 + * 相同),只有这两件事共用。 + */ + +/** + * 从投递事件里取出被接管的平台会话 id。 + * + * @param {any} data new_mail / permission_decided 事件的 payload + * @returns {string} 平台会话 id;空串 = 不是接管,照旧按邮件新开一条 + */ +export function adoptedSessionID(data) { + const raw = data?.platform_session_id; + return typeof raw === 'string' ? raw.trim() : ''; +} + +/** + * 平台侧那条会话已经不在了时的错误话术。 + * + * 镜像是快照,可以过期:人可能已经在界面上删了那条会话。 + * + * **不能退回「新建一条」**:那会让人在界面上看不到这封邮件带来的对话, + * 而发件人以为投进去了。静默改语义比报错糟得多(与 N-8「404 后自动改用 + * .new 是禁止的」同一条原则)。 + * + * 话术必须给出可执行的下一步:只说「不存在」的话,模型会原地重试同一个地址。 + * + * @param {string} platformID + * @param {string} [detail] 平台特有的补充说明,如「可能已在界面上删除」 + * @returns {string} + */ +export function adoptMissingMessage(platformID, detail = '可能已被删除') { + return `平台会话 ${platformID} 已不存在(${detail})。` + + `请用 name@path.new 新开一条会话,或换一个仍然存在的会话别名。`; +} diff --git a/plugins/opencode-mail-bridge/test/adopt.test.mjs b/plugins/opencode-mail-bridge/test/adopt.test.mjs new file mode 100644 index 0000000..87c598d --- /dev/null +++ b/plugins/opencode-mail-bridge/test/adopt.test.mjs @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js'; + +// 字段名是对外契约:三平台各写一遍时少个下划线就静默退化成「每封邮件新开一条」, +// 而那个错误不报任何异常。这组测试锁住字段名本身。 +test('adoptedSessionID: 取 platform_session_id', () => { + assert.equal(adoptedSessionID({ platform_session_id: 'ses_abc123' }), 'ses_abc123'); +}); + +test('adoptedSessionID: 去首尾空白', () => { + assert.equal(adoptedSessionID({ platform_session_id: ' ses_abc ' }), 'ses_abc'); +}); + +// 空串是「不是接管」的正常信号(服务端对非接管会话回空串),不是异常 +test('adoptedSessionID: 空串表示不是接管', () => { + assert.equal(adoptedSessionID({ platform_session_id: '' }), ''); + assert.equal(adoptedSessionID({ platform_session_id: ' ' }), ''); +}); + +test('adoptedSessionID: 字段缺失返回空串', () => { + assert.equal(adoptedSessionID({}), ''); + assert.equal(adoptedSessionID({ session_id: 'x' }), ''); +}); + +// 老版本服务端不发这个字段;插件不能因此崩掉整条投递 +test('adoptedSessionID: 非字符串与空输入都退回空串', () => { + assert.equal(adoptedSessionID({ platform_session_id: 123 }), ''); + assert.equal(adoptedSessionID({ platform_session_id: null }), ''); + assert.equal(adoptedSessionID({ platform_session_id: ['a'] }), ''); + assert.equal(adoptedSessionID(undefined), ''); + assert.equal(adoptedSessionID(null), ''); +}); + +// 话术必须给出可执行的下一步:只说「不存在」时模型会原地重试同一个地址 +test('adoptMissingMessage: 带上 id 与 .new 的指引', () => { + const msg = adoptMissingMessage('ses_gone'); + assert.match(msg, /ses_gone/); + assert.match(msg, /\.new/); + assert.match(msg, /可能已被删除/); +}); + +test('adoptMissingMessage: detail 可按平台定制', () => { + const msg = adoptMissingMessage('mail-1', '磁盘上已无这条会话'); + assert.match(msg, /磁盘上已无这条会话/); + assert.match(msg, /\.new/); +}); diff --git a/plugins/pi-mail-bridge/lib/adopt.js b/plugins/pi-mail-bridge/lib/adopt.js new file mode 100644 index 0000000..b1b6827 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/adopt.js @@ -0,0 +1,54 @@ +/** + * 接管平台会话:从投递事件里取出「要投进哪条平台会话」并给出统一的失败话术。 + * + * # 这件事是什么 + * + * TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。人在平台界面上 + * 开的会话(opencode 的 session、DSH 的 agent、pi 的 .jsonl)早就被 + * session-snapshot 上报成候选,写信时能在补全里选中;此前投递侧没有这一跳, + * 选中后只能得到 404 —— 候选列表在承诺一件做不到的事。 + * + * 服务端在本侧建一条会话并记下 `platform_id`(「接管」),随后每次投递都在 + * 事件里带上 `platform_session_id`。插件看到它就去那条平台会话里接着谈, + * **不新建** —— 新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的。 + * + * # 为什么这两个函数要三平台共用 + * + * 字段名与失败话术是**对外契约**:字段名各写一遍,少个下划线就静默退化成 + * 「每封邮件新开一条会话」,而那个错误不报任何异常;话术各写一遍,同一个 + * 处境在三个平台上说三种话,模型学不到「该改用 .new」这个动作。 + * + * 三平台的**接管机制**不共用(服务端持有会话 / 磁盘 replay / 文件 open 各不 + * 相同),只有这两件事共用。 + */ + +/** + * 从投递事件里取出被接管的平台会话 id。 + * + * @param {any} data new_mail / permission_decided 事件的 payload + * @returns {string} 平台会话 id;空串 = 不是接管,照旧按邮件新开一条 + */ +export function adoptedSessionID(data) { + const raw = data?.platform_session_id; + return typeof raw === 'string' ? raw.trim() : ''; +} + +/** + * 平台侧那条会话已经不在了时的错误话术。 + * + * 镜像是快照,可以过期:人可能已经在界面上删了那条会话。 + * + * **不能退回「新建一条」**:那会让人在界面上看不到这封邮件带来的对话, + * 而发件人以为投进去了。静默改语义比报错糟得多(与 N-8「404 后自动改用 + * .new 是禁止的」同一条原则)。 + * + * 话术必须给出可执行的下一步:只说「不存在」的话,模型会原地重试同一个地址。 + * + * @param {string} platformID + * @param {string} [detail] 平台特有的补充说明,如「可能已在界面上删除」 + * @returns {string} + */ +export function adoptMissingMessage(platformID, detail = '可能已被删除') { + return `平台会话 ${platformID} 已不存在(${detail})。` + + `请用 name@path.new 新开一条会话,或换一个仍然存在的会话别名。`; +} diff --git a/plugins/pi-mail-bridge/src/index.mjs b/plugins/pi-mail-bridge/src/index.mjs index 5fe3ec5..44f0116 100644 --- a/plugins/pi-mail-bridge/src/index.mjs +++ b/plugins/pi-mail-bridge/src/index.mjs @@ -34,6 +34,7 @@ import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib import { snapshotPiSessions } from '../lib/session-snapshot.js'; import { selectCatchup } from '../lib/catchup.js'; import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js'; +import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js'; import { createGrantStore, isApproval } from '../lib/permission-grants.js'; // ─── 配置 ─── @@ -254,6 +255,159 @@ function piMailFallback(sessionKey) { return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default')); } +/** + * 接管过的 pi 会话(pi session id)。用完即释放,见 releaseAdopted。 + * + * 与 `sessions` 的区别:那里存的是桥自己起的、长期持有的会话;这里是 + * 「借用一下磁盘上人家的会话」,一轮结束就还回去。 + */ +const adopted = new Set(); + +/** 接管会话的兜底释放计时器(pi session id -> Timeout)。 */ +const adoptTimers = new Map(); + +/** + * 接管会话最长持有多久。 + * + * 取轮次超时的两倍:`runTurn` 60 秒就按成功返回(长任务很正常,判成失败会 + * 换模型重跑一遍),但会话仍在跑。正常结束走 agent_end 提前释放,这个数字 + * 只兜「事件永远不来」的底。 + */ +const ADOPT_MAX_HOLD_MS = TURN_TIMEOUT_MS * 2; + +/** + * 接管一条磁盘上已经存在的 pi 会话,把这封邮件投进去。 + * + * # 为什么必须**短暂持有** + * + * pi 没有任何锁机制,它假定「一个文件一个持有者」。活着的 SessionManager + * 不 watch 文件:外部(TUI)追加的行它看不见,之后它自己的写入算出的 parentId + * 指向一个对方不知道的 entry —— 文件不会坏(写入是纯 append),但会话树分叉。 + * + * 所以这里 open → 跑一轮 → 丢弃,**不放进 sessions 长期缓存**。下一封邮件 + * 再来时重新 open,那一次读到的就是 TUI 期间写的全部内容。 + * + * 窗口是一轮对话的时长。人正好在这期间也在 TUI 里发消息仍会分叉,但那需要 + * 两边同时动手,且后果是历史看起来少一段,不是数据损坏。 + * + * # 为什么不校验「TUI 是否正开着这条会话」 + * + * pi 不提供这个信息(没有 lockfile、没有 pid 记录)。能做的只有猜 mtime, + * 而任何阈值都是猜。与其用一个猜出来的数字拒掉合法投递,不如让窗口尽量短。 + */ +async function adoptSession(platformID, data, mailTools) { + const mailSessionID = data.session_id; + const { SessionManager } = await import('@earendil-works/pi-coding-agent'); + + // listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关。 + const all = await SessionManager.listAll(); + const info = all.find((e) => e?.id === platformID); + if (!info?.path) { + // 镜像是快照,可以过期:那条会话可能已经被删了。 + // **不能**退回「新建一条」—— 那会让人在 TUI 里看不到这封邮件带来的对话, + // 而那正是接管的目的(N-8 同理:静默改语义比报错糟)。 + throw new Error(adoptMissingMessage(platformID, '磁盘上已无这个会话文件')); + } + + // cwd 取会话自己的(SessionInfo.cwd 来自持久化 header)。 + // 老会话的 cwd 是空串,那种情况退回地址里的 path 位。 + const { cwd } = resolveWorkspaceCwd( + info.cwd || data.to_workspace, piMailFallback(mailSessionID)); + + const opened = await openSession({ + cwd, + modelRuntime, + customTools: mailTools, + extension: permissionExtension((id) => mailContexts.get(id)), + sessionFile: info.path, + }); + for (const d of opened.diagnostics) { + log(`扩展诊断: ${d?.message ?? JSON.stringify(d)}`); + } + + const piSessionId = opened.session.sessionId; + const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd }; + + if (mailSessionID) { + sessions.set(mailSessionID, entry); + reverseMap.set(piSessionId, mailSessionID); + // 加进 mailDriven:接管之后这条会话**开始**参与邮件往来,轮次结束要把 + // 总结转回发件人。不加的话邮件投进去了却永远没有回音。 + mailDriven.add(piSessionId); + adopted.add(piSessionId); + } + + // 兜底释放:`agent_end` 不来就永远握着这个文件,而握着它的期间 TUI 那边 + // 的写入对我们不可见 —— 正是要避免的分叉窗口。会话跑挂、事件丢失、 + // 模型一直不结束都属于这种情形。 + // + // 时长取轮次超时的两倍:runTurn 自己 60 秒就按成功返回了(长任务很正常), + // 那之后会话仍在跑,正常结束时 agent_end 会照常触发并提前释放。 + const safety = setTimeout(() => { + if (!adopted.has(piSessionId)) return; + log(`接管会话 ${piSessionId} 超过 ${ADOPT_MAX_HOLD_MS / 1000}s 未结束,强制释放`); + releaseAdopted(piSessionId, mailSessionID); + }, ADOPT_MAX_HOLD_MS); + if (typeof safety.unref === 'function') safety.unref(); + adoptTimers.set(piSessionId, safety); + + // 只挂 agent_end,不挂 session_info_changed:改名同步会把 Gateway 侧的别名 + // 覆盖成 pi 的标题,而接管会话的别名是人从补全里选的那个 slug —— + // 改掉会让他找不到自己刚发的信。 + opened.session.subscribe((event) => { + if (event?.type !== 'agent_end') return; + if (event.willRetry) return; + // 转发完再释放:relaySummary 要读 sessions 里的 entry。 + relaySummary(piSessionId) + .catch((e) => log(`自动转发失败: ${describeError(e)}`)) + .finally(() => { + // **还在跑就不能释放。** + // + // 同一条会话可能已经排了下一封邮件:runTurn 在 isStreaming 时走 + // `streamingBehavior: 'followUp'`,那封信排在当轮之后。此时 dispose + // 会把排着的那一轮一起杀掉 —— 发件人只看到信发出去后再无音讯。 + // 排着的那轮结束时会再触发一次 agent_end,由它来释放。 + if (opened.session.isStreaming) { + log(`接管会话 ${piSessionId} 仍有排队轮次,暂不释放`); + return; + } + releaseAdopted(piSessionId, mailSessionID); + }); + }); + + log(`接管 pi 会话 ${piSessionId}(cwd=${cwd},文件 ${info.path})`); + // reused: true —— 这条会话有历史,提示词不该重新自我介绍, + // 且 deliverMail 的续谈支不做模型降级(换模型要换会话,会丢掉整条上下文)。 + return { ...entry, reused: true }; +} + +/** + * 还回一条接管来的会话:dispose + 清缓存。 + * + * mailDriven 不清:它同时喂给心跳快照的 mail_driven 标记,那条平台会话 + * 确实已经在邮件往来里了。reverseMap 也不清 —— 留着让迟到的事件能找到线索, + * 而 relaySummary 在 entry 缺失时会自己早退。 + */ +function releaseAdopted(piSessionId, mailSessionID) { + if (!adopted.has(piSessionId)) return; + adopted.delete(piSessionId); + const timer = adoptTimers.get(piSessionId); + if (timer) { + clearTimeout(timer); + adoptTimers.delete(piSessionId); + } + const entry = mailSessionID ? sessions.get(mailSessionID) : undefined; + if (entry?.session?.sessionId === piSessionId) { + sessions.delete(mailSessionID); + } + try { + entry?.session?.dispose?.(); + } catch (e) { + log(`释放接管会话失败(不影响后续): ${describeError(e)}`); + } + log(`释放接管会话 ${piSessionId}(文件已交还,下一封邮件重新打开)`); +} + /** * 找到(或建立)这封邮件该落进的 pi 会话。 * @@ -266,6 +420,13 @@ async function resolveSession(data, mailTools) { const bound = mailSessionID ? sessions.get(mailSessionID) : undefined; if (bound) return { ...bound, reused: true }; + // 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 TUI 里开的 + // 那种)—— 投进它而不是新建。TUI 与邮箱是同一个 Agent 的两个入口。 + const adoptedID = adoptedSessionID(data); + if (adoptedID) { + return await adoptSession(adoptedID, data, mailTools); + } + // cwd 取寻址里的 path 位(B-3.1)。校验走共用模块:目录不存在时**不创建** // (N-2:笔误会在磁盘上落下真目录,而 Agent 在里面一无所获),拒绝相对路径(N-3)。 // @@ -387,8 +548,21 @@ async function relaySummary(piSessionId) { // (实测过:第一封邮件跑通了,sessions.session_alias 仍是空串)。 // // 放在转发**之前**:回信里会带上会话别名,收件人看到的第一封回信就能用它续谈。 - await syncNaming(piSessionId, entry.session.sessionName) - .catch((e) => log(`命名同步失败: ${describeError(e)}`)); + // + // **接管来的会话跳过这一步。** + // + // 它的名字是人在 TUI 里定的,也是他从补全里选中的那个 slug。同步会双向改坏它: + // 别名撞上本侧已有会话时 Gateway 加后缀(`agent-only-chain` → + // `agent-only-chain-2`),而定稿别名又会**回写进 pi 的会话文件** —— + // 于是下一次心跳上报的 slug 变成加了后缀那个,人从补全里选的名字凭空消失。 + // 实测撞出来过一次。 + // + // 接管会话的别名由服务端在接管时按 slug 定好(AdoptPlatformSession), + // 这里不需要也不应该再动它。 + if (!adopted.has(piSessionId)) { + await syncNaming(piSessionId, entry.session.sessionName) + .catch((e) => log(`命名同步失败: ${describeError(e)}`)); + } // 只取 type==='text' 的块(B-5.1 / N-6):thinking 是思考过程,不是结论 const text = lastAssistantText(entry.session.messages); diff --git a/plugins/pi-mail-bridge/src/session-pool.mjs b/plugins/pi-mail-bridge/src/session-pool.mjs index 6a5a5eb..44db30d 100644 --- a/plugins/pi-mail-bridge/src/session-pool.mjs +++ b/plugins/pi-mail-bridge/src/session-pool.mjs @@ -24,7 +24,7 @@ import { createAgentSession, SessionManager, SettingsManager, DefaultResourceLoa * @param {(pi: any) => void} [opts.extension] 内联扩展工厂,用来挂 tool_call 权限钩子 * @returns {Promise<{session: any, sessionManager: any, diagnostics: any[]}>} */ -export async function openSession({ cwd, modelRuntime, model, customTools, extension }) { +export async function openSession({ cwd, modelRuntime, model, customTools, extension, sessionFile }) { const agentDir = getAgentDir(); const settingsManager = SettingsManager.create(cwd, agentDir); @@ -45,7 +45,28 @@ export async function openSession({ cwd, modelRuntime, model, customTools, exten }); await resourceLoader.reload(); - const sessionManager = SessionManager.create(cwd); + // sessionFile 非空 = **接管一条磁盘上已经存在的会话**(人在 TUI 里开的那种)。 + // + // `SessionManager.open` 把整条会话装回内存(历史消息、分支、标签都在), + // 之后 prompt 就是在那条对话后面接着谈 —— 人在 TUI 里再打开它能看到 + // 邮件带来的这一轮。TUI 与邮箱是同一个 Agent 的两个入口。 + // + // # 双写风险与它的边界 + // + // pi 没有任何锁机制(SDK 里 flock/lockfile 命中为 0),它假定「一个文件 + // 一个持有者」。写入本身是纯 append(`_persist` → `appendFileSync`),所以 + // 两个持有者不会把文件截断;坏的是**各自的内存索引**:对方追加的行自己看不见, + // 于是算出的 parentId 指向一个对方不知道的 entry,会话树分叉。 + // + // 取舍是「短暂持有」:open → 跑一轮 → 丢弃这个 manager(调用方不缓存它)。 + // 窗口是一轮对话的时长。人正好在那一刻也在 TUI 里发消息仍会分叉 —— + // 但那需要两边同时动手,而分叉的后果是历史看起来少了一段,不是数据损坏。 + // + // cwd 用会话 header 里的(open 的第三参不传即取 header),不是外面传进来的: + // 会话的工作目录在它创建时就定了,传一个不同的只会让项目级配置错位。 + const sessionManager = sessionFile + ? SessionManager.open(sessionFile) + : SessionManager.create(cwd); const created = await createAgentSession({ cwd, agentDir, diff --git a/plugins/pi-mail-bridge/test/adopt.test.mjs b/plugins/pi-mail-bridge/test/adopt.test.mjs new file mode 100644 index 0000000..87c598d --- /dev/null +++ b/plugins/pi-mail-bridge/test/adopt.test.mjs @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js'; + +// 字段名是对外契约:三平台各写一遍时少个下划线就静默退化成「每封邮件新开一条」, +// 而那个错误不报任何异常。这组测试锁住字段名本身。 +test('adoptedSessionID: 取 platform_session_id', () => { + assert.equal(adoptedSessionID({ platform_session_id: 'ses_abc123' }), 'ses_abc123'); +}); + +test('adoptedSessionID: 去首尾空白', () => { + assert.equal(adoptedSessionID({ platform_session_id: ' ses_abc ' }), 'ses_abc'); +}); + +// 空串是「不是接管」的正常信号(服务端对非接管会话回空串),不是异常 +test('adoptedSessionID: 空串表示不是接管', () => { + assert.equal(adoptedSessionID({ platform_session_id: '' }), ''); + assert.equal(adoptedSessionID({ platform_session_id: ' ' }), ''); +}); + +test('adoptedSessionID: 字段缺失返回空串', () => { + assert.equal(adoptedSessionID({}), ''); + assert.equal(adoptedSessionID({ session_id: 'x' }), ''); +}); + +// 老版本服务端不发这个字段;插件不能因此崩掉整条投递 +test('adoptedSessionID: 非字符串与空输入都退回空串', () => { + assert.equal(adoptedSessionID({ platform_session_id: 123 }), ''); + assert.equal(adoptedSessionID({ platform_session_id: null }), ''); + assert.equal(adoptedSessionID({ platform_session_id: ['a'] }), ''); + assert.equal(adoptedSessionID(undefined), ''); + assert.equal(adoptedSessionID(null), ''); +}); + +// 话术必须给出可执行的下一步:只说「不存在」时模型会原地重试同一个地址 +test('adoptMissingMessage: 带上 id 与 .new 的指引', () => { + const msg = adoptMissingMessage('ses_gone'); + assert.match(msg, /ses_gone/); + assert.match(msg, /\.new/); + assert.match(msg, /可能已被删除/); +}); + +test('adoptMissingMessage: detail 可按平台定制', () => { + const msg = adoptMissingMessage('mail-1', '磁盘上已无这条会话'); + assert.match(msg, /磁盘上已无这条会话/); + assert.match(msg, /\.new/); +});