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

@ -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

View File

@ -30,8 +30,27 @@ EnvironmentFile=/etc/agentmail/pi.env
Restart=always
RestartSec=10
# 桥用 O_EXCL 建 <AGENTMAIL_CONFIG_DIR>/pi-bridge.lock 保证单实例
# (两个实例各自订阅 SSE 会让同一封邮件各起一条会话,发件人收到两封回信,
# 而去重集合在各自内存里、彼此看不到)。
#
# 被 SIGKILL 或主机掉电时锁文件留在盘上 —— 桥自己会检查锁里的 pid 是否还活着,
# 但那要求 /proc 可读且 pid 未被复用。systemd 重启前清一次更直接:
# ExecStartPre 里的 pid 必然已经死了systemd 确认进程退出后才重启)。
#
# 用 `-` 前缀:文件不存在时 rm 失败不该拦住启动。
ExecStartPre=-/bin/rm -f /root/.agentmail-pi/pi-bridge.lock
# 桥是长驻守护进程,启动即注册 + 立刻打一次心跳 + 订阅 SSEB-1
# 不像 opencode 那样惰加载,因此不需要 ExecStartPost 预热。
# pi 会话在内存里持有整条对话,长跑之后常驻几百 MB。给一个上限让它被
# OOM killer 挑中而不是拖垮整机Restart=always 会把它拉回来。
MemoryMax=4G
# 优雅停机:桥收到 SIGTERM 后要注销 SSE、清锁文件。
# 默认 90 秒太长停机时人在等10 秒足够那两件事。
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target

View File

@ -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='<name>' AND mail_driven=0" → 出现它的 slug
→ 发一封到 <name>@<那条会话的 workspace>.<那个 slug>
→ 日志显示「接管/resume」而**不是**「新会话」
→ 让模型回答「这条会话之前在谈什么」→ 答案含界面上聊过的内容(上下文装回来了)
→ sqlite3 "SELECT platform_id FROM sessions WHERE session_alias='<slug>'"
→ 等于那条平台会话的 id
→ 收到自动回信(接管后也进 mailDrivenB-5.5
→ 再发第二封:复用**同一条**本侧会话,不再新建(避免线索裂成多条)
→ 平台侧会话文件/记录里**没有**新增改名条目(接管会话跳过 C-11
[ ] 平台侧删掉那条会话后再投同一个别名
→ 报错且话术含 `.new`,平台侧**没有**新建任何会话B-3.8
[ ] 模型主动调 send_mail 回信的那一轮
→ 只有一封邮件没有额外的自动转发B-5.3

View File

@ -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)",
}

View File

@ -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 <> '';

View File

@ -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。

View File

@ -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,
}
}

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

View File

@ -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
}

View File

@ -0,0 +1,2 @@
export function adoptedSessionID(data: any): string;
export function adoptMissingMessage(platformID: string, detail?: string): string;

View File

@ -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 新开一条会话,或换一个仍然存在的会话别名。`;
}

View File

@ -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-<uuid>` 换成平台自己那个。第一次投递走
// resume 分支装回上下文之后与普通续谈完全一样sessionMap 命中 → followup
const adoptedID = adoptedSessionID(data);
if (!existing && adoptedID) {
const onDisk = await persistedCwd(adoptedID);
if (onDisk === undefined) {
// 镜像是快照,可以过期:平台侧那条会话可能已经被人删了。
// 不能落到「新开会话」那条路 —— 那会用 `mail-<uuid>` 另开一条,
// 人在 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) {

View File

@ -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/);
});

View File

@ -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 就是「希望它在哪儿干活」。用固定的

View File

@ -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 新开一条会话,或换一个仍然存在的会话别名。`;
}

View File

@ -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/);
});

View File

@ -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 新开一条会话,或换一个仍然存在的会话别名。`;
}

View File

@ -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-6thinking 是思考过程,不是结论
const text = lastAssistantText(entry.session.messages);

View File

@ -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,

View File

@ -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/);
});