feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件
四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。 ## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」) 插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有 邮件会话既不属于任何项目、彼此也不同组。 而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload 只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在 SSE 事件里,插件即使想用也拿不到。 - SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**, 不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区 - 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到 兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活) - 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/` ## 2. 会话别名列不出工作区下的历史会话(无法选择) workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须 JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace 填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p` 在只剩 Agent 回信可匹配时两边都对不上。 - `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入 - `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace 为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失 - `FindOrCreateDefaultSession` 同步改用会话的 workspace ## 3. 平台侧会话在补全里根本不存在 人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。 新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。 **上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway, Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。 - 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有 本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断 这条到底是不是真的本侧会话 - **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是 三态语义,指向不存在的会话直接 404 - **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像), 而不是传空数组把镜像抹掉 - **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是 派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名; 它们是父 agent 内部的工作单元,人往里发邮件毫无意义 - **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里 出现几个点哪个都不确定的候选 - DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着 补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与 第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是 「缓存选型」而不是 brisk-harbor 这种随机短名。 ## 4. 对话树看不见抄送与转发产生的分支 旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是 它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到 另一个;挂在原件上的转发分支同理。 改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向, 因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点 为什么并列,唯一的解释就是父邮件抄送给了两个人。 ## 5. DSH 插件(Phase 7.7) 卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是 `followup()` 的参数形状:DSH 要完整的 UserMessage(content + source), 而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。 - `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的 session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道 - `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的 permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall, 可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回 - 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 —— 留在别名里会让它自己被解析器切开) - 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处 炸一个无关错误」 ## 其他 - `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup` 而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的 foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰 systemd(演练时误停过一次生产服务) - 插件 dist/ 不进版本库,install.sh 负责构建 - `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话
This commit is contained in:
14
plugins/dsh-mail-bridge/cordis.patch.yml
Normal file
14
plugins/dsh-mail-bridge/cordis.patch.yml
Normal file
@ -0,0 +1,14 @@
|
||||
# dsh-mail-bridge bundle patch.
|
||||
# 安装方式: dsh plugin --profile web add link:/home/program/agentmail/plugins/dsh-mail-bridge
|
||||
- insert:
|
||||
- id: dsh-mail-bridge
|
||||
name: dsh-mail-bridge
|
||||
config:
|
||||
gateway:
|
||||
url: !!js process.env.AGENTMAIL_GATEWAY_URL ?? 'http://127.0.0.1:8180'
|
||||
agentName: !!js process.env.AGENTMAIL_AGENT_NAME ?? 'dsh'
|
||||
agentKey: !!js process.env.AGENTMAIL_AGENT_KEY
|
||||
agentSecret: !!js process.env.AGENTMAIL_AGENT_SECRET
|
||||
reply:
|
||||
provider: !!js process.env.AGENTMAIL_REPLY_PROVIDER
|
||||
model: !!js process.env.AGENTMAIL_REPLY_MODEL
|
||||
10
plugins/dsh-mail-bridge/lib/message.d.ts
vendored
Normal file
10
plugins/dsh-mail-bridge/lib/message.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
export interface DshUserMessage {
|
||||
content: { type: 'text'; text: string }[];
|
||||
source: { kind: 'user' };
|
||||
}
|
||||
|
||||
export function userMessage(text: string): DshUserMessage;
|
||||
export function stripRe(subject: string): string;
|
||||
export function replySubject(subject: string, fallback?: string): string;
|
||||
export function lastAssistantText(events: readonly any[]): string;
|
||||
export function modelTitle(events: readonly any[]): string;
|
||||
88
plugins/dsh-mail-bridge/lib/message.js
Normal file
88
plugins/dsh-mail-bridge/lib/message.js
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* DSH 消息与会话日志的纯函数工具。
|
||||
*
|
||||
* 单独放一个模块是为了能被单测覆盖 —— 其中 userMessage() 的形状曾让整个插件
|
||||
* 卡了一下午(见该函数注释),这种「错了不报错、只在深处炸一个无关的类型错误」
|
||||
* 的约定必须被测试钉住。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 构造 DSH 的 UserMessage。
|
||||
*
|
||||
* **这是 followup()/steer() 唯一接受的形状。** DSH 的 `agent.followup(message)`
|
||||
* 要的是完整的 UserMessage(`content` + `source`),不是 opencode 那种 parts 数组。
|
||||
* 传数组进去不会当场报错:agent-loop 会一路走到 preStep 里读 `message.source.kind`,
|
||||
* 然后抛 `Cannot read properties of undefined (reading 'kind')` —— 错误信息落在
|
||||
* agent-loop 内部,完全不指向调用点。
|
||||
*
|
||||
* @param {string} text 正文
|
||||
* @returns {{content: {type: 'text', text: string}[], source: {kind: 'user'}}}
|
||||
*/
|
||||
export function userMessage(text) {
|
||||
return {
|
||||
content: [{ type: 'text', text: String(text) }],
|
||||
source: { kind: 'user' },
|
||||
};
|
||||
}
|
||||
|
||||
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 无限叠加。 */
|
||||
export function stripRe(subject) {
|
||||
return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 回信主题:原主题前加一个 Re:,空主题给一个兜底。
|
||||
* @param {string} subject 来信主题
|
||||
* @param {string} fallback 主题为空时用的标题
|
||||
*/
|
||||
export function replySubject(subject, fallback = 'DSH 回复') {
|
||||
const base = stripRe(subject).trim();
|
||||
return base ? `Re: ${base}` : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话事件日志里取最后一条 assistant 消息的可见文本。
|
||||
*
|
||||
* 只取 `type === 'text'` 的块:reasoning 块是模型的思考过程,不该出现在邮件里。
|
||||
*
|
||||
* @param {readonly any[]} events session.events
|
||||
* @returns {string} 文本,找不到时为空串
|
||||
*/
|
||||
export function lastAssistantText(events) {
|
||||
const list = Array.isArray(events) ? events : [];
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const ev = list[i];
|
||||
if (ev?.type !== 'assistant/message') continue;
|
||||
const blocks = ev.data?.message?.content;
|
||||
if (!Array.isArray(blocks)) return '';
|
||||
return blocks
|
||||
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话事件日志里取最后一次 session/title 的标题。
|
||||
*
|
||||
* DSH 首轮结束后由模型生成摘要标题,之前是 `source.kind === 'fallback'` 的占位
|
||||
* (内容就是用户第一句话的截断)。占位标题不值得回写给 AgentMail,会把
|
||||
* 「你收到一封新邮件(AgentMail)」这种插件自己的提示词当成会话标题。
|
||||
*
|
||||
* @param {readonly any[]} events session.events
|
||||
* @returns {string} 模型生成的标题,没有则空串
|
||||
*/
|
||||
export function modelTitle(events) {
|
||||
const list = Array.isArray(events) ? events : [];
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const ev = list[i];
|
||||
if (ev?.type !== 'session/title') continue;
|
||||
const kind = ev.data?.source?.kind;
|
||||
if (kind === 'fallback') return '';
|
||||
const title = ev.data?.title;
|
||||
return typeof title === 'string' ? title.trim() : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
14
plugins/dsh-mail-bridge/lib/relay-dedup.d.ts
vendored
Normal file
14
plugins/dsh-mail-bridge/lib/relay-dedup.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
export interface RelayRecord {
|
||||
names: Set<string>;
|
||||
replyTos: Set<string>;
|
||||
}
|
||||
|
||||
export declare const explicitSends: Map<string, RelayRecord>;
|
||||
|
||||
export function addrName(addr: string): string;
|
||||
export function noteExplicitSend(sessionID: string | undefined, to: string, replyTo: string): void;
|
||||
export function shouldSkipAutoRelay(
|
||||
sent: RelayRecord | undefined,
|
||||
replyTo: string,
|
||||
mailID?: string
|
||||
): boolean;
|
||||
58
plugins/dsh-mail-bridge/lib/relay-dedup.js
Normal file
58
plugins/dsh-mail-bridge/lib/relay-dedup.js
Normal file
@ -0,0 +1,58 @@
|
||||
// 自动转发去重的纯逻辑。
|
||||
//
|
||||
// 单独一个文件而不是放在 index.js 里导出:**opencode 会把插件入口模块的
|
||||
// 每一个导出都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数),
|
||||
// 多导出一个 Map 就会让整个插件加载失败:
|
||||
// ERROR message="failed to load plugin" error="Plugin export is not a function"
|
||||
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
|
||||
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
|
||||
|
||||
/** 取三维地址的名字段:admin@root.alias -> admin */
|
||||
export function addrName(addr) {
|
||||
return String(addr || "").split("@")[0].trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 本轮内模型**自己调 send_mail** 发出去的信(按 opencode 会话)。
|
||||
*
|
||||
* session.idle 的自动转发要据此让位:模型已经亲手回过这条线索了,
|
||||
* 再把它最后那段话转一遍,收件箱里就是两封内容几乎一样的邮件。
|
||||
* 生产实测过这个后果 —— 同一轮里 311 字节和 342 字节各一封,
|
||||
* 说的是同一件事,其中带附件的那封才是模型真正想发的。
|
||||
*
|
||||
* 为什么不靠 relay_key 幂等:那个键是 assistant message id,
|
||||
* 保证的是「同一条消息不被转两次」,管不了「模型已经自己发过了」。
|
||||
*
|
||||
* 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始),
|
||||
* relaySummary 用完即清。
|
||||
*/
|
||||
export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set }
|
||||
|
||||
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
|
||||
export function noteExplicitSend(sessionID, to, replyTo) {
|
||||
if (!sessionID) return;
|
||||
let rec = explicitSends.get(sessionID);
|
||||
if (!rec) {
|
||||
rec = { names: new Set(), replyTos: new Set() };
|
||||
explicitSends.set(sessionID, rec);
|
||||
}
|
||||
const name = addrName(to);
|
||||
if (name) rec.names.add(name);
|
||||
if (replyTo) rec.replyTos.add(String(replyTo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 本轮是否该跳过自动转发。
|
||||
*
|
||||
* @param sent 该会话本轮的主动发信记录 { names:Set, replyTos:Set },可为空
|
||||
* @param replyTo 自动转发本来要发给谁(三维地址或纯名字)
|
||||
* @param mailID 自动转发本来要 reply_to 的邮件 id
|
||||
*/
|
||||
export function shouldSkipAutoRelay(sent, replyTo, mailID) {
|
||||
if (!sent) return false;
|
||||
// 收件人同名:模型已经跟这个人说过了
|
||||
if (sent.names.has(addrName(replyTo))) return true;
|
||||
// 同一封信已被回过:即使收件人写法不同(别名/路径不同)也算回过
|
||||
if (mailID && sent.replyTos.has(String(mailID))) return true;
|
||||
return false;
|
||||
}
|
||||
22
plugins/dsh-mail-bridge/lib/session-snapshot.d.ts
vendored
Normal file
22
plugins/dsh-mail-bridge/lib/session-snapshot.d.ts
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
export interface PlatformSessionReport {
|
||||
platform_id: string;
|
||||
workspace: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
mail_driven: boolean;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export declare const MAX_REPORTED: number;
|
||||
|
||||
export function snapshotOpencodeSessions(
|
||||
sessions: readonly any[],
|
||||
isMailDriven?: (id: string) => boolean
|
||||
): PlatformSessionReport[];
|
||||
|
||||
export function snapshotDshSessions(
|
||||
entries: readonly any[],
|
||||
isMailDriven?: (id: string) => boolean
|
||||
): PlatformSessionReport[];
|
||||
|
||||
export function slugFromTitle(title: string): string;
|
||||
151
plugins/dsh-mail-bridge/lib/session-snapshot.js
Normal file
151
plugins/dsh-mail-bridge/lib/session-snapshot.js
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。
|
||||
*
|
||||
* 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。
|
||||
* Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话
|
||||
* 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。
|
||||
*
|
||||
* 为什么是插件上报而不是 Gateway 拉取:当前架构是单向的(Agent 持密钥主动连
|
||||
* Gateway,Gateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与凭证,
|
||||
* 那是另一套信任模型。
|
||||
*/
|
||||
|
||||
/** 单次上报的会话数上限。与服务端的 maxPlatformSessions 一致。 */
|
||||
export const MAX_REPORTED = 200;
|
||||
|
||||
/**
|
||||
* 把 opencode 的 session 列表整理成上报格式。
|
||||
*
|
||||
* @param {any[]} sessions client.session.list() 的结果
|
||||
* @param {(id: string) => boolean} isMailDriven 该平台会话是否由邮件驱动
|
||||
* @returns {object[]} 按最近活跃排序、截断到 MAX_REPORTED 的上报项
|
||||
*/
|
||||
export function snapshotOpencodeSessions(sessions, isMailDriven = () => false) {
|
||||
const list = Array.isArray(sessions) ? sessions : [];
|
||||
const out = [];
|
||||
for (const s of list) {
|
||||
const id = typeof s?.id === 'string' ? s.id : '';
|
||||
if (!id) continue;
|
||||
// 没有 slug 的会话不报:slug 是填进 session 位的值,
|
||||
// 没有它这一项在补全里点下去只能得到一个空的 session 段。
|
||||
const slug = typeof s?.slug === 'string' ? s.slug : '';
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
platform_id: id,
|
||||
// opencode 的工作目录在 directory 上(path 是项目内的子路径,不是 cwd)
|
||||
workspace: typeof s?.directory === 'string' ? s.directory : '',
|
||||
slug,
|
||||
title: typeof s?.title === 'string' ? s.title : '',
|
||||
mail_driven: Boolean(isMailDriven(id)),
|
||||
updated_at: toISO(s?.time?.updated ?? s?.time?.created),
|
||||
});
|
||||
}
|
||||
return sortAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 DSH 的 agent 列表整理成上报格式。
|
||||
*
|
||||
* DSH 没有 opencode 那样的 slug,别名由**模型生成的会话标题**派生
|
||||
* (与「别名复用平台命名」的既定决策一致)。占位标题不派生别名:
|
||||
* DSH 在模型生成真标题前会先落一个 fallback 标题,内容是用户第一句话的截断,
|
||||
* 而那句话是插件自己拼的提示词。
|
||||
*
|
||||
* @param {any[]} entries [{ id, cwd, title, updatedAt }]
|
||||
* @param {(id: string) => boolean} isMailDriven
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotDshSessions(entries, isMailDriven = () => false) {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
const out = [];
|
||||
for (const e of list) {
|
||||
const id = typeof e?.id === 'string' ? e.id : '';
|
||||
if (!id) continue;
|
||||
// subagent 子会话不上报:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
|
||||
// 而且它们的标题就是派活时的提示词前缀(实测九条会话都叫
|
||||
// "You are auditing ONE file"),派生出的 slug 全都撞名、毫无区分度。
|
||||
if (isSubagent(e)) continue;
|
||||
const title = typeof e?.title === 'string' ? e.title : '';
|
||||
const slug = slugFromTitle(title);
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
platform_id: id,
|
||||
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
|
||||
slug,
|
||||
title,
|
||||
mail_driven: Boolean(isMailDriven(id)),
|
||||
updated_at: toISO(e?.updatedAt),
|
||||
});
|
||||
}
|
||||
// slug 撞名的只留最近那条:别名是**寻址**用的,
|
||||
// 同一个 slug 对应多条会话时服务端只能取其中一条(updated_at DESC LIMIT 1),
|
||||
// 上报一堆同名项只会让人在补全列表里看到几个一模一样、点哪个都不确定的候选。
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
|
||||
function isSubagent(e) {
|
||||
if (e?.origin === 'subagent') return true;
|
||||
const depth = e?.delegationDepth;
|
||||
return typeof depth === 'number' && depth > 0;
|
||||
}
|
||||
|
||||
/** 同 slug 只保留第一条(调用前已按最近活跃排序)。 */
|
||||
function dedupeBySlug(list) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const item of list) {
|
||||
if (seen.has(item.slug)) continue;
|
||||
seen.add(item.slug);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把模型生成的会话标题转成可寻址的 slug。
|
||||
*
|
||||
* 保留中文而不转拼音:标题「缓存层选型评估」转成 huancunceng-xuanxing 之后
|
||||
* 既不好读也不好打,而 AgentMail 的别名校验本来就允许中文(三维地址按最后一个
|
||||
* `.` 切分,中文不影响解析)。
|
||||
*
|
||||
* 处理:空白 → `-`,去掉会干扰寻址的字符(`.` 是 session 位的分隔符,
|
||||
* `@` 是 path 位的分隔符,`/` 会被当成路径),压缩连续 `-`,截断到 48 字符。
|
||||
*
|
||||
* @param {string} title
|
||||
* @returns {string} slug,无法派生时为空串
|
||||
*/
|
||||
export function slugFromTitle(title) {
|
||||
const raw = String(title ?? '').trim();
|
||||
if (!raw) return '';
|
||||
const slug = raw
|
||||
.replace(/[\s\u3000]+/g, '-')
|
||||
// 寻址相关的分隔符必须去掉,否则别名本身会被解析器切开
|
||||
.replace(/[.@/\\:,;'"`?#[\]{}()<>|*!$&=+%^~]/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48)
|
||||
// 截断可能又切出尾部的 -
|
||||
.replace(/-+$/g, '');
|
||||
// 纯符号标题清干净后会剩空串
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** 毫秒时间戳或 ISO 串 → ISO 串;无法解析时返回 undefined。 */
|
||||
function toISO(v) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
if (typeof v === 'string' && v) {
|
||||
const d = new Date(v);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 按最近活跃降序排列并截断。上千条会话对补全列表毫无用处。 */
|
||||
function sortAndCap(list) {
|
||||
return list
|
||||
.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')))
|
||||
.slice(0, MAX_REPORTED);
|
||||
}
|
||||
6
plugins/dsh-mail-bridge/lib/workspace.d.ts
vendored
Normal file
6
plugins/dsh-mail-bridge/lib/workspace.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
export function resolveWorkspaceCwd(
|
||||
workspace: string | undefined,
|
||||
fallbackKey: string
|
||||
): { cwd: string; grouped: boolean };
|
||||
|
||||
export function ensureCwd(cwd: string, grouped: boolean): void;
|
||||
65
plugins/dsh-mail-bridge/lib/workspace.js
Normal file
65
plugins/dsh-mail-bridge/lib/workspace.js
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 邮件寻址里的工作目录(三维地址 name@path.session 的 path 位)。
|
||||
*
|
||||
* 这个模块存在的理由是一次真实故障:插件建会话时用的 cwd 是自己拼的
|
||||
* `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。
|
||||
* DSH 与 opencode 都按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、
|
||||
* 彼此也不同组,界面上全落进「未分组」。
|
||||
*
|
||||
* path 位本来就是「希望它在哪儿干活」,插件只需照用。
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* 把 new_mail 事件里的 to_workspace 解析成一个可用的 cwd。
|
||||
*
|
||||
* 决策顺序:
|
||||
* 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组)
|
||||
* 2. path 位非空但目录不存在 → **不创建**,回退到兜底目录
|
||||
* 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底目录
|
||||
*
|
||||
* 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`)
|
||||
* 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 ——
|
||||
* 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。
|
||||
*
|
||||
* 为什么拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,
|
||||
* 那是个与邮件语义无关的量(systemd 下通常是 `/`)。
|
||||
*
|
||||
* @param {string} workspace 事件里的 to_workspace
|
||||
* @param {string} fallbackKey 兜底目录名(通常是会话 id)
|
||||
* @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里
|
||||
*/
|
||||
export function resolveWorkspaceCwd(workspace, fallbackKey) {
|
||||
const raw = typeof workspace === 'string' ? workspace.trim() : '';
|
||||
const fallback = join(homedir(), '.dsh', 'mail-sessions', String(fallbackKey || 'default'));
|
||||
|
||||
if (!raw || !isAbsolute(raw)) return { cwd: fallback, grouped: false };
|
||||
|
||||
const abs = resolve(raw);
|
||||
try {
|
||||
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
||||
return { cwd: abs, grouped: true };
|
||||
}
|
||||
} catch {
|
||||
// 权限不足等:当作不可用
|
||||
}
|
||||
return { cwd: fallback, grouped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中),
|
||||
* 只有兜底目录需要现建。
|
||||
* @param {string} cwd resolveWorkspaceCwd 的结果
|
||||
* @param {boolean} grouped 是否落在寻址指定的目录里
|
||||
*/
|
||||
export function ensureCwd(cwd, grouped) {
|
||||
if (grouped) return;
|
||||
try {
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
} catch {
|
||||
// 建不出来就让 harness 自己报错,这里不该吞掉真实原因
|
||||
}
|
||||
}
|
||||
102
plugins/dsh-mail-bridge/package-lock.json
generated
Normal file
102
plugins/dsh-mail-bridge/package-lock.json
generated
Normal file
@ -0,0 +1,102 @@
|
||||
{
|
||||
"name": "dsh-mail-bridge",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dsh-mail-bridge",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@deepseek-ai/cordis": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@deepseek-ai/cordis/-/cordis-4.0.2.tgz",
|
||||
"integrity": "sha512-asOnXP1TzFSFQlHb1iegDZp0z/8WD1c7YNrwJR/Tx2bzNuMXfcekE/I67Iv6SQXeLB4csxqCngzQKANP7gdw0g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@deepseek-ai/cosmokit": "^1.8.3",
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"cordis": "bin.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "^1.0.7",
|
||||
"@deepseek-ai/cordis-plugin-loader": "^1.0.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/cordis-plugin-include": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/cordis-plugin-loader": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@deepseek-ai/cosmokit": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.3.tgz",
|
||||
"integrity": "sha512-qBo+ronVM6Eu2WNVJXi8JcMiqZ19T9BRIpV+5qJUFPXjGH/Z0QKcQMC/IZJ7L394YTOtJgcovbk9qP0w2GsBXQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
plugins/dsh-mail-bridge/package.json
Normal file
26
plugins/dsh-mail-bridge/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "dsh-mail-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "DeepSeek Harness plugin: AgentMail 邮件驱动多智能体协作平台桥接",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"verify": "tsc --noEmit",
|
||||
"test": "node --test 'test/*.test.mjs'"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
696
plugins/dsh-mail-bridge/src/index.ts
Normal file
696
plugins/dsh-mail-bridge/src/index.ts
Normal file
@ -0,0 +1,696 @@
|
||||
/**
|
||||
* dsh-mail-bridge — DeepSeek Harness ↔ AgentMail 桥接插件
|
||||
*
|
||||
* 与 opencode-mail-bridge 共享同一套 Gateway API。
|
||||
* DSH 用 Cordis 插件框架(@deepseek-ai/cordis),不是 opencode 的 @opencode-ai/plugin。
|
||||
*
|
||||
* 关键差异:
|
||||
* - opencode: client.session.create() + client.session.promptAsync()
|
||||
* - DSH: ctx.agents.create() + agent.followup()
|
||||
* - opencode: tool 用 zod schema
|
||||
* - DSH: tool 用 defineTool() + 参数 spec 格式
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import {
|
||||
explicitSends,
|
||||
noteExplicitSend,
|
||||
shouldSkipAutoRelay,
|
||||
} from '../lib/relay-dedup.js';
|
||||
import {
|
||||
userMessage,
|
||||
replySubject,
|
||||
lastAssistantText,
|
||||
modelTitle,
|
||||
} from '../lib/message.js';
|
||||
import { snapshotDshSessions, slugFromTitle } from '../lib/session-snapshot.js';
|
||||
import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
|
||||
|
||||
// ─── 凭证管理 ───
|
||||
|
||||
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
|
||||
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
|
||||
|
||||
function readLocalKey(): string | null {
|
||||
try {
|
||||
if (!existsSync(KEY_FILE)) return null;
|
||||
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
|
||||
return typeof raw.key === 'string' ? raw.key : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function saveLocalKey(key: string) {
|
||||
try {
|
||||
mkdirSync(dirname(KEY_FILE), { recursive: true });
|
||||
writeFileSync(KEY_FILE, JSON.stringify({ key }, null, 2), 'utf8');
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function generateLocalKey(): string {
|
||||
const key = 'ak_' + randomBytes(24).toString('hex');
|
||||
saveLocalKey(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
// ─── Gateway HTTP 客户端 ───
|
||||
|
||||
class GatewayClient {
|
||||
baseURL: string;
|
||||
agentName: string;
|
||||
agentKey: string;
|
||||
agentSecret: string;
|
||||
|
||||
constructor(gatewayURL: string, agentName: string, agentKey: string, agentSecret: string) {
|
||||
this.baseURL = gatewayURL.replace(/\/+$/, '');
|
||||
this.agentName = agentName;
|
||||
this.agentKey = agentKey;
|
||||
this.agentSecret = agentSecret;
|
||||
}
|
||||
|
||||
/** 与 opencode-mail-bridge 的 authHeaders() 相同逻辑 */
|
||||
authHeaders(): Record<string, string> {
|
||||
if (this.agentKey) {
|
||||
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
|
||||
}
|
||||
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
|
||||
}
|
||||
|
||||
async post(path: string, body: Record<string, unknown>): Promise<any> {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
if (!res.ok) throw new Error(data?.error || `POST ${path} failed: ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async get(path: string): Promise<any> {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
|
||||
const data = await res.json() as any;
|
||||
if (!res.ok) throw new Error(data?.error || `GET ${path} failed: ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async register(): Promise<void> {
|
||||
await this.post('/agent/register', {
|
||||
name: this.agentName,
|
||||
secret: this.agentSecret || '',
|
||||
workspaces: [],
|
||||
platform: 'dsh',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 会话映射(与 opencode-mail-bridge 相同结构)───
|
||||
|
||||
const sessionMap = new Map<string, { dshSessionId: string; directory: string }>();
|
||||
const reverseMap = new Map<string, string>();
|
||||
const mailDrivenSessions = new Set<string>();
|
||||
const mailContexts = new Map<string, { replyTo: string; subject: string; mailID: string }>();
|
||||
const relayedSummaries = new Map<string, string>();
|
||||
const syncedTitles = new Map<string, string>();
|
||||
|
||||
// 权限询问:DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人,
|
||||
// 人类决策通过 SSE 回来后再 resolve 这个 promise,让 DSH 自己恢复执行。
|
||||
// relay_key 用 `${sessionId}:${toolName}:${callId}` —— DSH 不给询问发 id,
|
||||
// 而同一个 callId 的同一个工具只会问一次。
|
||||
interface PendingApproval {
|
||||
resolve: (outcome: string) => void;
|
||||
sessionId: string;
|
||||
}
|
||||
const pendingApprovals = new Map<string, PendingApproval>();
|
||||
|
||||
// ─── 运行时导入 DSH 内部函数 ───
|
||||
|
||||
let _defineTool: any;
|
||||
function defineTool(opts: any): any {
|
||||
if (!_defineTool) {
|
||||
try {
|
||||
const dshToolsPath = require.resolve('@deepseek-ai/dsh-tools');
|
||||
_defineTool = require(dshToolsPath).defineTool;
|
||||
} catch { return opts; }
|
||||
}
|
||||
return _defineTool(opts);
|
||||
}
|
||||
|
||||
// ─── Cordis 插件入口 ───
|
||||
|
||||
// Cordis 要求插件声明依赖的服务:没有 inject,ctx.tools / ctx.agents 根本不存在
|
||||
// (报 `cannot get property "tools" without inject`)。
|
||||
//
|
||||
// sessionQuery 不列在这里而用 ctx.get('sessionQuery') 取:inject 是硬依赖,
|
||||
// 列进去的服务没挂载时整个插件不会启动 —— 而会话上报只是补全体验,
|
||||
// 不应该能把邮件投递整体拘死。
|
||||
export const inject = ['agents', 'tools'];
|
||||
export const name = 'dsh-mail-bridge';
|
||||
|
||||
interface PluginConfig {
|
||||
gateway: { url: string; agentName: string; agentKey: string; agentSecret: string };
|
||||
reply: { provider?: string; model?: string };
|
||||
}
|
||||
|
||||
export function apply(ctx: any, config: PluginConfig): void {
|
||||
const GW = config.gateway.url || 'http://127.0.0.1:8180';
|
||||
const AGENT_NAME = config.gateway.agentName || 'dsh';
|
||||
let AGENT_KEY = config.gateway.agentKey || '';
|
||||
const AGENT_SECRET = config.gateway.agentSecret || '';
|
||||
const REPLY_PROVIDER = config.reply.provider || '';
|
||||
const REPLY_MODEL = config.reply.model || 'AUTO';
|
||||
|
||||
if (!AGENT_KEY && !AGENT_SECRET) {
|
||||
AGENT_KEY = readLocalKey() || generateLocalKey();
|
||||
}
|
||||
|
||||
const client = new GatewayClient(GW, AGENT_NAME, AGENT_KEY, AGENT_SECRET);
|
||||
|
||||
// 注册 Agent
|
||||
(async () => {
|
||||
try {
|
||||
await client.register();
|
||||
ctx.logger.info(`[dsh-mail-bridge] 已接入 ${GW},身份 ${AGENT_NAME}`);
|
||||
} catch (e: any) {
|
||||
ctx.logger.error(`[dsh-mail-bridge] 注册失败: ${e?.message || e}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// ─── 心跳 + 平台会话上报 ───
|
||||
//
|
||||
// 心跳两个职责:
|
||||
// 1. 保活 —— Gateway 靠 last_seen 判在线,不发心跳就会被当成离线(之前真的没发)
|
||||
// 2. 上报平台侧会话快照 —— 写信时想续谈某条会话,得先知道那个工作区下
|
||||
// 有哪些会话可续;Gateway 只看得见邮件驱动的那部分。
|
||||
//
|
||||
// 上报而不是让 Gateway 反向拉取:当前架构是单向的(Agent 持密钥主动连
|
||||
// Gateway,Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证。
|
||||
|
||||
/**
|
||||
* 收集本机 DSH 的会话快照。
|
||||
*
|
||||
* 优先用 ctx.sessionQuery(包含已落盘的历史会话),它不可用时退到
|
||||
* ctx.agents.list()(只有当前活着的)。base bundle 里 session-query-sqlite 的
|
||||
* openAt 是 'never',但 listSessions/readTitle 这些精确读不依赖 SQLite
|
||||
* —— 只有全文搜索会报 SESSION_QUERY_SEARCH_DISABLED。
|
||||
*
|
||||
* 返回 undefined 表示「本次拿不到列表」,调用方应当省略字段而不是传空数组:
|
||||
* 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。
|
||||
*/
|
||||
async function collectSessions(): Promise<any[] | undefined> {
|
||||
const q = ctx.get('sessionQuery');
|
||||
if (q?.listSessions) {
|
||||
try {
|
||||
const records = await q.listSessions();
|
||||
const ids = records
|
||||
.map((r: any) => r?.header?.id)
|
||||
.filter((id: any): id is string => typeof id === 'string');
|
||||
// 标题在日志里,需要单独 fold。批量读而不是逐个读:
|
||||
// readTitleSnapshots 共用一次 corpus 观测,而逐个 readTitle 会重复加载日志。
|
||||
const titles = new Map<string, string>();
|
||||
if (q.readTitleSnapshots && ids.length > 0) {
|
||||
const results = await q.readTitleSnapshots(ids);
|
||||
results.forEach((res: any, i: number) => {
|
||||
// 单个会话读失败不该拘到其他会话(接口本身就是逐条隔离的)
|
||||
if (res?.status === 'rejected') return;
|
||||
const title = res?.value?.title?.title ?? res?.title?.title;
|
||||
if (typeof title === 'string' && title) titles.set(ids[i], title);
|
||||
});
|
||||
}
|
||||
return records.map((r: any) => ({
|
||||
id: r?.header?.id,
|
||||
cwd: r?.header?.cwd ?? '',
|
||||
title: titles.get(r?.header?.id) ?? '',
|
||||
updatedAt: r?.header?.createdAt,
|
||||
// subagent 子会话要被过滤掉,判据在 header 上
|
||||
origin: r?.header?.origin,
|
||||
delegationDepth: r?.header?.delegationDepth,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
ctx.logger.warn(`[dsh-mail-bridge] sessionQuery 不可用,退到活会话列表: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 退路:只报当前活着的 agent。比什么都不报强 ——
|
||||
// 它们恰好是正在进行的任务,也是最可能被续谈的那批。
|
||||
try {
|
||||
const live = ctx.agents?.list?.() ?? [];
|
||||
return live.map((a: any) => ({
|
||||
id: String(a?.id ?? ''),
|
||||
cwd: a?.session?.header?.cwd ?? '',
|
||||
title: modelTitle(a?.session?.events ?? []),
|
||||
updatedAt: a?.session?.header?.createdAt,
|
||||
origin: a?.session?.header?.origin,
|
||||
delegationDepth: a?.session?.header?.delegationDepth,
|
||||
}));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function beat(): Promise<void> {
|
||||
let body: Record<string, unknown> = {};
|
||||
const entries = await collectSessions();
|
||||
if (entries) {
|
||||
body = {
|
||||
platform_sessions: snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id)),
|
||||
};
|
||||
}
|
||||
try {
|
||||
await client.post('/agent/heartbeat', body);
|
||||
} catch {
|
||||
// 心跳失败不报错:网络抖动很常见,下一轮会补上。
|
||||
// 真的持续连不上时 Gateway 会把它判成离线,那才是可见的信号。
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
void beat();
|
||||
const timer = setInterval(() => { void beat(); }, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, 'dsh-mail-bridge.heartbeat');
|
||||
|
||||
// ─── 获取默认模型 ───
|
||||
|
||||
function modelSelection(): { provider: string; model: string } | undefined {
|
||||
if (REPLY_PROVIDER && REPLY_MODEL) {
|
||||
return { provider: REPLY_PROVIDER, model: REPLY_MODEL };
|
||||
}
|
||||
const defaults = ctx.get('agentDefaultModel');
|
||||
const sel = defaults?.currentSelection?.();
|
||||
if (sel?.provider && sel.model) {
|
||||
return { provider: sel.provider, model: sel.model };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── 投递邮件到 DSH 会话 ───
|
||||
|
||||
async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> {
|
||||
const mailSessionID = data.session_id;
|
||||
const existing = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
|
||||
|
||||
if (existing) {
|
||||
const live = ctx.agents.get(existing.dshSessionId);
|
||||
if (live) {
|
||||
const promptText = kind === 'permission'
|
||||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
|
||||
: [
|
||||
`本会话收到一封新邮件(AgentMail 续谈)。`,
|
||||
``,
|
||||
`发件人:${data.from_name || 'unknown'}`,
|
||||
`主题:${data.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data.mail_id || 'unknown'}`,
|
||||
``,
|
||||
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
|
||||
`回信不用你自己发:把这一轮做完、把结论说出来就行。`,
|
||||
].join('\n');
|
||||
live.followup(userMessage(promptText));
|
||||
return { sessionID: existing.dshSessionId, reused: true };
|
||||
}
|
||||
}
|
||||
|
||||
// 新开会话
|
||||
const sessionId = `mail-${mailSessionID || Date.now()}`;
|
||||
// cwd 取寻址里的 path 位(to_workspace)。
|
||||
//
|
||||
// 之前这里硬拼 `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。
|
||||
// DSH 按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、彼此也不同组,
|
||||
// 界面上全落进「未分组」。path 位本来就是「希望它在哪儿干活」。
|
||||
const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, sessionId);
|
||||
ensureCwd(cwd, grouped);
|
||||
if (!grouped && data.to_workspace) {
|
||||
ctx.logger.warn(
|
||||
`[dsh-mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
|
||||
}
|
||||
|
||||
const selection = modelSelection();
|
||||
const agentOpts = selection
|
||||
? { provider: selection.provider, model: selection.model }
|
||||
: {};
|
||||
|
||||
const promptText = kind === 'permission'
|
||||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续。`
|
||||
: [
|
||||
`你收到一封新邮件(AgentMail)。`,
|
||||
``,
|
||||
`发件人:${data.from_name || 'unknown'}`,
|
||||
`主题:${data.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data.mail_id || 'unknown'}`,
|
||||
`身份:你是 ${AGENT_NAME}`,
|
||||
``,
|
||||
`请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`,
|
||||
``,
|
||||
`**回信不用你自己发**:你把本轮工作做完、把结论正常说出来就行,`,
|
||||
`插件会在这一轮结束时自动把你最后那段话作为回信发回给 ${data.from_name || '发件人'}(不消耗你的发信配额)。`,
|
||||
`只有在需要主动联系其他人、或要带附件时才调用 send_mail。`,
|
||||
].join('\n');
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId,
|
||||
meta: { cwd },
|
||||
agentOptions: agentOpts,
|
||||
// setup 留空:DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
|
||||
// modelSelection 通过 agentOptions 传入即可 —— 挂载 preset 或
|
||||
// installModelSelection 反而会让 turn 崩溃(实测)。
|
||||
setup: undefined,
|
||||
});
|
||||
|
||||
if (mailSessionID) {
|
||||
sessionMap.set(mailSessionID, { dshSessionId: sessionId, directory: cwd });
|
||||
reverseMap.set(sessionId, mailSessionID);
|
||||
mailDrivenSessions.add(sessionId);
|
||||
mailContexts.set(mailSessionID, {
|
||||
replyTo: data.from_name || '',
|
||||
subject: data.subject || '',
|
||||
mailID: data.mail_id || '',
|
||||
});
|
||||
}
|
||||
|
||||
handle.agent.followup(userMessage(promptText));
|
||||
return { sessionID: sessionId, reused: false };
|
||||
}
|
||||
|
||||
// ─── SSE 监听(与 opencode-mail-bridge 相同的 fetch + reader 模式)───
|
||||
|
||||
let sseAbort: AbortController | null = null;
|
||||
|
||||
function startSSE(onEvent: (type: string, data: any) => void) {
|
||||
sseAbort?.abort();
|
||||
sseAbort = new AbortController();
|
||||
|
||||
const reconnect = () => {
|
||||
if (sseAbort?.signal.aborted) return;
|
||||
|
||||
fetch(`${GW}/api/v1/events/stream`, {
|
||||
headers: client.authHeaders(),
|
||||
signal: sseAbort?.signal ?? new AbortController().signal,
|
||||
}).then((res) => {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return;
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
const read = () => {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) { setTimeout(reconnect, 3000); return; }
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() || '';
|
||||
let evt = '', data = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) evt = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) data = line.slice(6);
|
||||
else if (line === '' && evt) {
|
||||
try { onEvent(evt, JSON.parse(data)); } catch {}
|
||||
evt = ''; data = '';
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch(() => setTimeout(reconnect, 5000));
|
||||
};
|
||||
read();
|
||||
}).catch(() => setTimeout(reconnect, 5000));
|
||||
};
|
||||
|
||||
reconnect();
|
||||
}
|
||||
|
||||
// ─── 注册模型工具 ───
|
||||
|
||||
ctx.effect(() => {
|
||||
// send_mail
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'send_mail',
|
||||
description: '发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
|
||||
parameters: {
|
||||
to: { type: 'string', required: true, description: '收件人三维地址' },
|
||||
subject: { type: 'string', required: true, description: '邮件主题' },
|
||||
body: { type: 'string', required: true, description: '邮件正文(Markdown)' },
|
||||
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
|
||||
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
|
||||
session_alias: { type: 'string', description: '给新会话命名' },
|
||||
attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any, toolCtx: any): Promise<string> {
|
||||
const result = await client.post('/mail/send', {
|
||||
to: args.to, subject: args.subject, body: args.body,
|
||||
cc: args.cc || '', reply_to: args.reply_to || '',
|
||||
session_alias: args.session_alias || '',
|
||||
attachment_ids: args.attachment_ids || [],
|
||||
});
|
||||
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);
|
||||
const budget = typeof result.budget_remaining === 'number'
|
||||
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : '';
|
||||
return `邮件已发送(ID: ${result.mail_id})${budget}`;
|
||||
},
|
||||
}));
|
||||
|
||||
// read_inbox
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read_inbox',
|
||||
description: '读取收件箱邮件列表。返回最新的邮件,每封含 mail_id、发件人、主题、正文、附件清单。',
|
||||
parameters: {
|
||||
status: { type: 'string', description: '过滤状态(all/unread/read)' },
|
||||
limit: { type: 'number', description: '返回数量上限' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
const { mails } = await client.get(
|
||||
`/mail/inbox?status=${args.status || 'all'}&limit=${args.limit || 20}`
|
||||
);
|
||||
if (!mails?.length) return '收件箱为空。';
|
||||
return mails.map((m: any) => {
|
||||
const att = m.attachments?.length
|
||||
? ` [附件: ${m.attachments.map((a: any) => a.filename).join(', ')}]` : '';
|
||||
return `- ID: ${m.mail_id} | ${m.from_name} | ${m.subject}${att}\n ${m.body.slice(0, 200)}`;
|
||||
}).join('\n');
|
||||
},
|
||||
}));
|
||||
|
||||
// upload_attachment
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'upload_attachment',
|
||||
description: '上传本地文件作为邮件附件,返回 attachment_id。',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: '本地文件路径' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
const data = await readFile(args.file_path);
|
||||
const filename = args.file_path.split('/').pop() || 'file';
|
||||
const res = await fetch(`${client.baseURL}/api/v1/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { ...client.authHeaders(), 'Content-Type': 'application/octet-stream', 'X-Filename': filename },
|
||||
body: data,
|
||||
});
|
||||
const json = await res.json() as any;
|
||||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
const a = json.attachment;
|
||||
return `已上传 ${a.filename}(${a.size_bytes} 字节)。attachment_id: ${a.attachment_id}`;
|
||||
},
|
||||
}));
|
||||
|
||||
// download_attachment
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'download_attachment',
|
||||
description: '下载邮件附件到本地文件。',
|
||||
parameters: {
|
||||
attachment_id: { type: 'string', required: true, description: '附件 ID' },
|
||||
save_path: { type: 'string', required: true, description: '保存路径' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
const res = await fetch(`${client.baseURL}/api/v1/attachments/${args.attachment_id}`, {
|
||||
headers: client.authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(args.save_path, buf);
|
||||
return `已保存到 ${args.save_path}(${buf.length} 字节)`;
|
||||
},
|
||||
}));
|
||||
|
||||
return () => {
|
||||
for (const n of ['send_mail', 'read_inbox', 'upload_attachment', 'download_attachment']) {
|
||||
try { ctx.tools.unregister(n); } catch {}
|
||||
}
|
||||
};
|
||||
}, 'dsh-mail-bridge.tools');
|
||||
|
||||
// ─── turn 完成后自动转发回复 ───
|
||||
//
|
||||
// 与 opencode-mail-bridge 的 session.idle 同一职责:模型把话说完了,
|
||||
// 插件把它最后那段话搬到邮件里 —— 不该让模型自己记得调 send_mail。
|
||||
ctx.on('agent/status', async (payload: any) => {
|
||||
if (payload?.status !== 'idle') return;
|
||||
const agent = payload.agent;
|
||||
if (!agent?.id) return;
|
||||
const mailSessionID = reverseMap.get(String(agent.id));
|
||||
if (!mailSessionID) return;
|
||||
const mctx = mailContexts.get(mailSessionID);
|
||||
if (!mctx?.replyTo) return;
|
||||
|
||||
// 取最后一条 assistant 消息的文本
|
||||
const events = agent.session?.events ?? [];
|
||||
const lastText = lastAssistantText(events);
|
||||
if (!lastText) return;
|
||||
|
||||
// 本轮模型已亲手回过这条线索 → 不再自动转发(与 opencode 侧同一取舍)
|
||||
if (shouldSkipAutoRelay(explicitSends.get(String(agent.id)), mctx.replyTo, mctx.mailID)) {
|
||||
explicitSends.delete(String(agent.id));
|
||||
return;
|
||||
}
|
||||
// 同一条消息只转一次
|
||||
if (relayedSummaries.get(String(agent.id)) === lastText) return;
|
||||
|
||||
try {
|
||||
await client.post('/mail/send', {
|
||||
to: mctx.replyTo,
|
||||
subject: replySubject(mctx.subject),
|
||||
body: lastText,
|
||||
reply_to: mctx.mailID || '',
|
||||
// relay + relay_key:走免配额通道(harness 的搬运不该收费)
|
||||
relay: 'summary',
|
||||
relay_key: `${agent.id}:${events.length}`,
|
||||
});
|
||||
relayedSummaries.set(String(agent.id), lastText);
|
||||
explicitSends.delete(String(agent.id));
|
||||
ctx.logger.info(`[dsh-mail-bridge] 总结已回信 ${mctx.replyTo}(不计配额)`);
|
||||
} catch (e: any) {
|
||||
ctx.logger.error(`[dsh-mail-bridge] 转发回复失败: ${e?.message || e}`);
|
||||
}
|
||||
|
||||
// 会话命名回写:DSH 首轮结束后由模型生成摘要标题,把它同步回 AgentMail。
|
||||
// 与 opencode 侧同一决定:不另造一套命名,平台叫什么这边就叫什么。
|
||||
//
|
||||
// DSH 没有 opencode 那样的 slug,别名由标题派生(slugFromTitle 会去掉
|
||||
// `.` `@` `/` 这些寻址分隔符 —— 留在别名里会让它自己被解析器切开)。
|
||||
// 别名与标题一起发:服务端撞名时自动追 -2/-3 后缀,并尊重人工改过的别名。
|
||||
const title = modelTitle(events);
|
||||
if (title && syncedTitles.get(String(agent.id)) !== title) {
|
||||
syncedTitles.set(String(agent.id), title);
|
||||
const alias = slugFromTitle(title);
|
||||
try {
|
||||
await client.post(`/sessions/${mailSessionID}/sync`,
|
||||
alias ? { title, alias } : { title });
|
||||
ctx.logger.info(
|
||||
`[dsh-mail-bridge] 会话命名已同步: title=${title}${alias ? ` alias=${alias}` : ''}`);
|
||||
} catch (e: any) {
|
||||
ctx.logger.error(`[dsh-mail-bridge] 命名同步失败: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 平台原生权限询问 → 转成邮件问人 ───
|
||||
//
|
||||
// 这是 harness 的职责,不该让模型自己调一个 request_permission 工具:
|
||||
// 模型可能忘了调,也可能在不需要时乱调,而真正被 DSH 拦下的那次询问反而没人看见。
|
||||
//
|
||||
// 与 opencode 的差异:opencode 的 permission.ask 是同步钩子,卡在里面会把整个
|
||||
// 请求挂住,所以那边只能“转出去就返回 ask”;而 DSH 的 approval/request 是
|
||||
// **异步 waterfall**,返回 Promise<ApprovalOutcome> 就是它要的,因此可以真的等人。
|
||||
ctx.on('approval/request', async (req: any, next: () => Promise<string>) => {
|
||||
const agentId = String(req?.agent?.id ?? '');
|
||||
if (!mailDrivenSessions.has(agentId)) return next(); // 非邮件驱动的会话不接管
|
||||
const mailSessionID = reverseMap.get(agentId);
|
||||
if (!mailSessionID) return next();
|
||||
|
||||
// DSH 不给询问发 id,用 (会话, 工具, callId) 做幂等键。
|
||||
const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`;
|
||||
|
||||
try {
|
||||
await client.post('/permission/request', {
|
||||
question: `请求执行 ${req.toolName}`,
|
||||
options: ['同意', '拒绕'],
|
||||
context: [
|
||||
`工具:${req.toolName}`,
|
||||
req.callId ? `调用 ID:${req.callId}` : '',
|
||||
req.reason ? `理由:${req.reason}` : '',
|
||||
].filter(Boolean).join('\n'),
|
||||
session_id: mailSessionID,
|
||||
relay_key: relayKey,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// 转不出去就别把 DSH 挂在那儿等:交给下一个 answerer(本地 UI)接管。
|
||||
ctx.logger.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`);
|
||||
return next();
|
||||
}
|
||||
|
||||
ctx.logger.info(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`);
|
||||
|
||||
// 等人类决策;DSH 撤销询问(signal abort)时结算为 cancelled。
|
||||
return new Promise<string>((resolve) => {
|
||||
pendingApprovals.set(relayKey, { resolve, sessionId: agentId });
|
||||
req.signal?.addEventListener('abort', () => {
|
||||
if (pendingApprovals.delete(relayKey)) resolve('cancelled');
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
/** 人类决策回来:先看是不是在等的那条 approval,否则当普通通知投给会话。 */
|
||||
function handlePermissionDecision(data: any): void {
|
||||
const relayKey = String(data?.relay_key ?? '');
|
||||
const pending = relayKey ? pendingApprovals.get(relayKey) : undefined;
|
||||
if (pending) {
|
||||
pendingApprovals.delete(relayKey);
|
||||
// AgentMail 的选项文本 → DSH 的 ApprovalOutcome。
|
||||
// 只有“同意”才放行,其余(包括认不出的选项)一律 fail closed。
|
||||
const decision = String(data?.decision ?? '');
|
||||
const outcome = /^(同意|allow|approve|yes)/i.test(decision) ? 'allowed-once' : 'rejected';
|
||||
pending.resolve(outcome);
|
||||
ctx.logger.info(`[dsh-mail-bridge] 权限决策 ${relayKey} -> ${outcome}`);
|
||||
return;
|
||||
}
|
||||
// 没在等(插件重启后丢了 pendingApprovals,或历史数据)→ 当一封通知投进会话。
|
||||
deliverMail(data, 'permission')
|
||||
.catch((e: any) => ctx.logger.error(`[dsh-mail-bridge] 权限决策投递失败: ${e?.message || e}`));
|
||||
}
|
||||
|
||||
// ─── 启动 SSE(与 opencode-mail-bridge 相同的事件处理)───
|
||||
|
||||
ctx.effect(() => {
|
||||
startSSE((type, data) => {
|
||||
switch (type) {
|
||||
case 'new_mail':
|
||||
deliverMail(data, 'mail')
|
||||
.then(({ sessionID, reused }) => {
|
||||
console.error(`[dsh-mail-bridge] ${type} -> ${reused ? '续谈' : '新会话'} ${sessionID}`);
|
||||
})
|
||||
.catch((e: any) => {
|
||||
ctx.logger.error(`[dsh-mail-bridge] ${type} 处理失败: ${e?.message || e}`);
|
||||
});
|
||||
break;
|
||||
case 'permission_decision':
|
||||
handlePermissionDecision(data);
|
||||
break;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
sseAbort?.abort();
|
||||
sseAbort = null;
|
||||
// 拆插件时没人再能回答待决询问,一律 fail closed,
|
||||
// 否则 DSH 侧那些 await 永远不会返回。
|
||||
for (const [key, pending] of pendingApprovals) {
|
||||
pending.resolve('unavailable');
|
||||
pendingApprovals.delete(key);
|
||||
}
|
||||
};
|
||||
}, 'dsh-mail-bridge.sse');
|
||||
}
|
||||
179
plugins/dsh-mail-bridge/test/message.test.mjs
Normal file
179
plugins/dsh-mail-bridge/test/message.test.mjs
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* dsh-mail-bridge 纯函数测试。
|
||||
*
|
||||
* 重点不是覆盖率,而是钉住几条「错了不当场报错」的约定:
|
||||
* 1. userMessage() 的形状 —— 传错会在 agent-loop 深处抛一个不指向调用点的
|
||||
* `Cannot read properties of undefined (reading 'kind')`,实测卡了一下午
|
||||
* 2. modelTitle() 必须拒绝 fallback 占位标题 —— 否则会把插件自己的提示词
|
||||
* 「你收到一封新邮件(AgentMail)」当成会话标题回写给 AgentMail
|
||||
* 3. lastAssistantText() 只取 text 块 —— reasoning 是思考过程,不该进邮件
|
||||
*
|
||||
* node --test test/
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
userMessage,
|
||||
stripRe,
|
||||
replySubject,
|
||||
lastAssistantText,
|
||||
modelTitle,
|
||||
} from '../lib/message.js';
|
||||
|
||||
// ─── userMessage:DSH followup() 的唯一合法形状 ───
|
||||
|
||||
test('userMessage 产出 content + source 两个字段', () => {
|
||||
const m = userMessage('你好');
|
||||
assert.deepEqual(m, {
|
||||
content: [{ type: 'text', text: '你好' }],
|
||||
source: { kind: 'user' },
|
||||
});
|
||||
});
|
||||
|
||||
test('不变量:userMessage 必须带 source.kind —— agent-loop 的 preStep 直接读它', () => {
|
||||
// 这一条是本文件存在的理由。少了 source.kind,DSH 抛的错落在 agent-loop 内部
|
||||
// (`Cannot read properties of undefined (reading 'kind')`),既不指向调用点,
|
||||
// 也不说是哪个字段,turn 会一 start 就 end、模型请求根本不发出去。
|
||||
for (const text of ['x', '', '多行\n文本', '🙂']) {
|
||||
const m = userMessage(text);
|
||||
assert.equal(typeof m.source?.kind, 'string', 'source.kind 必须是字符串');
|
||||
assert.equal(m.source.kind, 'user');
|
||||
assert.ok(Array.isArray(m.content), 'content 必须是数组');
|
||||
}
|
||||
});
|
||||
|
||||
test('不变量:userMessage 返回的不是裸数组(opencode 的 parts 形状)', () => {
|
||||
// opencode 的 promptAsync 收 parts 数组,DSH 收完整 UserMessage。
|
||||
// 把 opencode 的写法照抄过来正是那次故障的起因。
|
||||
const m = userMessage('x');
|
||||
assert.ok(!Array.isArray(m), 'followup() 不接受裸数组');
|
||||
});
|
||||
|
||||
test('userMessage 把非字符串转成字符串', () => {
|
||||
assert.equal(userMessage(42).content[0].text, '42');
|
||||
});
|
||||
|
||||
// ─── stripRe / replySubject ───
|
||||
|
||||
test('stripRe 去掉单个与叠加的 Re: 前缀', () => {
|
||||
assert.equal(stripRe('Re: 主题'), '主题');
|
||||
assert.equal(stripRe('Re: Re: Re: 主题'), '主题');
|
||||
assert.equal(stripRe('RE: 主题'), '主题');
|
||||
assert.equal(stripRe('主题'), '主题');
|
||||
});
|
||||
|
||||
test('stripRe 不动正文里的 Re:', () => {
|
||||
assert.equal(stripRe('关于 Re: 这个写法'), '关于 Re: 这个写法');
|
||||
});
|
||||
|
||||
test('replySubject 只加一层 Re:', () => {
|
||||
assert.equal(replySubject('缓存选型'), 'Re: 缓存选型');
|
||||
assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型');
|
||||
assert.equal(replySubject('Re: Re: 缓存选型'), 'Re: 缓存选型');
|
||||
});
|
||||
|
||||
test('replySubject 空主题走兜底而不是产出裸 "Re: "', () => {
|
||||
assert.equal(replySubject(''), 'DSH 回复');
|
||||
assert.equal(replySubject(' '), 'DSH 回复');
|
||||
assert.equal(replySubject(undefined), 'DSH 回复');
|
||||
assert.equal(replySubject('', '自定义'), '自定义');
|
||||
});
|
||||
|
||||
// ─── lastAssistantText ───
|
||||
|
||||
const assistantMsg = (blocks) => ({
|
||||
type: 'assistant/message',
|
||||
data: { message: { role: 'assistant', content: blocks } },
|
||||
});
|
||||
|
||||
test('lastAssistantText 取最后一条 assistant 消息', () => {
|
||||
const events = [
|
||||
assistantMsg([{ type: 'text', text: '第一轮' }]),
|
||||
{ type: 'tool/call', data: {} },
|
||||
assistantMsg([{ type: 'text', text: '第二轮' }]),
|
||||
];
|
||||
assert.equal(lastAssistantText(events), '第二轮');
|
||||
});
|
||||
|
||||
test('lastAssistantText 丢掉 reasoning 块', () => {
|
||||
const events = [assistantMsg([
|
||||
{ type: 'reasoning', text: '让我想想……用户要的是' },
|
||||
{ type: 'text', text: '结论:可以。' },
|
||||
])];
|
||||
assert.equal(lastAssistantText(events), '结论:可以。');
|
||||
});
|
||||
|
||||
test('lastAssistantText 拼接多个 text 块', () => {
|
||||
const events = [assistantMsg([
|
||||
{ type: 'text', text: '第一段' },
|
||||
{ type: 'text', text: '第二段' },
|
||||
])];
|
||||
assert.equal(lastAssistantText(events), '第一段\n第二段');
|
||||
});
|
||||
|
||||
test('lastAssistantText 只有 tool-call 时返回空串(没有可回信的内容)', () => {
|
||||
const events = [assistantMsg([
|
||||
{ type: 'reasoning', text: '先读收件箱' },
|
||||
{ type: 'tool-call', id: 'c1', name: 'read_inbox' },
|
||||
])];
|
||||
assert.equal(lastAssistantText(events), '');
|
||||
});
|
||||
|
||||
test('lastAssistantText 容错:空日志、非数组、结构缺失', () => {
|
||||
assert.equal(lastAssistantText([]), '');
|
||||
assert.equal(lastAssistantText(undefined), '');
|
||||
assert.equal(lastAssistantText(null), '');
|
||||
assert.equal(lastAssistantText([{ type: 'assistant/message' }]), '');
|
||||
assert.equal(lastAssistantText([{ type: 'assistant/message', data: {} }]), '');
|
||||
});
|
||||
|
||||
test('lastAssistantText 跳过非 assistant/message 事件', () => {
|
||||
const events = [
|
||||
assistantMsg([{ type: 'text', text: '正文' }]),
|
||||
{ type: 'step/end', data: {} },
|
||||
{ type: 'turn/end', data: {} },
|
||||
];
|
||||
assert.equal(lastAssistantText(events), '正文');
|
||||
});
|
||||
|
||||
// ─── modelTitle ───
|
||||
|
||||
const titleEvent = (title, kind) => ({
|
||||
type: 'session/title',
|
||||
data: { title, source: { kind } },
|
||||
});
|
||||
|
||||
test('modelTitle 取模型生成的标题', () => {
|
||||
const events = [titleEvent('处理新邮件任务并回复', 'provider')];
|
||||
assert.equal(modelTitle(events), '处理新邮件任务并回复');
|
||||
});
|
||||
|
||||
test('不变量:modelTitle 拒绝 fallback 占位标题', () => {
|
||||
// DSH 在模型生成真标题之前会先落一个 fallback 标题,内容是用户第一句话的截断。
|
||||
// 而「用户第一句话」是插件自己拼的提示词,回写过去等于把
|
||||
// 「你收到一封新邮件(AgentMail)」当成会话标题。
|
||||
const events = [titleEvent('你收到一封新邮件(AgentMail)', 'fallback')];
|
||||
assert.equal(modelTitle(events), '');
|
||||
});
|
||||
|
||||
test('modelTitle 取最后一次 session/title —— fallback 之后的 provider 标题算', () => {
|
||||
const events = [
|
||||
titleEvent('你收到一封新邮件(AgentMail)', 'fallback'),
|
||||
{ type: 'assistant/chunk', data: {} },
|
||||
titleEvent('缓存层选型评估邮件回复', 'provider'),
|
||||
];
|
||||
assert.equal(modelTitle(events), '缓存层选型评估邮件回复');
|
||||
});
|
||||
|
||||
test('modelTitle 容错:无标题事件、结构缺失、非字符串', () => {
|
||||
assert.equal(modelTitle([]), '');
|
||||
assert.equal(modelTitle(undefined), '');
|
||||
assert.equal(modelTitle([{ type: 'session/title' }]), '');
|
||||
assert.equal(modelTitle([{ type: 'session/title', data: {} }]), '');
|
||||
assert.equal(modelTitle([{ type: 'session/title', data: { title: 42 } }]), '');
|
||||
});
|
||||
|
||||
test('modelTitle 修掉标题两端空白', () => {
|
||||
assert.equal(modelTitle([titleEvent(' 带空白的标题 ', 'provider')]), '带空白的标题');
|
||||
});
|
||||
228
plugins/dsh-mail-bridge/test/session-snapshot.test.mjs
Normal file
228
plugins/dsh-mail-bridge/test/session-snapshot.test.mjs
Normal file
@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 平台会话快照的纯函数测试。
|
||||
*
|
||||
* 这些函数的产出直接决定「写信时能不能选到某条会话」:slug 错了就填出一个
|
||||
* 送不到的 session 位(三态语义下会 404),workspace 错了就归到别的工作区去。
|
||||
*
|
||||
* node --test test/
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeSessions,
|
||||
snapshotDshSessions,
|
||||
slugFromTitle,
|
||||
MAX_REPORTED,
|
||||
} from '../lib/session-snapshot.js';
|
||||
|
||||
// ─── opencode ───
|
||||
|
||||
const ocSession = (over = {}) => ({
|
||||
id: 'ses_abc',
|
||||
slug: 'witty-planet',
|
||||
title: '重构导入路径',
|
||||
directory: '/home/program/agentmail',
|
||||
path: '',
|
||||
time: { created: 1788300000000, updated: 1788344476744 },
|
||||
...over,
|
||||
});
|
||||
|
||||
test('opencode 快照取 directory 作为 workspace', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.workspace, '/home/program/agentmail');
|
||||
});
|
||||
|
||||
test('不变量:workspace 取 directory 而不是 path', () => {
|
||||
// opencode 的 path 是项目内的子路径(通常是空串),cwd 在 directory 上。
|
||||
// 取错的后果是所有会话的 workspace 都变成空串,一条都匹配不上。
|
||||
const [got] = snapshotOpencodeSessions([
|
||||
ocSession({ directory: '/home/real/cwd', path: 'src/sub' }),
|
||||
]);
|
||||
assert.equal(got.workspace, '/home/real/cwd');
|
||||
});
|
||||
|
||||
test('opencode 快照带出 slug 与标题', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.slug, 'witty-planet');
|
||||
assert.equal(got.title, '重构导入路径');
|
||||
assert.equal(got.platform_id, 'ses_abc');
|
||||
});
|
||||
|
||||
test('不变量:无 slug 的会话不上报', () => {
|
||||
// slug 是填进 session 位的值。没有它,这一项在补全里点下去
|
||||
// 只能得到 `name@path.` —— 一个空的 session 段。
|
||||
const got = snapshotOpencodeSessions([
|
||||
ocSession({ id: 'a', slug: '' }),
|
||||
ocSession({ id: 'b', slug: undefined }),
|
||||
ocSession({ id: 'c', slug: 'good-name' }),
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].slug, 'good-name');
|
||||
});
|
||||
|
||||
test('无 id 的条目被跳过', () => {
|
||||
const got = snapshotOpencodeSessions([ocSession({ id: '' }), ocSession({ id: undefined })]);
|
||||
assert.equal(got.length, 0);
|
||||
});
|
||||
|
||||
test('mail_driven 由回调判定', () => {
|
||||
const got = snapshotOpencodeSessions(
|
||||
[ocSession({ id: 'driven' }), ocSession({ id: 'manual' })],
|
||||
id => id === 'driven'
|
||||
);
|
||||
assert.equal(got.find(s => s.platform_id === 'driven').mail_driven, true);
|
||||
assert.equal(got.find(s => s.platform_id === 'manual').mail_driven, false);
|
||||
});
|
||||
|
||||
test('updated_at 由毫秒时间戳转 ISO', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
|
||||
});
|
||||
|
||||
test('没有 updated 时退回 created', () => {
|
||||
const [got] = snapshotOpencodeSessions([
|
||||
ocSession({ time: { created: 1788300000000 } }),
|
||||
]);
|
||||
assert.equal(got.updated_at, new Date(1788300000000).toISOString());
|
||||
});
|
||||
|
||||
test('时间完全缺失时 updated_at 为 undefined 而不是崩', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession({ time: undefined })]);
|
||||
assert.equal(got.updated_at, undefined);
|
||||
});
|
||||
|
||||
test('按最近活跃降序排列', () => {
|
||||
const got = snapshotOpencodeSessions([
|
||||
ocSession({ id: 'old', slug: 'old', time: { updated: 1000 } }),
|
||||
ocSession({ id: 'new', slug: 'new', time: { updated: 9000 } }),
|
||||
ocSession({ id: 'mid', slug: 'mid', time: { updated: 5000 } }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
test('截断到 MAX_REPORTED', () => {
|
||||
const many = Array.from({ length: MAX_REPORTED + 50 }, (_, i) =>
|
||||
ocSession({ id: `s${i}`, slug: `slug-${i}`, time: { updated: i } })
|
||||
);
|
||||
assert.equal(snapshotOpencodeSessions(many).length, MAX_REPORTED);
|
||||
});
|
||||
|
||||
test('非数组输入不崩', () => {
|
||||
assert.deepEqual(snapshotOpencodeSessions(undefined), []);
|
||||
assert.deepEqual(snapshotOpencodeSessions(null), []);
|
||||
assert.deepEqual(snapshotOpencodeSessions({}), []);
|
||||
});
|
||||
|
||||
// ─── DSH ───
|
||||
|
||||
test('DSH 快照从标题派生 slug', () => {
|
||||
const [got] = snapshotDshSessions([
|
||||
{ id: 'mail-1', cwd: '/home/x', title: '缓存层选型评估', updatedAt: 1788344476744 },
|
||||
]);
|
||||
assert.equal(got.slug, '缓存层选型评估');
|
||||
assert.equal(got.title, '缓存层选型评估');
|
||||
assert.equal(got.workspace, '/home/x');
|
||||
});
|
||||
|
||||
test('DSH 无标题时不上报(派生不出别名)', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'a', cwd: '/home/x', title: '' },
|
||||
{ id: 'b', cwd: '/home/x' },
|
||||
]);
|
||||
assert.equal(got.length, 0);
|
||||
});
|
||||
|
||||
// ─── slugFromTitle ───
|
||||
|
||||
test('slugFromTitle 空白转连字符', () => {
|
||||
assert.equal(slugFromTitle('处理新邮件 任务'), '处理新邮件-任务');
|
||||
assert.equal(slugFromTitle('a b c'), 'a-b-c');
|
||||
});
|
||||
|
||||
test('不变量:slug 不含寻址分隔符', () => {
|
||||
// `.` 是 session 位的分隔符、`@` 是 path 位的分隔符。留在 slug 里
|
||||
// 会让别名自己被解析器切开 —— 填进去的地址会指向一个完全不同的目标。
|
||||
const slug = slugFromTitle('修 a.b@c/d 的问题');
|
||||
for (const ch of ['.', '@', '/', '\\', ':']) {
|
||||
assert.ok(!slug.includes(ch), `slug 里不该有 ${ch}:${slug}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('slugFromTitle 压缩连续连字符', () => {
|
||||
assert.equal(slugFromTitle('a...b'), 'ab');
|
||||
assert.equal(slugFromTitle('a - b'), 'a-b');
|
||||
});
|
||||
|
||||
test('slugFromTitle 去掉首尾连字符', () => {
|
||||
assert.equal(slugFromTitle(' 中间 '), '中间');
|
||||
assert.equal(slugFromTitle('--x--'), 'x');
|
||||
});
|
||||
|
||||
test('slugFromTitle 截断到 48 字符且不留尾部连字符', () => {
|
||||
const long = 'a'.repeat(60);
|
||||
assert.equal(slugFromTitle(long).length, 48);
|
||||
// 第 48 个字符正好落在空格上时,截断后不该留下尾部 -
|
||||
const tricky = `${'b'.repeat(47)} tail`;
|
||||
const slug = slugFromTitle(tricky);
|
||||
assert.ok(!slug.endsWith('-'), `尾部残留连字符:${slug}`);
|
||||
});
|
||||
|
||||
test('slugFromTitle 保留中文', () => {
|
||||
// 不转拼音:huancunceng-xuanxing 既不好读也不好打,
|
||||
// 而三维地址按最后一个 . 切分,中文不影响解析。
|
||||
assert.equal(slugFromTitle('缓存选型'), '缓存选型');
|
||||
});
|
||||
|
||||
test('slugFromTitle 纯符号标题返回空串', () => {
|
||||
assert.equal(slugFromTitle('...'), '');
|
||||
assert.equal(slugFromTitle('@@@'), '');
|
||||
assert.equal(slugFromTitle(' '), '');
|
||||
});
|
||||
|
||||
test('slugFromTitle 容错非字符串', () => {
|
||||
assert.equal(slugFromTitle(undefined), '');
|
||||
assert.equal(slugFromTitle(null), '');
|
||||
assert.equal(slugFromTitle(42), '42');
|
||||
});
|
||||
|
||||
// ─── DSH:subagent 过滤与 slug 去重 ───
|
||||
|
||||
test('不变量:subagent 子会话不上报(origin 判据)', () => {
|
||||
// 它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'child', cwd: '/w', title: 'You are auditing ONE file', origin: 'subagent' },
|
||||
{ id: 'top', cwd: '/w', title: '正常会话' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'top');
|
||||
});
|
||||
|
||||
test('不变量:subagent 子会话不上报(delegationDepth 判据)', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'child', cwd: '/w', title: '子任务', delegationDepth: 1 },
|
||||
{ id: 'top', cwd: '/w', title: '顶层', delegationDepth: 0 },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'top');
|
||||
});
|
||||
|
||||
test('不变量:slug 撞名只留最近那条', () => {
|
||||
// 别名是寻址用的:同一个 slug 对应多条会话时服务端只能取其中一条,
|
||||
// 上报一堆同名项只会让补全列表里出现几个点哪个都不确定的候选。
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'old', cwd: '/w', title: '同一个标题', updatedAt: 1000 },
|
||||
{ id: 'new', cwd: '/w', title: '同一个标题', updatedAt: 9000 },
|
||||
{ id: 'mid', cwd: '/w', title: '同一个标题', updatedAt: 5000 },
|
||||
]);
|
||||
assert.equal(got.length, 1, `应去重到 1 条,实际 ${got.length}`);
|
||||
assert.equal(got[0].platform_id, 'new', '应保留最近活跃的那条');
|
||||
});
|
||||
|
||||
test('不同标题不受去重影响', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'a', cwd: '/w', title: '标题一', updatedAt: 2000 },
|
||||
{ id: 'b', cwd: '/w', title: '标题二', updatedAt: 1000 },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
});
|
||||
112
plugins/dsh-mail-bridge/test/workspace.test.mjs
Normal file
112
plugins/dsh-mail-bridge/test/workspace.test.mjs
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 工作目录解析的回归测试。
|
||||
*
|
||||
* 这是「dsh 指定工作目录完全失效,所有对话都落在未分组下」那次故障的直接回归:
|
||||
* 插件曾无视寻址里的 path 位,每封邮件自己拼一个 ~/.dsh/mail-sessions/mail-<uuid>,
|
||||
* 而 DSH 按 cwd 分组,于是所有邮件会话既不属于任何项目、彼此也不同组。
|
||||
*
|
||||
* node --test test/
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
|
||||
|
||||
const fallbackOf = key => join(homedir(), '.dsh', 'mail-sessions', key);
|
||||
|
||||
test('存在的绝对路径直接用作 cwd', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
try {
|
||||
const got = resolveWorkspaceCwd(dir, 'mail-1');
|
||||
assert.equal(got.cwd, dir);
|
||||
assert.equal(got.grouped, true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('不变量:同一 path 的多封邮件得到同一个 cwd(这才能同组)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
try {
|
||||
const a = resolveWorkspaceCwd(dir, 'mail-aaa');
|
||||
const b = resolveWorkspaceCwd(dir, 'mail-bbb');
|
||||
assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('path 为空时回退到兜底目录', () => {
|
||||
const got = resolveWorkspaceCwd('', 'mail-2');
|
||||
assert.equal(got.cwd, fallbackOf('mail-2'));
|
||||
assert.equal(got.grouped, false);
|
||||
});
|
||||
|
||||
test('path 缺失/非字符串时回退', () => {
|
||||
for (const v of [undefined, null, 42, {}]) {
|
||||
const got = resolveWorkspaceCwd(v, 'mail-3');
|
||||
assert.equal(got.grouped, false);
|
||||
assert.equal(got.cwd, fallbackOf('mail-3'));
|
||||
}
|
||||
});
|
||||
|
||||
test('不变量:不存在的目录不创建,回退到兜底', () => {
|
||||
// 一个笔误(/home/porgram/x)不该在磁盘上落下真目录 ——
|
||||
// Agent 会在里面一无所获地干活,比明确回退更难排查。
|
||||
const got = resolveWorkspaceCwd('/nonexistent/path/xyz-should-not-exist', 'mail-4');
|
||||
assert.equal(got.grouped, false);
|
||||
assert.equal(got.cwd, fallbackOf('mail-4'));
|
||||
});
|
||||
|
||||
test('不变量:相对路径被拒绝', () => {
|
||||
// cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 /,
|
||||
// 那是个与邮件语义完全无关的量。
|
||||
for (const rel of ['relative/path', './x', '../y', 'src']) {
|
||||
const got = resolveWorkspaceCwd(rel, 'mail-5');
|
||||
assert.equal(got.grouped, false, `${rel} 不该被当作工作目录`);
|
||||
}
|
||||
});
|
||||
|
||||
test('指向文件而非目录时回退', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
const file = join(dir, 'a-file');
|
||||
writeFileSync(file, 'x');
|
||||
try {
|
||||
const got = resolveWorkspaceCwd(file, 'mail-6');
|
||||
assert.equal(got.grouped, false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('两端空白被修掉', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
try {
|
||||
const got = resolveWorkspaceCwd(` ${dir} `, 'mail-7');
|
||||
assert.equal(got.cwd, dir);
|
||||
assert.equal(got.grouped, true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureCwd 只建兜底目录,不碰寻址指定的目录', () => {
|
||||
const base = mkdtempSync(join(tmpdir(), 'ws-ensure-'));
|
||||
try {
|
||||
const target = join(base, 'made-by-ensure');
|
||||
ensureCwd(target, false);
|
||||
// 建出来了
|
||||
const got = resolveWorkspaceCwd(target, 'x');
|
||||
assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录');
|
||||
|
||||
// grouped=true 时不该创建(那种目录本来就存在)
|
||||
const never = join(base, 'should-not-exist');
|
||||
ensureCwd(never, true);
|
||||
assert.equal(resolveWorkspaceCwd(never, 'x').grouped, false);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
16
plugins/dsh-mail-bridge/tsconfig.json
Normal file
16
plugins/dsh-mail-bridge/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"allowJs": true
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
||||
import { join, dirname, basename } from "node:path";
|
||||
// 自动转发去重的纯逻辑放在 lib/ 里:opencode 会把入口模块的每一个导出
|
||||
// 都当成插件工厂,入口文件多导出一个东西就会 "Plugin export is not a function"。
|
||||
import { snapshotOpencodeSessions } from "./lib/session-snapshot.js";
|
||||
import {
|
||||
explicitSends,
|
||||
noteExplicitSend,
|
||||
@ -474,10 +475,19 @@ async function resolveSessionForMail(client, directory, data, kind) {
|
||||
const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
|
||||
if (bound) return { sessionID: bound, reused: true };
|
||||
|
||||
// 工作目录取**寻址里的 path 位**,而不是插件启动时那个固定的 directory。
|
||||
//
|
||||
// 三维地址 name@path.session 的 path 就是「希望它在哪儿干活」。用固定的
|
||||
// directory 会让所有邮件会话都挤在同一个目录里,与地址写的完全无关;
|
||||
// 而 opencode 按 directory 归属项目,写错了会话就归到别的项目下。
|
||||
const wantDir = typeof data.to_workspace === "string" && data.to_workspace.trim()
|
||||
? data.to_workspace.trim()
|
||||
: directory;
|
||||
|
||||
// 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
|
||||
// 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 session.updated 事件回写。
|
||||
const created = await client.session.create({
|
||||
query: directory ? { directory } : undefined,
|
||||
query: wantDir ? { directory: wantDir } : undefined,
|
||||
});
|
||||
const session = created?.data ?? created;
|
||||
const sessionID = session?.id;
|
||||
@ -704,12 +714,37 @@ export default async function mailBridge(input) {
|
||||
// 所以用一个闭包把 relaySummary 需要的两个参数固定下来。
|
||||
relaySummaryRef = (sid) => relaySummary(client, directory, sid);
|
||||
|
||||
// 心跳。只保活与取待处理邮件数 ——
|
||||
// 心跳。保活、取待处理邮件数,并上报平台侧的会话快照。
|
||||
//
|
||||
// 额度属于具体任务(会话),不属于 Agent,所以这里没有「剩余额度」可报。
|
||||
// 剩余往返随每次发信响应的 budget_remaining 回传,在那里才有意义。
|
||||
const heartbeat = setInterval(() => {
|
||||
apiPost("/agent/heartbeat", {}).catch(() => {});
|
||||
}, 30000);
|
||||
//
|
||||
// 会话快照解决的是「工作区下的历史会话在补全里选不到」:Gateway 只看得见
|
||||
// 邮件驱动的那部分,人直接在 opencode 界面上开的会话它一无所知。
|
||||
// 让插件上报而不是让 Gateway 反向拉取 —— 当前架构是单向的,
|
||||
// 反向拉取需要 Gateway 保存各平台的地址与凭证。
|
||||
async function reportSessions() {
|
||||
try {
|
||||
const listed = await client.session.list({
|
||||
query: directory ? { directory } : undefined,
|
||||
});
|
||||
const sessions = listed?.data ?? listed ?? [];
|
||||
return snapshotOpencodeSessions(sessions, (id) => mailDrivenSessions.has(id));
|
||||
} catch (e) {
|
||||
// 拉不到列表就**省略**该字段,而不是传空数组:
|
||||
// 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。
|
||||
console.error("[mail-bridge] 会话列表读取失败:", e?.message || e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const beat = async () => {
|
||||
const platform_sessions = await reportSessions();
|
||||
const body = platform_sessions ? { platform_sessions } : {};
|
||||
apiPost("/agent/heartbeat", body).catch(() => {});
|
||||
};
|
||||
beat();
|
||||
const heartbeat = setInterval(beat, 30000);
|
||||
|
||||
startSSE((type, data) => {
|
||||
// 人类决策了一条权限请求 → 回复 opencode 的原生 permission,让它自己恢复执行。
|
||||
|
||||
151
plugins/opencode-mail-bridge/lib/session-snapshot.js
Normal file
151
plugins/opencode-mail-bridge/lib/session-snapshot.js
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。
|
||||
*
|
||||
* 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。
|
||||
* Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话
|
||||
* 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。
|
||||
*
|
||||
* 为什么是插件上报而不是 Gateway 拉取:当前架构是单向的(Agent 持密钥主动连
|
||||
* Gateway,Gateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与凭证,
|
||||
* 那是另一套信任模型。
|
||||
*/
|
||||
|
||||
/** 单次上报的会话数上限。与服务端的 maxPlatformSessions 一致。 */
|
||||
export const MAX_REPORTED = 200;
|
||||
|
||||
/**
|
||||
* 把 opencode 的 session 列表整理成上报格式。
|
||||
*
|
||||
* @param {any[]} sessions client.session.list() 的结果
|
||||
* @param {(id: string) => boolean} isMailDriven 该平台会话是否由邮件驱动
|
||||
* @returns {object[]} 按最近活跃排序、截断到 MAX_REPORTED 的上报项
|
||||
*/
|
||||
export function snapshotOpencodeSessions(sessions, isMailDriven = () => false) {
|
||||
const list = Array.isArray(sessions) ? sessions : [];
|
||||
const out = [];
|
||||
for (const s of list) {
|
||||
const id = typeof s?.id === 'string' ? s.id : '';
|
||||
if (!id) continue;
|
||||
// 没有 slug 的会话不报:slug 是填进 session 位的值,
|
||||
// 没有它这一项在补全里点下去只能得到一个空的 session 段。
|
||||
const slug = typeof s?.slug === 'string' ? s.slug : '';
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
platform_id: id,
|
||||
// opencode 的工作目录在 directory 上(path 是项目内的子路径,不是 cwd)
|
||||
workspace: typeof s?.directory === 'string' ? s.directory : '',
|
||||
slug,
|
||||
title: typeof s?.title === 'string' ? s.title : '',
|
||||
mail_driven: Boolean(isMailDriven(id)),
|
||||
updated_at: toISO(s?.time?.updated ?? s?.time?.created),
|
||||
});
|
||||
}
|
||||
return sortAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 DSH 的 agent 列表整理成上报格式。
|
||||
*
|
||||
* DSH 没有 opencode 那样的 slug,别名由**模型生成的会话标题**派生
|
||||
* (与「别名复用平台命名」的既定决策一致)。占位标题不派生别名:
|
||||
* DSH 在模型生成真标题前会先落一个 fallback 标题,内容是用户第一句话的截断,
|
||||
* 而那句话是插件自己拼的提示词。
|
||||
*
|
||||
* @param {any[]} entries [{ id, cwd, title, updatedAt }]
|
||||
* @param {(id: string) => boolean} isMailDriven
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotDshSessions(entries, isMailDriven = () => false) {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
const out = [];
|
||||
for (const e of list) {
|
||||
const id = typeof e?.id === 'string' ? e.id : '';
|
||||
if (!id) continue;
|
||||
// subagent 子会话不上报:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
|
||||
// 而且它们的标题就是派活时的提示词前缀(实测九条会话都叫
|
||||
// "You are auditing ONE file"),派生出的 slug 全都撞名、毫无区分度。
|
||||
if (isSubagent(e)) continue;
|
||||
const title = typeof e?.title === 'string' ? e.title : '';
|
||||
const slug = slugFromTitle(title);
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
platform_id: id,
|
||||
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
|
||||
slug,
|
||||
title,
|
||||
mail_driven: Boolean(isMailDriven(id)),
|
||||
updated_at: toISO(e?.updatedAt),
|
||||
});
|
||||
}
|
||||
// slug 撞名的只留最近那条:别名是**寻址**用的,
|
||||
// 同一个 slug 对应多条会话时服务端只能取其中一条(updated_at DESC LIMIT 1),
|
||||
// 上报一堆同名项只会让人在补全列表里看到几个一模一样、点哪个都不确定的候选。
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
|
||||
function isSubagent(e) {
|
||||
if (e?.origin === 'subagent') return true;
|
||||
const depth = e?.delegationDepth;
|
||||
return typeof depth === 'number' && depth > 0;
|
||||
}
|
||||
|
||||
/** 同 slug 只保留第一条(调用前已按最近活跃排序)。 */
|
||||
function dedupeBySlug(list) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const item of list) {
|
||||
if (seen.has(item.slug)) continue;
|
||||
seen.add(item.slug);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把模型生成的会话标题转成可寻址的 slug。
|
||||
*
|
||||
* 保留中文而不转拼音:标题「缓存层选型评估」转成 huancunceng-xuanxing 之后
|
||||
* 既不好读也不好打,而 AgentMail 的别名校验本来就允许中文(三维地址按最后一个
|
||||
* `.` 切分,中文不影响解析)。
|
||||
*
|
||||
* 处理:空白 → `-`,去掉会干扰寻址的字符(`.` 是 session 位的分隔符,
|
||||
* `@` 是 path 位的分隔符,`/` 会被当成路径),压缩连续 `-`,截断到 48 字符。
|
||||
*
|
||||
* @param {string} title
|
||||
* @returns {string} slug,无法派生时为空串
|
||||
*/
|
||||
export function slugFromTitle(title) {
|
||||
const raw = String(title ?? '').trim();
|
||||
if (!raw) return '';
|
||||
const slug = raw
|
||||
.replace(/[\s\u3000]+/g, '-')
|
||||
// 寻址相关的分隔符必须去掉,否则别名本身会被解析器切开
|
||||
.replace(/[.@/\\:,;'"`?#[\]{}()<>|*!$&=+%^~]/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48)
|
||||
// 截断可能又切出尾部的 -
|
||||
.replace(/-+$/g, '');
|
||||
// 纯符号标题清干净后会剩空串
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** 毫秒时间戳或 ISO 串 → ISO 串;无法解析时返回 undefined。 */
|
||||
function toISO(v) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
if (typeof v === 'string' && v) {
|
||||
const d = new Date(v);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 按最近活跃降序排列并截断。上千条会话对补全列表毫无用处。 */
|
||||
function sortAndCap(list) {
|
||||
return list
|
||||
.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')))
|
||||
.slice(0, MAX_REPORTED);
|
||||
}
|
||||
@ -11,6 +11,6 @@
|
||||
"@opencode-ai/plugin": ">=1.15.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node test/auto-relay.test.mjs"
|
||||
"test": "node test/auto-relay.test.mjs && node --test test/session-snapshot.test.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
228
plugins/opencode-mail-bridge/test/session-snapshot.test.mjs
Normal file
228
plugins/opencode-mail-bridge/test/session-snapshot.test.mjs
Normal file
@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 平台会话快照的纯函数测试。
|
||||
*
|
||||
* 这些函数的产出直接决定「写信时能不能选到某条会话」:slug 错了就填出一个
|
||||
* 送不到的 session 位(三态语义下会 404),workspace 错了就归到别的工作区去。
|
||||
*
|
||||
* node --test test/
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeSessions,
|
||||
snapshotDshSessions,
|
||||
slugFromTitle,
|
||||
MAX_REPORTED,
|
||||
} from '../lib/session-snapshot.js';
|
||||
|
||||
// ─── opencode ───
|
||||
|
||||
const ocSession = (over = {}) => ({
|
||||
id: 'ses_abc',
|
||||
slug: 'witty-planet',
|
||||
title: '重构导入路径',
|
||||
directory: '/home/program/agentmail',
|
||||
path: '',
|
||||
time: { created: 1788300000000, updated: 1788344476744 },
|
||||
...over,
|
||||
});
|
||||
|
||||
test('opencode 快照取 directory 作为 workspace', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.workspace, '/home/program/agentmail');
|
||||
});
|
||||
|
||||
test('不变量:workspace 取 directory 而不是 path', () => {
|
||||
// opencode 的 path 是项目内的子路径(通常是空串),cwd 在 directory 上。
|
||||
// 取错的后果是所有会话的 workspace 都变成空串,一条都匹配不上。
|
||||
const [got] = snapshotOpencodeSessions([
|
||||
ocSession({ directory: '/home/real/cwd', path: 'src/sub' }),
|
||||
]);
|
||||
assert.equal(got.workspace, '/home/real/cwd');
|
||||
});
|
||||
|
||||
test('opencode 快照带出 slug 与标题', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.slug, 'witty-planet');
|
||||
assert.equal(got.title, '重构导入路径');
|
||||
assert.equal(got.platform_id, 'ses_abc');
|
||||
});
|
||||
|
||||
test('不变量:无 slug 的会话不上报', () => {
|
||||
// slug 是填进 session 位的值。没有它,这一项在补全里点下去
|
||||
// 只能得到 `name@path.` —— 一个空的 session 段。
|
||||
const got = snapshotOpencodeSessions([
|
||||
ocSession({ id: 'a', slug: '' }),
|
||||
ocSession({ id: 'b', slug: undefined }),
|
||||
ocSession({ id: 'c', slug: 'good-name' }),
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].slug, 'good-name');
|
||||
});
|
||||
|
||||
test('无 id 的条目被跳过', () => {
|
||||
const got = snapshotOpencodeSessions([ocSession({ id: '' }), ocSession({ id: undefined })]);
|
||||
assert.equal(got.length, 0);
|
||||
});
|
||||
|
||||
test('mail_driven 由回调判定', () => {
|
||||
const got = snapshotOpencodeSessions(
|
||||
[ocSession({ id: 'driven' }), ocSession({ id: 'manual' })],
|
||||
id => id === 'driven'
|
||||
);
|
||||
assert.equal(got.find(s => s.platform_id === 'driven').mail_driven, true);
|
||||
assert.equal(got.find(s => s.platform_id === 'manual').mail_driven, false);
|
||||
});
|
||||
|
||||
test('updated_at 由毫秒时间戳转 ISO', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
|
||||
});
|
||||
|
||||
test('没有 updated 时退回 created', () => {
|
||||
const [got] = snapshotOpencodeSessions([
|
||||
ocSession({ time: { created: 1788300000000 } }),
|
||||
]);
|
||||
assert.equal(got.updated_at, new Date(1788300000000).toISOString());
|
||||
});
|
||||
|
||||
test('时间完全缺失时 updated_at 为 undefined 而不是崩', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession({ time: undefined })]);
|
||||
assert.equal(got.updated_at, undefined);
|
||||
});
|
||||
|
||||
test('按最近活跃降序排列', () => {
|
||||
const got = snapshotOpencodeSessions([
|
||||
ocSession({ id: 'old', slug: 'old', time: { updated: 1000 } }),
|
||||
ocSession({ id: 'new', slug: 'new', time: { updated: 9000 } }),
|
||||
ocSession({ id: 'mid', slug: 'mid', time: { updated: 5000 } }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
test('截断到 MAX_REPORTED', () => {
|
||||
const many = Array.from({ length: MAX_REPORTED + 50 }, (_, i) =>
|
||||
ocSession({ id: `s${i}`, slug: `slug-${i}`, time: { updated: i } })
|
||||
);
|
||||
assert.equal(snapshotOpencodeSessions(many).length, MAX_REPORTED);
|
||||
});
|
||||
|
||||
test('非数组输入不崩', () => {
|
||||
assert.deepEqual(snapshotOpencodeSessions(undefined), []);
|
||||
assert.deepEqual(snapshotOpencodeSessions(null), []);
|
||||
assert.deepEqual(snapshotOpencodeSessions({}), []);
|
||||
});
|
||||
|
||||
// ─── DSH ───
|
||||
|
||||
test('DSH 快照从标题派生 slug', () => {
|
||||
const [got] = snapshotDshSessions([
|
||||
{ id: 'mail-1', cwd: '/home/x', title: '缓存层选型评估', updatedAt: 1788344476744 },
|
||||
]);
|
||||
assert.equal(got.slug, '缓存层选型评估');
|
||||
assert.equal(got.title, '缓存层选型评估');
|
||||
assert.equal(got.workspace, '/home/x');
|
||||
});
|
||||
|
||||
test('DSH 无标题时不上报(派生不出别名)', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'a', cwd: '/home/x', title: '' },
|
||||
{ id: 'b', cwd: '/home/x' },
|
||||
]);
|
||||
assert.equal(got.length, 0);
|
||||
});
|
||||
|
||||
// ─── slugFromTitle ───
|
||||
|
||||
test('slugFromTitle 空白转连字符', () => {
|
||||
assert.equal(slugFromTitle('处理新邮件 任务'), '处理新邮件-任务');
|
||||
assert.equal(slugFromTitle('a b c'), 'a-b-c');
|
||||
});
|
||||
|
||||
test('不变量:slug 不含寻址分隔符', () => {
|
||||
// `.` 是 session 位的分隔符、`@` 是 path 位的分隔符。留在 slug 里
|
||||
// 会让别名自己被解析器切开 —— 填进去的地址会指向一个完全不同的目标。
|
||||
const slug = slugFromTitle('修 a.b@c/d 的问题');
|
||||
for (const ch of ['.', '@', '/', '\\', ':']) {
|
||||
assert.ok(!slug.includes(ch), `slug 里不该有 ${ch}:${slug}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('slugFromTitle 压缩连续连字符', () => {
|
||||
assert.equal(slugFromTitle('a...b'), 'ab');
|
||||
assert.equal(slugFromTitle('a - b'), 'a-b');
|
||||
});
|
||||
|
||||
test('slugFromTitle 去掉首尾连字符', () => {
|
||||
assert.equal(slugFromTitle(' 中间 '), '中间');
|
||||
assert.equal(slugFromTitle('--x--'), 'x');
|
||||
});
|
||||
|
||||
test('slugFromTitle 截断到 48 字符且不留尾部连字符', () => {
|
||||
const long = 'a'.repeat(60);
|
||||
assert.equal(slugFromTitle(long).length, 48);
|
||||
// 第 48 个字符正好落在空格上时,截断后不该留下尾部 -
|
||||
const tricky = `${'b'.repeat(47)} tail`;
|
||||
const slug = slugFromTitle(tricky);
|
||||
assert.ok(!slug.endsWith('-'), `尾部残留连字符:${slug}`);
|
||||
});
|
||||
|
||||
test('slugFromTitle 保留中文', () => {
|
||||
// 不转拼音:huancunceng-xuanxing 既不好读也不好打,
|
||||
// 而三维地址按最后一个 . 切分,中文不影响解析。
|
||||
assert.equal(slugFromTitle('缓存选型'), '缓存选型');
|
||||
});
|
||||
|
||||
test('slugFromTitle 纯符号标题返回空串', () => {
|
||||
assert.equal(slugFromTitle('...'), '');
|
||||
assert.equal(slugFromTitle('@@@'), '');
|
||||
assert.equal(slugFromTitle(' '), '');
|
||||
});
|
||||
|
||||
test('slugFromTitle 容错非字符串', () => {
|
||||
assert.equal(slugFromTitle(undefined), '');
|
||||
assert.equal(slugFromTitle(null), '');
|
||||
assert.equal(slugFromTitle(42), '42');
|
||||
});
|
||||
|
||||
// ─── DSH:subagent 过滤与 slug 去重 ───
|
||||
|
||||
test('不变量:subagent 子会话不上报(origin 判据)', () => {
|
||||
// 它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'child', cwd: '/w', title: 'You are auditing ONE file', origin: 'subagent' },
|
||||
{ id: 'top', cwd: '/w', title: '正常会话' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'top');
|
||||
});
|
||||
|
||||
test('不变量:subagent 子会话不上报(delegationDepth 判据)', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'child', cwd: '/w', title: '子任务', delegationDepth: 1 },
|
||||
{ id: 'top', cwd: '/w', title: '顶层', delegationDepth: 0 },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'top');
|
||||
});
|
||||
|
||||
test('不变量:slug 撞名只留最近那条', () => {
|
||||
// 别名是寻址用的:同一个 slug 对应多条会话时服务端只能取其中一条,
|
||||
// 上报一堆同名项只会让补全列表里出现几个点哪个都不确定的候选。
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'old', cwd: '/w', title: '同一个标题', updatedAt: 1000 },
|
||||
{ id: 'new', cwd: '/w', title: '同一个标题', updatedAt: 9000 },
|
||||
{ id: 'mid', cwd: '/w', title: '同一个标题', updatedAt: 5000 },
|
||||
]);
|
||||
assert.equal(got.length, 1, `应去重到 1 条,实际 ${got.length}`);
|
||||
assert.equal(got[0].platform_id, 'new', '应保留最近活跃的那条');
|
||||
});
|
||||
|
||||
test('不同标题不受去重影响', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'a', cwd: '/w', title: '标题一', updatedAt: 2000 },
|
||||
{ id: 'b', cwd: '/w', title: '标题二', updatedAt: 1000 },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user