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:
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user