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:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
# 桥是长驻守护进程,启动即注册 + 立刻打一次心跳 + 订阅 SSE(B-1),
|
||||
# 不像 opencode 那样惰加载,因此不需要 ExecStartPost 预热。
|
||||
|
||||
# pi 会话在内存里持有整条对话,长跑之后常驻几百 MB。给一个上限让它被
|
||||
# OOM killer 挑中而不是拖垮整机;Restart=always 会把它拉回来。
|
||||
MemoryMax=4G
|
||||
|
||||
# 优雅停机:桥收到 SIGTERM 后要注销 SSE、清锁文件。
|
||||
# 默认 90 秒太长(停机时人在等),10 秒足够那两件事。
|
||||
TimeoutStopSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -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
|
||||
→ 收到自动回信(接管后也进 mailDriven,B-5.5)
|
||||
→ 再发第二封:复用**同一条**本侧会话,不再新建(避免线索裂成多条)
|
||||
→ 平台侧会话文件/记录里**没有**新增改名条目(接管会话跳过 C-11)
|
||||
|
||||
[ ] 平台侧删掉那条会话后再投同一个别名
|
||||
→ 报错且话术含 `.new`,平台侧**没有**新建任何会话(B-3.8)
|
||||
|
||||
[ ] 模型主动调 send_mail 回信的那一轮
|
||||
→ 只有一封邮件,没有额外的自动转发(B-5.3)
|
||||
|
||||
|
||||
@ -88,6 +88,9 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
// 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的,
|
||||
// 而反过来(默认成 event_time)会让正在等的提醒永远发不出去。
|
||||
{"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"},
|
||||
// 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」,
|
||||
// 与新建会话的语义一致,不需要数据迁移。
|
||||
{"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"},
|
||||
// 派给该 Agent 的新任务默认多少个来回。
|
||||
// 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
|
||||
// 不能直接搬过来当单任务预算。
|
||||
@ -97,6 +100,8 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||||
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
|
||||
var sqliteAddIndexes = []string{
|
||||
// 接管平台会话时按 platform_id 反查(依赖上面补的列)
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''",
|
||||
// 人类决策后要按 mail_id 反查上游 permission id
|
||||
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
|
||||
}
|
||||
|
||||
@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
workspace VARCHAR(512) NOT NULL DEFAULT '',
|
||||
from_agent VARCHAR(64) NOT NULL,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
-- 接管的平台侧会话 id(见 init_sqlite.sql 的说明)
|
||||
platform_id VARCHAR(256) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
owner_user_id UUID REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@ -399,3 +401,13 @@ CREATE TABLE IF NOT EXISTS calendar_attachments (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||||
ON calendar_attachments(event_id);
|
||||
|
||||
-- 接管平台会话时按 platform_id 反查本侧会话。
|
||||
--
|
||||
-- 先 ALTER 再建索引:CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,
|
||||
-- 而这个脚本在已部署的库上也要能跑。PG 支持 ADD COLUMN IF NOT EXISTS,
|
||||
-- 所以这里不需要像 SQLite 那样绕到代码里去(见 migrate.go 的 sqliteAddIndexes)。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS platform_id VARCHAR(256) NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_platform
|
||||
ON sessions(platform_id) WHERE platform_id <> '';
|
||||
|
||||
@ -85,6 +85,17 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
|
||||
-- 绑定到平台侧的哪条会话(agent_platform_sessions.platform_id)。
|
||||
--
|
||||
-- 空 = 这条会话由邮件创建,平台侧的会话是桥按邮件开的。
|
||||
-- 非空 = 这条会话**接管**了一条平台上已经存在的会话(人在 TUI 里开的那种)。
|
||||
--
|
||||
-- 为什么需要它:TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。
|
||||
-- 人在 TUI 里聊了一半想转到邮件上继续,或者想把一封邮件投进正在谈的那条
|
||||
-- 会话 —— 补全早就把平台会话列为候选(agent_platform_sessions),
|
||||
-- 但投递侧没有这一跳,选中后只能得到 404。这一列就是那一跳的落点。
|
||||
platform_id TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||
rename_dismissed TEXT,
|
||||
|
||||
@ -427,3 +438,9 @@ CREATE TABLE IF NOT EXISTS calendar_attachments (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||||
ON calendar_attachments(event_id);
|
||||
|
||||
-- 注意:idx_sessions_platform 不在这里。
|
||||
-- 这个脚本在 addMissingColumns **之前**执行,而已部署的库里 sessions 表已经
|
||||
-- 存在 —— CREATE TABLE IF NOT EXISTS 不会给它补 platform_id 列,于是这里建
|
||||
-- 索引会以 "no such column" 失败,整个迁移中断(实测过一次)。
|
||||
-- 依赖补出来的列的索引一律放 migrate.go 的 sqliteAddIndexes。
|
||||
|
||||
@ -17,8 +17,8 @@ import (
|
||||
// ---------- Mail ----------
|
||||
|
||||
type sendMailRequest struct {
|
||||
To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在)
|
||||
CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session
|
||||
To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在)
|
||||
CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
@ -122,19 +122,87 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
|
||||
|
||||
default: // models.SessionNamed
|
||||
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
if err == nil {
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, nil
|
||||
|
||||
// 本侧没有这条别名 —— 再看平台会话镜像。
|
||||
//
|
||||
// TUI 与邮箱是同一个 Agent 的两个入口,人在平台界面上开的会话
|
||||
// 早就被补全列为候选(agent_platform_sessions),此前投递侧却没有
|
||||
// 这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
|
||||
//
|
||||
// 命中就**接管**它:本侧建一条会话并绑定 platform_id,插件收到投递
|
||||
// 事件时据此 resume 那条平台会话而不是新建。
|
||||
if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil {
|
||||
return adopted, nil, nil
|
||||
} else if !errors.Is(aErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, aErr
|
||||
}
|
||||
|
||||
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
}
|
||||
}
|
||||
|
||||
// adoptFromPlatform 把地址里的 session 位当作**平台会话的 slug** 来解析,
|
||||
// 命中则接管那条会话。
|
||||
//
|
||||
// 返回 repo.ErrSessionNotFound 表示镜像里也没有,调用方据此回 404。
|
||||
//
|
||||
// # 为什么接管而不是直接投
|
||||
//
|
||||
// 平台会话在本侧没有身份:没有 session_id、没有预算、没法归档,
|
||||
// 也无处记录「谁往里投过什么」。接管一次之后它就是一条正常的本侧会话,
|
||||
// 只是多带一个 platform_id 告诉插件「别新建,去 resume 那条」。
|
||||
//
|
||||
// # 为什么一条平台会话只能被接管一次
|
||||
//
|
||||
// 第二次投递必须复用第一次建的本侧会话。否则同一条 TUI 对话会在邮箱里
|
||||
// 裂成多条互不相干的线索 —— 人看到三个同名会话,而回信只落在其中一条上。
|
||||
func adoptFromPlatform(r *http.Request, addr models.Address, fromAgent, subject, byAgent string) (uuid.UUID, error) {
|
||||
platformID, realWorkspace, title, err := repo.FindPlatformSession(
|
||||
r.Context(), addr.Name, addr.Session, addr.Path)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
// 已被接管过 → 复用,不再建新的
|
||||
if existing, fErr := repo.FindSessionByPlatformID(r.Context(), addr.Name, platformID); fErr == nil {
|
||||
repo.TouchSession(r.Context(), existing)
|
||||
return existing, nil
|
||||
} else if !errors.Is(fErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, fErr
|
||||
}
|
||||
|
||||
// 接管等于新开一条本侧线索,计入速率限制 —— 否则它成了绕过
|
||||
// AllowNewSession 的后门(镜像里有几百条 slug 可选)。
|
||||
if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok {
|
||||
return uuid.Nil, errRateLimited(fmt.Sprintf(
|
||||
"新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
|
||||
repo.SessionRateLimit(), retry))
|
||||
}
|
||||
|
||||
// 主题优先用平台侧标题:它是那条对话在谈什么,比这封邮件的主题更能
|
||||
// 代表整条会话。人在补全里看到的也是这个标题。
|
||||
sub := strings.TrimSpace(title)
|
||||
if sub == "" {
|
||||
sub = subject
|
||||
}
|
||||
id, err := repo.AdoptPlatformSession(
|
||||
r.Context(), addr.Name, platformID, addr.Session, realWorkspace, sub)
|
||||
if err != nil {
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/send
|
||||
func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
@ -346,6 +414,9 @@ func notifyRecipients(ctx context.Context, to models.Address, cc []models.Addres
|
||||
// 仍可能为空的情形:命名写入失败(已吐日志)。此时退回省略 session 位,
|
||||
// 而不是把 "new" 写进去 —— 后者会让参与方反复建新会话。
|
||||
alias := repo.SessionAliasOf(ctx, sessionID)
|
||||
// 这条会话是否接管了一条平台侧已存在的会话(人在 TUI 里开的那种)。
|
||||
// 插件据此决定 resume 还是新建 —— 空串就是过去的行为。
|
||||
platformID := repo.PlatformIDOf(ctx, sessionID)
|
||||
|
||||
payload := func(role, workspace, forName string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
@ -367,6 +438,13 @@ func notifyRecipients(ctx context.Context, to models.Address, cc []models.Addres
|
||||
"reply_address": models.FormatAddress(from, "", alias),
|
||||
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
|
||||
"self_address": models.FormatAddress(forName, workspace, alias),
|
||||
// platform_session_id 非空时,这封邮件要投进**平台侧已经存在的
|
||||
// 那条会话**(TUI 与邮箱是同一个 Agent 的两个入口)。
|
||||
//
|
||||
// 插件必须 resume 而不是新建:新建会让人在 TUI 里看不到这封邮件
|
||||
// 带来的对话,而那正是接管这条会话的目的。
|
||||
// 空串 = 照旧按邮件新开一条平台会话。
|
||||
"platform_session_id": platformID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
228
gateway/internal/repo/adopt_test.go
Normal file
228
gateway/internal/repo/adopt_test.go
Normal file
@ -0,0 +1,228 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
func seedPlatformMirror(t *testing.T, agentName string, list []PlatformSession) {
|
||||
t.Helper()
|
||||
if err := ReplacePlatformSessions(context.Background(), agentName, list); err != nil {
|
||||
t.Fatalf("上报镜像: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 补全把平台会话列为候选,投递侧必须能命中同一条。
|
||||
// 此前 FindNamedSessionFor 只查 sessions 表 —— 候选列表在承诺一件做不到的事。
|
||||
func TestFindPlatformSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
seedPlatformMirror(t, "pi", []PlatformSession{
|
||||
{PlatformID: "pi-sess-1", Workspace: "/home/program/agentmail",
|
||||
Slug: "设计文档-项目定位", Title: "邮件驱动·多智能体协作平台", UpdatedAt: &now},
|
||||
{PlatformID: "pi-sess-2", Workspace: "/tmp/other",
|
||||
Slug: "别处的会话", Title: "无关", UpdatedAt: &now},
|
||||
})
|
||||
|
||||
t.Run("按 slug + workspace 命中", func(t *testing.T) {
|
||||
pid, ws, title, err := FindPlatformSession(ctx, "pi", "设计文档-项目定位", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("查找: %v", err)
|
||||
}
|
||||
if pid != "pi-sess-1" {
|
||||
t.Errorf("platform_id = %q", pid)
|
||||
}
|
||||
if ws != "/home/program/agentmail" {
|
||||
t.Errorf("workspace = %q", ws)
|
||||
}
|
||||
if title != "邮件驱动·多智能体协作平台" {
|
||||
t.Errorf("title = %q", title)
|
||||
}
|
||||
})
|
||||
|
||||
// 地址省略 path 位时不限工作区
|
||||
t.Run("workspace 为空时不限", func(t *testing.T) {
|
||||
if pid, _, _, err := FindPlatformSession(ctx, "pi", "别处的会话", ""); err != nil || pid != "pi-sess-2" {
|
||||
t.Errorf("得到 %q err=%v", pid, err)
|
||||
}
|
||||
})
|
||||
|
||||
// workspace 不匹配时不该命中 —— 那会让邮件投进另一个项目的会话
|
||||
t.Run("workspace 不匹配不命中", func(t *testing.T) {
|
||||
if _, _, _, err := FindPlatformSession(ctx, "pi", "别处的会话", "/home/program/agentmail"); err == nil {
|
||||
t.Error("workspace 不同却命中了")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("别的 Agent 的镜像不串", func(t *testing.T) {
|
||||
if _, _, _, err := FindPlatformSession(ctx, "dsh", "设计文档-项目定位", ""); err == nil {
|
||||
t.Error("dsh 命中了 pi 的会话")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空参数返回 not found 而不是 panic", func(t *testing.T) {
|
||||
if _, _, _, err := FindPlatformSession(ctx, "", "x", ""); err != ErrSessionNotFound {
|
||||
t.Errorf("空 agent 应给 ErrSessionNotFound,得到 %v", err)
|
||||
}
|
||||
if _, _, _, err := FindPlatformSession(ctx, "pi", "", ""); err != ErrSessionNotFound {
|
||||
t.Errorf("空 slug 应给 ErrSessionNotFound,得到 %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 接管后本侧有正式身份:可寻址(别名)、绑定 platform_id、workspace 用会话真实的。
|
||||
func TestAdoptPlatformSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "设计文档-项目定位",
|
||||
"/home/program/agentmail", "邮件驱动·多智能体协作平台")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
|
||||
// 别名复用平台 slug:人在补全里看到的就是那个名字,换掉会让他找不到
|
||||
if alias := SessionAliasOf(ctx, id); alias != "设计文档-项目定位" {
|
||||
t.Errorf("别名 = %q,期望复用平台 slug", alias)
|
||||
}
|
||||
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-1" {
|
||||
t.Errorf("platform_id = %q", pid)
|
||||
}
|
||||
|
||||
// workspace 取平台会话的真实 cwd
|
||||
var ws string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspace FROM sessions WHERE session_id = $1`, id).Scan(&ws); err != nil {
|
||||
t.Fatalf("读 workspace: %v", err)
|
||||
}
|
||||
if ws != "/home/program/agentmail" {
|
||||
t.Errorf("workspace = %q", ws)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通会话的 platform_id 必须是空串(不是接管来的)。
|
||||
func TestPlatformIDOfEmptyForNormalSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := CreateSession(ctx, nil, "pi", "普通邮件会话", "/tmp/x")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
if pid := PlatformIDOf(ctx, id); pid != "" {
|
||||
t.Errorf("普通会话的 platform_id 应为空,得到 %q", pid)
|
||||
}
|
||||
}
|
||||
|
||||
// 一条平台会话只能被接管一次。
|
||||
//
|
||||
// 第二次投递必须复用第一次建的本侧会话 —— 否则同一条 TUI 对话会在邮箱里
|
||||
// 裂成多条互不相干的线索:人看到三个同名会话,而回信只落在其中一条上。
|
||||
func TestFindSessionByPlatformIDPreventsDoubleAdopt(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "某会话", "/tmp/ws", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
// 接管后还没有邮件 —— 此时反查不到(EXISTS 子句要求有本 Agent 参与的邮件)
|
||||
if _, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-1"); err == nil {
|
||||
t.Log("注意:无邮件时也能反查到")
|
||||
}
|
||||
|
||||
// 投一封进去,让参与关系成立
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/tmp/ws", "主题", "正文", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
|
||||
got, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-1")
|
||||
if err != nil {
|
||||
t.Fatalf("反查: %v", err)
|
||||
}
|
||||
if got != id {
|
||||
t.Errorf("反查到 %v,期望 %v", got, id)
|
||||
}
|
||||
|
||||
// 别的 platform_id 查不到
|
||||
if _, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-999"); err != ErrSessionNotFound {
|
||||
t.Errorf("不存在的 platform_id 应给 ErrSessionNotFound,得到 %v", err)
|
||||
}
|
||||
// 别的 Agent 查不到(参与关系不成立)
|
||||
if _, err := FindSessionByPlatformID(ctx, "dsh", "pi-sess-1"); err != ErrSessionNotFound {
|
||||
t.Errorf("dsh 不该查到 pi 的接管会话,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 整表替换镜像后,已接管的本侧会话不受影响。
|
||||
//
|
||||
// 镜像是平台当前状态的快照、会被整表替换;而 sessions.platform_id 是本侧的
|
||||
// 持久绑定。平台侧那条会话被删掉之后,本侧线索与历史邮件仍然要在。
|
||||
func TestAdoptedSessionSurvivesMirrorReplace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
now := time.Now()
|
||||
seedPlatformMirror(t, "pi", []PlatformSession{
|
||||
{PlatformID: "pi-sess-1", Workspace: "/tmp/ws", Slug: "会话甲", UpdatedAt: &now},
|
||||
})
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "会话甲", "/tmp/ws", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
|
||||
// 平台侧删了那条会话(新快照里没有它)
|
||||
seedPlatformMirror(t, "pi", []PlatformSession{
|
||||
{PlatformID: "pi-sess-2", Workspace: "/tmp/ws", Slug: "会话乙", UpdatedAt: &now},
|
||||
})
|
||||
|
||||
// 本侧绑定与别名都还在
|
||||
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-1" {
|
||||
t.Errorf("镜像替换后 platform_id 丢了:%q", pid)
|
||||
}
|
||||
if alias := SessionAliasOf(ctx, id); alias != "会话甲" {
|
||||
t.Errorf("别名丢了:%q", alias)
|
||||
}
|
||||
// 但镜像里查不到了(补全不再列它,符合预期)
|
||||
if _, _, _, err := FindPlatformSession(ctx, "pi", "会话甲", ""); err != ErrSessionNotFound {
|
||||
t.Errorf("镜像里应已消失,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 接管用的 slug 与本侧某条无关会话撞名时要自动加后缀(别名全局唯一)。
|
||||
func TestAdoptHandlesAliasCollision(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
// 先占掉这个别名
|
||||
taken := "撞名的别名"
|
||||
if _, err := CreateSession(ctx, &taken, "pi", "已存在", "/tmp/a"); err != nil {
|
||||
t.Fatalf("建占位会话: %v", err)
|
||||
}
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-x", taken, "/tmp/b", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
alias := SessionAliasOf(ctx, id)
|
||||
if alias == "" {
|
||||
t.Fatal("接管后没有别名 —— 这条会话将无法寻址")
|
||||
}
|
||||
if alias == taken {
|
||||
t.Errorf("别名与已存在的重复了:%q", alias)
|
||||
}
|
||||
// 绑定仍然正确
|
||||
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-x" {
|
||||
t.Errorf("platform_id = %q", pid)
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,13 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 平台会话镜像 ----------
|
||||
@ -100,8 +103,8 @@ type SessionCandidate struct {
|
||||
// SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。
|
||||
//
|
||||
// 两个来源合并:
|
||||
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
|
||||
// 2. 平台会话镜像里带 slug 的那些
|
||||
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
|
||||
// 2. 平台会话镜像里带 slug 的那些
|
||||
//
|
||||
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
|
||||
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
|
||||
@ -220,3 +223,105 @@ func SetSessionWorkspace(ctx context.Context, sessionID interface{ String() stri
|
||||
ws, sessionID.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 接管平台会话 ----------
|
||||
//
|
||||
// TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。
|
||||
// 人在平台界面上开的会话,应该也能被邮件投进去 —— 补全早就把它们列为候选,
|
||||
// 缺的只是投递侧这一跳。
|
||||
//
|
||||
// 「接管」= 在本侧建一条会话并把 platform_id 记上。之后:
|
||||
// - 这条会话在 sessions 表里有正式身份(可寻址、有预算、能归档)
|
||||
// - 插件收到投递事件时看到 platform_id,就去 resume 那条平台会话
|
||||
// 而不是新建一条
|
||||
//
|
||||
// 一条平台会话只能被接管一次:第二次投递复用第一次建的本侧会话,
|
||||
// 否则同一条 TUI 对话会在邮箱里裂成多条互不相干的线索。
|
||||
|
||||
// FindPlatformSession 按 (agent, slug, workspace) 找一条平台会话镜像。
|
||||
//
|
||||
// workspace 为空表示不限(地址省略 path 位时)。返回 platform_id 与它的
|
||||
// 真实 workspace —— 后者是权威的:**会话的 cwd 在它创建时就定了**,
|
||||
// 地址里的 path 位若与之不同,以会话为准。人是从候选列表里选的,
|
||||
// 他要的是「那条会话」而不是「那个目录」。
|
||||
func FindPlatformSession(ctx context.Context, agentName, slug, workspace string) (platformID, realWorkspace, title string, err error) {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
slug = strings.TrimSpace(slug)
|
||||
if agentName == "" || slug == "" {
|
||||
return "", "", "", ErrSessionNotFound
|
||||
}
|
||||
ws := strings.TrimSpace(workspace)
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
SELECT platform_id, workspace, title
|
||||
FROM agent_platform_sessions
|
||||
WHERE agent_name = $1 AND slug = $2
|
||||
AND ($3 = '' OR workspace = $3)
|
||||
ORDER BY COALESCE(updated_at, reported_at) DESC
|
||||
LIMIT 1
|
||||
`, agentName, slug, ws).Scan(&platformID, &realWorkspace, &title)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", "", ErrSessionNotFound
|
||||
}
|
||||
return platformID, realWorkspace, title, err
|
||||
}
|
||||
|
||||
// FindSessionByPlatformID 找出已经接管了某条平台会话的本侧会话。
|
||||
//
|
||||
// 返回 ErrSessionNotFound 表示还没被接管。归档的也算 —— 让归档过的会话
|
||||
// 重新被接管会造出第二条本侧会话,同一条 TUI 对话在邮箱里就裂成两截。
|
||||
// 需要恢复的话人应该去取消归档。
|
||||
func FindSessionByPlatformID(ctx context.Context, agentName, platformID string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT s.session_id
|
||||
FROM sessions s
|
||||
WHERE s.platform_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.to_name = $2 OR m.from_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
|
||||
)
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT 1
|
||||
`, platformID, agentName).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, ErrSessionNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// AdoptPlatformSession 接管一条平台会话:建本侧会话并绑定 platform_id。
|
||||
//
|
||||
// alias 用平台自己的 slug —— 「别名复用平台命名」是既定决策,而且人在补全里
|
||||
// 看到的就是那个 slug,投递后别名换成别的会让他找不到自己刚发的信。
|
||||
//
|
||||
// workspace 用平台会话的真实 cwd 而不是地址里的 path 位,理由见
|
||||
// FindPlatformSession 的注释。
|
||||
func AdoptPlatformSession(ctx context.Context, agentName, platformID, slug, workspace, subject string) (uuid.UUID, error) {
|
||||
// slug 可能与本侧某条无关会话撞名(别名全局唯一)。撞了就加后缀 ——
|
||||
// EnsureSessionAlias 已有这套逻辑,这里先建后命名即可。
|
||||
id, err := CreateSession(ctx, nil, agentName, subject, workspace)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET platform_id = $1 WHERE session_id = $2`,
|
||||
platformID, id); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
// 别名尽量用 slug;撞名时 EnsureSessionAlias 自动加后缀
|
||||
_, _ = EnsureSessionAlias(ctx, id, slug)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PlatformIDOf 读一条本侧会话绑定的平台会话 id(空 = 不是接管来的)。
|
||||
//
|
||||
// 投递时要把它放进 SSE 事件:插件据此决定 resume 还是新建。
|
||||
func PlatformIDOf(ctx context.Context, sessionID uuid.UUID) string {
|
||||
var pid string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT platform_id FROM sessions WHERE session_id = $1`, sessionID).Scan(&pid); err != nil {
|
||||
return ""
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
2
plugins/dsh-mail-bridge/lib/adopt.d.ts
vendored
Normal file
2
plugins/dsh-mail-bridge/lib/adopt.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export function adoptedSessionID(data: any): string;
|
||||
export function adoptMissingMessage(platformID: string, detail?: string): string;
|
||||
54
plugins/dsh-mail-bridge/lib/adopt.js
Normal file
54
plugins/dsh-mail-bridge/lib/adopt.js
Normal 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 新开一条会话,或换一个仍然存在的会话别名。`;
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
47
plugins/dsh-mail-bridge/test/adopt.test.mjs
Normal file
47
plugins/dsh-mail-bridge/test/adopt.test.mjs
Normal 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/);
|
||||
});
|
||||
@ -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 就是「希望它在哪儿干活」。用固定的
|
||||
|
||||
54
plugins/opencode-mail-bridge/lib/adopt.js
Normal file
54
plugins/opencode-mail-bridge/lib/adopt.js
Normal 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 新开一条会话,或换一个仍然存在的会话别名。`;
|
||||
}
|
||||
47
plugins/opencode-mail-bridge/test/adopt.test.mjs
Normal file
47
plugins/opencode-mail-bridge/test/adopt.test.mjs
Normal 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/);
|
||||
});
|
||||
54
plugins/pi-mail-bridge/lib/adopt.js
Normal file
54
plugins/pi-mail-bridge/lib/adopt.js
Normal 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 新开一条会话,或换一个仍然存在的会话别名。`;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
47
plugins/pi-mail-bridge/test/adopt.test.mjs
Normal file
47
plugins/pi-mail-bridge/test/adopt.test.mjs
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user