feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
This commit is contained in:
13
plugins/dsh-mail-bridge/lib/addressing.d.ts
vendored
Normal file
13
plugins/dsh-mail-bridge/lib/addressing.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
export interface MailParticipant {
|
||||
role: string;
|
||||
name: string;
|
||||
path: string;
|
||||
address: string;
|
||||
is_self: boolean;
|
||||
}
|
||||
|
||||
export function formatAddress(name: string, path?: string, session?: string): string;
|
||||
export function roleOf(mail: any, selfName: string): 'to' | 'cc' | 'unknown';
|
||||
export function replyAddressFor(mail: any, alias?: string): string;
|
||||
export function selfAddressFor(mail: any, selfName: string, alias?: string): string;
|
||||
export function participantsOfMail(mail: any, selfName?: string, alias?: string): MailParticipant[];
|
||||
141
plugins/dsh-mail-bridge/lib/addressing.js
Normal file
141
plugins/dsh-mail-bridge/lib/addressing.js
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 三维寻址的构造与判读 —— 所有平台插件共用。
|
||||
*
|
||||
* 为什么这些函数必须共用、且必须是纯函数:
|
||||
*
|
||||
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
|
||||
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
|
||||
* 生产上真实发生过两次:
|
||||
*
|
||||
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
|
||||
* 再建一条平行会话,双方从此各说各话。
|
||||
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`,
|
||||
* 整串被当成名字,session 位静默丢失。
|
||||
*
|
||||
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 拼一个可寻址的 `name@path.session`。
|
||||
*
|
||||
* **空 path 也必须留下 `@` 与 `.`**:`admin@.silent-harbor` 才解析成
|
||||
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
|
||||
* `admin.silent-harbor` 会被整串当作名字。
|
||||
*
|
||||
* session 省略时不写那一位(默认会话语义)。
|
||||
*
|
||||
* @param {string} name 收件方名(Agent 名或人类用户名)
|
||||
* @param {string} [path] 工作目录,可为空
|
||||
* @param {string} [session] 会话别名;空则省略该位
|
||||
* @returns {string} 地址,name 为空时返回空串
|
||||
*/
|
||||
export function formatAddress(name, path, session) {
|
||||
const n = String(name ?? '').trim();
|
||||
const p = String(path ?? '').trim();
|
||||
const s = String(session ?? '').trim();
|
||||
if (!n) return '';
|
||||
if (!s) return p ? `${n}@${p}` : n;
|
||||
return `${n}@${p}.${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断自己在这封邮件里是收件人还是抄送方。
|
||||
*
|
||||
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
|
||||
* admin 主发 dsh、抄送 opencode,分工是「dsh 提供源码解读、opencode 提供部署
|
||||
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
|
||||
* 或者都以为自己只是旁观者。
|
||||
*
|
||||
* @param {any} mail `/mail/inbox` 返回的一封邮件
|
||||
* @param {string} selfName 自己的 Agent 名
|
||||
* @returns {'to'|'cc'|'unknown'}
|
||||
*/
|
||||
export function roleOf(mail, selfName) {
|
||||
const self = String(selfName ?? '').trim();
|
||||
if (!self) return 'unknown';
|
||||
if (mail?.to_name === self) return 'to';
|
||||
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
|
||||
return 'cc';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 给出「把回信发回这条会话」的地址。
|
||||
*
|
||||
* 发件人一侧**不带 path**:Agent 回信时 `from_workspace` 存的是 Agent 名而不是
|
||||
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
|
||||
* 人类发件人本来就没有工作目录。
|
||||
*
|
||||
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
|
||||
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {string}
|
||||
*/
|
||||
export function replyAddressFor(mail, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
return formatAddress(mail?.from_name, '', a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
|
||||
*
|
||||
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
|
||||
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} selfName 自己的 Agent 名
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {string}
|
||||
*/
|
||||
export function selfAddressFor(mail, selfName, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
|
||||
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
|
||||
let path = mail?.to_workspace ?? '';
|
||||
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
|
||||
const mine = mail.cc_list.find(c => c?.name === selfName);
|
||||
if (mine) path = mine.path ?? '';
|
||||
}
|
||||
return formatAddress(selfName, path, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出这封邮件的全部参与方及各自可投递的地址。
|
||||
*
|
||||
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
|
||||
* 抄送方的 path 取它自己那个地址的 path 位。
|
||||
*
|
||||
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
|
||||
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} [selfName] 自己的名字,用于标记 is_self
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
|
||||
*/
|
||||
export function participantsOfMail(mail, selfName, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
const self = String(selfName ?? '').trim();
|
||||
const out = [];
|
||||
const add = (role, name, path) => {
|
||||
const n = String(name ?? '').trim();
|
||||
if (!n) return;
|
||||
out.push({
|
||||
role,
|
||||
name: n,
|
||||
path: String(path ?? ''),
|
||||
address: formatAddress(n, path, a),
|
||||
is_self: !!self && n === self,
|
||||
});
|
||||
};
|
||||
// 发件人一侧 path 留空,理由同 replyAddressFor
|
||||
add('from', mail?.from_name, '');
|
||||
add('to', mail?.to_name, mail?.to_workspace);
|
||||
if (Array.isArray(mail?.cc_list)) {
|
||||
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
6
plugins/dsh-mail-bridge/lib/discovery.d.ts
vendored
Normal file
6
plugins/dsh-mail-bridge/lib/discovery.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
export function renderNameSuggestions(names: readonly string[]): string;
|
||||
export function renderPathSuggestions(paths: readonly string[], name: string): string;
|
||||
export function renderSessionSuggestions(data: any, name: string, path: string): string;
|
||||
export function renderParticipants(data: any): string;
|
||||
export function renderContacts(data: any, limit?: number): string;
|
||||
export function renderThread(data: any, selfName?: string): string;
|
||||
237
plugins/dsh-mail-bridge/lib/discovery.js
Normal file
237
plugins/dsh-mail-bridge/lib/discovery.js
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
|
||||
*
|
||||
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
|
||||
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
|
||||
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
|
||||
*
|
||||
* # 这一组端点解决的问题
|
||||
*
|
||||
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
|
||||
* 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从
|
||||
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
|
||||
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
|
||||
* 那个错误路径静默变成了新会话的 workspace。
|
||||
*
|
||||
* # 渲染的取舍
|
||||
*
|
||||
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
|
||||
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
|
||||
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 渲染候选收件人清单(`kind: "name"`)。
|
||||
*
|
||||
* 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的
|
||||
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
|
||||
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
|
||||
*
|
||||
* @param {string[]} names
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderNameSuggestions(names) {
|
||||
const list = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||
if (list.length === 0) return '当前没有可投递的收件人。';
|
||||
return [
|
||||
`可投递的收件人(${list.length} 个):`,
|
||||
list.map(n => `- ${n}`).join('\n'),
|
||||
'',
|
||||
'下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染工作目录候选(`kind: "path"`)。
|
||||
*
|
||||
* 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录),
|
||||
* 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
*
|
||||
* @param {string[]} paths
|
||||
* @param {string} name 正在查的收件人名,用于拼下一步的提示
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderPathSuggestions(paths, name) {
|
||||
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
return [
|
||||
`${name} 没有记录在案的工作目录。`,
|
||||
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
|
||||
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`,
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`${name} 用过的工作目录(按最近使用排序):`,
|
||||
list.map(p => `- ${p}`).join('\n'),
|
||||
'',
|
||||
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染会话候选(`kind: "session"`)。
|
||||
*
|
||||
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`:
|
||||
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
|
||||
*
|
||||
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
|
||||
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
*
|
||||
* @param {object} data `/agent/contacts/suggest` 的返回体
|
||||
* @param {string} name
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderSessionSuggestions(data, name, path) {
|
||||
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
|
||||
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
|
||||
|
||||
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
|
||||
const existing = aliases.filter(a => a !== 'new');
|
||||
if (existing.length === 0) {
|
||||
return [
|
||||
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
|
||||
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`,
|
||||
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
|
||||
for (let i = 0; i < aliases.length; i++) {
|
||||
const alias = aliases[i];
|
||||
const addr = addresses[i] || '';
|
||||
const c = candidates[i] || {};
|
||||
if (alias === 'new') continue; // new 单独放最后
|
||||
const bits = [];
|
||||
if (c.title) bits.push(c.title);
|
||||
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
|
||||
if (c.source === 'platform') bits.push('平台侧会话');
|
||||
lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
|
||||
const newAddr = addressAt(addresses, aliases, 'new');
|
||||
if (newAddr) {
|
||||
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 按别名在同序的 addresses 里取地址。 */
|
||||
function addressAt(addresses, aliases, alias) {
|
||||
const i = aliases.indexOf(alias);
|
||||
return i >= 0 ? addresses[i] || '' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染会话参与方清单。
|
||||
*
|
||||
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
|
||||
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
|
||||
* 「作为发件人」的邮件,正是为了让这个判断成立。
|
||||
*
|
||||
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderParticipants(data) {
|
||||
const parts = Array.isArray(data?.participants) ? data.participants : [];
|
||||
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
|
||||
|
||||
const alias = data?.session_alias || '';
|
||||
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
|
||||
for (const p of parts) {
|
||||
const tags = [];
|
||||
if (p.is_self) tags.push('就是你');
|
||||
if (Array.isArray(p.roles) && p.roles.length) {
|
||||
tags.push(p.roles.map(roleLabel).join('/'));
|
||||
}
|
||||
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
|
||||
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
|
||||
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染联系人清单(本 Agent 参与过的全部会话)。
|
||||
*
|
||||
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
|
||||
* 有未读的那些才是答案。
|
||||
*
|
||||
* @param {object} data `/agent/contacts` 的返回体
|
||||
* @param {number} limit 最多列出多少条
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderContacts(data, limit = 20) {
|
||||
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
|
||||
if (list.length === 0) return '还没有任何往来会话。';
|
||||
|
||||
list.sort((a, b) => {
|
||||
const ua = a?.unread_count || 0;
|
||||
const ub = b?.unread_count || 0;
|
||||
if (ua !== ub) return ub - ua;
|
||||
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
|
||||
});
|
||||
|
||||
const shown = list.slice(0, limit);
|
||||
const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`];
|
||||
for (const c of shown) {
|
||||
const bits = [];
|
||||
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
|
||||
if (c.subject) bits.push(c.subject);
|
||||
if (c.max_rounds > 0) {
|
||||
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
|
||||
bits.push(`剩 ${left}/${c.max_rounds} 个来回`);
|
||||
}
|
||||
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
|
||||
lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||||
function roleLabel(role) {
|
||||
switch (role) {
|
||||
case 'from': return '发件人';
|
||||
case 'to': return '收件人';
|
||||
case 'cc': return '抄送方';
|
||||
default: return String(role);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染对话树,回答「谁已经回了、谁还没回」。
|
||||
*
|
||||
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
|
||||
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
|
||||
*
|
||||
* @param {object} data `/agent/mail/{id}/thread` 的返回体
|
||||
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderThread(data, selfName = '') {
|
||||
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||||
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
|
||||
|
||||
const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`];
|
||||
for (const n of nodes) {
|
||||
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
|
||||
const indent = ' '.repeat(Math.min(depth, 8));
|
||||
const marks = [];
|
||||
if (selfName && n?.from_name === selfName) marks.push('你发的');
|
||||
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
|
||||
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
|
||||
lines.push(
|
||||
`${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
|
||||
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}`
|
||||
);
|
||||
}
|
||||
if (data?.has_more) {
|
||||
lines.push('');
|
||||
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@ -2,6 +2,6 @@ export declare const DEFAULT_INBOX_STATUS: string;
|
||||
export declare const DEFAULT_INBOX_LIMIT: number;
|
||||
|
||||
export function formatSize(n: number | undefined): string;
|
||||
export function renderMail(mail: any, bodyLimit?: number): string;
|
||||
export function renderInbox(mails: readonly any[], bodyLimit?: number): string;
|
||||
export function renderMail(mail: any, bodyLimit?: number, selfName?: string): string;
|
||||
export function renderInbox(mails: readonly any[], bodyLimit?: number, selfName?: string): string;
|
||||
export function idsToMarkRead(status: string | undefined, mails: readonly any[]): string[];
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
|
||||
*/
|
||||
|
||||
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
|
||||
|
||||
/** 人类可读的字节数,用于附件清单展示。 */
|
||||
export function formatSize(n) {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
|
||||
@ -19,19 +21,41 @@ export function formatSize(n) {
|
||||
*
|
||||
* @param {any} m `/mail/inbox` 返回的一封邮件
|
||||
* @param {number} bodyLimit 正文截断长度
|
||||
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
|
||||
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderMail(m, bodyLimit = 200) {
|
||||
export function renderMail(m, bodyLimit = 200, selfName = '') {
|
||||
const alias = m?.session_alias || '';
|
||||
const lines = [
|
||||
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
|
||||
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
|
||||
`会话: #${m?.session_alias || '未命名'}`,
|
||||
`会话: #${alias || '未命名'}`,
|
||||
];
|
||||
|
||||
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
|
||||
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
|
||||
// 抄送方却看不到收件人叫什么。
|
||||
if (m?.to_name) {
|
||||
let toLine = `收件人: ${m.to_name}`;
|
||||
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
|
||||
lines.push(toLine);
|
||||
}
|
||||
|
||||
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
|
||||
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
|
||||
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
|
||||
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
|
||||
}
|
||||
|
||||
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
|
||||
// 以为自己是负责人,或者都以为自己只是旁观者。
|
||||
if (selfName) {
|
||||
const role = roleOf(m, selfName);
|
||||
if (role === 'to') lines.push('你的身份: 收件人(主办)');
|
||||
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
|
||||
}
|
||||
|
||||
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
|
||||
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
|
||||
lines.push(
|
||||
@ -42,22 +66,52 @@ export function renderMail(m, bodyLimit = 200) {
|
||||
);
|
||||
lines.push('下载附件请用 download_attachment 工具。');
|
||||
}
|
||||
|
||||
// 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。
|
||||
const body = m?.body_preview || m?.body || '';
|
||||
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
|
||||
|
||||
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
|
||||
//
|
||||
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
|
||||
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
|
||||
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
|
||||
if (selfName && alias) {
|
||||
const parts = participantsOfMail(m, selfName, alias);
|
||||
const others = parts.filter(p => !p.is_self && p.address);
|
||||
if (others.length > 0) {
|
||||
lines.push(
|
||||
'可投递地址: ' +
|
||||
others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、')
|
||||
);
|
||||
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||||
function roleLabel(role) {
|
||||
switch (role) {
|
||||
case 'from': return '发件人';
|
||||
case 'to': return '收件人';
|
||||
case 'cc': return '抄送方';
|
||||
default: return role;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染整个收件箱。
|
||||
* @param {any[]} mails
|
||||
* @param {number} bodyLimit
|
||||
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderInbox(mails, bodyLimit = 200) {
|
||||
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
|
||||
const list = Array.isArray(mails) ? mails : [];
|
||||
if (list.length === 0) return '收件箱为空。';
|
||||
return list.map(m => renderMail(m, bodyLimit)).join('\n\n');
|
||||
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
plugins/dsh-mail-bridge/lib/model-scope.d.ts
vendored
1
plugins/dsh-mail-bridge/lib/model-scope.d.ts
vendored
@ -13,6 +13,7 @@ export declare const MAX_CATALOG: number;
|
||||
|
||||
export function snapshotOpencodeModels(config: any): CatalogEntry[];
|
||||
export function snapshotDshModels(entries: readonly any[]): CatalogEntry[];
|
||||
export function snapshotPiModels(models: readonly any[]): CatalogEntry[];
|
||||
|
||||
export function modelAttemptOrder(
|
||||
allowed: readonly ModelRoute[] | undefined,
|
||||
|
||||
@ -65,6 +65,37 @@ export function snapshotDshModels(entries) {
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pi 的模型列表整理成上报格式。
|
||||
*
|
||||
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
|
||||
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
|
||||
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
|
||||
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
|
||||
*
|
||||
* pi 的 Model 对象上,provider 在 `provider` 字段、模型 id 在 `id` 字段,
|
||||
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
|
||||
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
|
||||
*
|
||||
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotPiModels(models) {
|
||||
const list = Array.isArray(models) ? models : [];
|
||||
const out = [];
|
||||
for (const m of list) {
|
||||
const provider = typeof m?.provider === 'string' ? m.provider : '';
|
||||
const model = typeof m?.id === 'string' ? m.id : '';
|
||||
if (!provider || !model) continue;
|
||||
out.push({
|
||||
provider,
|
||||
model,
|
||||
display_name: typeof m?.name === 'string' ? m.name : '',
|
||||
});
|
||||
}
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定这一轮按什么顺序尝试模型。
|
||||
*
|
||||
|
||||
@ -19,4 +19,11 @@ export function snapshotDshSessions(
|
||||
isMailDriven?: (id: string) => boolean
|
||||
): PlatformSessionReport[];
|
||||
|
||||
export function snapshotPiSessions(
|
||||
entries: readonly any[],
|
||||
isMailDriven?: (id: string) => boolean
|
||||
): PlatformSessionReport[];
|
||||
|
||||
export function isUnusableName(name: string): boolean;
|
||||
|
||||
export function slugFromTitle(title: string): string;
|
||||
|
||||
@ -83,6 +83,83 @@ export function snapshotDshSessions(entries, isMailDriven = () => false) {
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
|
||||
*
|
||||
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
|
||||
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
|
||||
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
|
||||
* - **pi 内核(SDK)自己不生成**:桥用 createAgentSession 起的会话没有名字,
|
||||
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming)
|
||||
*
|
||||
* 与另两个平台的差异:pi 的 SessionInfo 里**没有 subagent 标记**。
|
||||
* pi-subagents 把子会话写在自定义 sessionDir(run 根目录)下,默认会话目录
|
||||
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
|
||||
*
|
||||
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
|
||||
* @param {(id: string) => boolean} isMailDriven
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotPiSessions(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;
|
||||
const name = typeof e?.name === 'string' ? e.name : '';
|
||||
// 没有名字的会话不报(S-1):pi 的列表在无名时显示首条消息,
|
||||
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
|
||||
if (!name) continue;
|
||||
// 模型把思维链当标题写进来的那些不报(见 isUnusableName)
|
||||
if (isUnusableName(name)) continue;
|
||||
const slug = slugFromTitle(name);
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
platform_id: id,
|
||||
// 老会话的 cwd 是空串(pi 的 SessionInfo 注释里写明了),照实上报,
|
||||
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
|
||||
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
|
||||
slug,
|
||||
title: name,
|
||||
mail_driven: Boolean(isMailDriven(id)),
|
||||
updated_at: toISO(e?.modified ?? e?.created),
|
||||
});
|
||||
}
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个平台侧名字是否不适合当别名。
|
||||
*
|
||||
* 这条判废是 pi 特有的:pi-web 的标题生成器(`sessionNameGenerator`)只做了
|
||||
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
|
||||
*
|
||||
* "The user is asking me to generate a title for a coding-agent"
|
||||
* "我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:…"
|
||||
*
|
||||
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
|
||||
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
|
||||
*
|
||||
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
|
||||
* 而漏掉一个坏名字只是别名难看。
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUnusableName(name) {
|
||||
const s = String(name ?? '').trim();
|
||||
if (!s) return true;
|
||||
// 自指标题生成任务 = 模型把系统提示词复述了出来
|
||||
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
|
||||
return true;
|
||||
}
|
||||
// 以第三人称叙述用户意图开头 = 思维链的典型开场
|
||||
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(,|,))/i.test(s)) return true;
|
||||
// 又长又分句 = 一段话而不是一个标题(pi-web 截断上限是 60)
|
||||
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
|
||||
function isSubagent(e) {
|
||||
if (e?.origin === 'subagent') return true;
|
||||
@ -131,11 +208,17 @@ export function slugFromTitle(title) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** 毫秒时间戳或 ISO 串 → ISO 串;无法解析时返回 undefined。 */
|
||||
/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */
|
||||
function toISO(v) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
// pi 的 SessionInfo 给的是 Date 实例(created/modified),不是时间戳。
|
||||
// 少了这一支会让整份快照的 updated_at 全是 undefined,于是服务端只能按
|
||||
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
|
||||
if (v instanceof Date) {
|
||||
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
|
||||
}
|
||||
if (typeof v === 'string' && v) {
|
||||
const d = new Date(v);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
|
||||
@ -42,6 +42,14 @@ import {
|
||||
DEFAULT_INBOX_STATUS,
|
||||
DEFAULT_INBOX_LIMIT,
|
||||
} from '../lib/inbox-format.js';
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
|
||||
// ─── 凭证管理 ───
|
||||
|
||||
@ -294,11 +302,11 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
try {
|
||||
await deliverMail(ev, 'mail');
|
||||
} catch (e: any) {
|
||||
ctx.logger.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
|
||||
console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
ctx.logger.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
|
||||
console.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -440,6 +448,82 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 建会话(磁盘上已有则 resume)───
|
||||
|
||||
/**
|
||||
* 问持久化层:磁盘上是否已经有这条会话?
|
||||
*
|
||||
* `sessionMap` 是纯内存的,插件重启后为空,于是同一封邮件的续谈会走
|
||||
* 「新开会话」那条路,用回同一个 `mail-<session_id>` —— 而那个 id 上一次
|
||||
* 已经落过盘。只能问持久化层,因为这是重启后唯一还存在的事实来源。
|
||||
*
|
||||
* 读不到就当作不存在:`readSession` 在会话不存在、日志不可读、replay 校验
|
||||
* 不过时都会抛。三种情形里只有第一种适合 create,但后两种 resume 也一样
|
||||
* 救不回来 —— 那就让 create 去报它自己的错。
|
||||
*/
|
||||
async function persistedCwd(sessionId: string): Promise<string | undefined> {
|
||||
const q: any = (ctx as any).get?.('sessionQuery');
|
||||
if (!q?.readSession) return undefined;
|
||||
try {
|
||||
const snap = await q.readSession(sessionId);
|
||||
return snap?.header?.cwd ?? '';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启一个 agent:磁盘上没有这个 id 就 create,有就 resume。
|
||||
*
|
||||
* # 为何必须先探测,不能靠 try/catch
|
||||
*
|
||||
* id 冲突不是 `create` 报的:持久化是在**轮次进行中** flush 的,所以
|
||||
* `create` 会正常返回,错误到 `turn/end` 才以 `reason.kind === 'error'`
|
||||
* 冲出来(实测:`UNKNOWN: session "..." already has a persisted log on disk`)。
|
||||
* 把修法写成 catch 里改 resume 完全不会生效 —— 这与「模型失败不是同步抛出的」
|
||||
* 是同一类陷阱,只是上了一层。
|
||||
*
|
||||
* # 为何 resume 而不是换一个新 id
|
||||
*
|
||||
* 换 id 等于把之前的往来上下文丢掉,模型会重新问一遍已经问过的问题。
|
||||
* resume 把磁盘上那条会话装回来接着谈,这同时修掉了一个已知取舍:
|
||||
* 插件重启后续谈的邮件不再另开一条平台会话。
|
||||
*
|
||||
* resume 不接受 `meta`:cwd 取自持久化的 header。这正是想要的 —— 上一次在哪个
|
||||
* 目录,就继续在那儿;传一个不同的 cwd 只会得到
|
||||
* `is already persisted at a different cwd` 而不是“改目录”。
|
||||
*/
|
||||
async function startAgent(
|
||||
sessionId: string, cwd: string, route: any,
|
||||
): Promise<{ handle: any; resumed: boolean }> {
|
||||
// route 为 undefined 表示不指定模型,交给平台自己选
|
||||
const agentOptions = route ? { provider: route.provider, model: route.model } : {};
|
||||
|
||||
const onDisk = await persistedCwd(sessionId);
|
||||
if (onDisk !== undefined) {
|
||||
// 用 console.error 而不是 ctx.logger.info:后者不进 journalctl(实测),
|
||||
// 而这条是排查「邮件投不进去」时唯一能看到的线索。
|
||||
console.error(`[dsh-mail-bridge] 会话 ${sessionId} 已在磁盘上(cwd=${onDisk || '未记录'}),改为 resume 续谈`);
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId as any,
|
||||
agentOptions,
|
||||
setup: undefined,
|
||||
});
|
||||
return { handle, resumed: true };
|
||||
}
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId,
|
||||
meta: { cwd },
|
||||
agentOptions,
|
||||
// setup 留空:DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
|
||||
// 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或
|
||||
// installModelSelection 反而会让 turn 崩溃(实测)。
|
||||
setup: undefined,
|
||||
});
|
||||
return { handle, resumed: false };
|
||||
}
|
||||
|
||||
// ─── 投递邮件到 DSH 会话 ───
|
||||
|
||||
async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> {
|
||||
@ -517,20 +601,13 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
let handle: any;
|
||||
|
||||
try {
|
||||
handle = await ctx.agents.create({
|
||||
sessionId: attemptSessionId,
|
||||
meta: { cwd },
|
||||
// route 为 undefined 表示不指定模型,交给平台自己选
|
||||
agentOptions: route ? { provider: route.provider, model: route.model } : {},
|
||||
// setup 留空:DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
|
||||
// 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或
|
||||
// installModelSelection 反而会让 turn 崩溃(实测)。
|
||||
setup: undefined,
|
||||
});
|
||||
const started = await startAgent(attemptSessionId, cwd, route);
|
||||
handle = started.handle;
|
||||
} catch (e: any) {
|
||||
// create 本身很少失败(它不校验模型),但会话 id 冲突之类仍会抛
|
||||
// create/resume 本身很少失败(create 不校验模型),
|
||||
// 但 cwd 不符、日志 replay 不过之类仍会抛
|
||||
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
|
||||
ctx.logger.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
|
||||
console.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -553,13 +630,13 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
|
||||
if (outcome.ok) {
|
||||
if (failures.length > 0) {
|
||||
ctx.logger.info(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
|
||||
console.error(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
|
||||
}
|
||||
return { sessionID: attemptSessionId, reused: false };
|
||||
}
|
||||
|
||||
failures.push({ ...(route ?? {}), error: outcome.error });
|
||||
ctx.logger.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
|
||||
console.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
|
||||
// 拆掉这一路的 agent 与映射,否则它会占着会话 id,
|
||||
// 而 agent/status 还会为这个死会话触发一次自动转发
|
||||
reverseMap.delete(attemptSessionId);
|
||||
@ -693,7 +770,9 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
|
||||
// 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关,
|
||||
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
|
||||
const listed = renderInbox(mails);
|
||||
//
|
||||
// 传 AGENT_NAME 才能判定「我是收件人还是抄送方」并给出可投递地址。
|
||||
const listed = renderInbox(mails, 200, AGENT_NAME);
|
||||
|
||||
// 读过就标掉,否则每次拉收件箱都重复捞同一批,
|
||||
// 处理过的和新来的混在一起,模型分不清哪封该回。
|
||||
@ -722,10 +801,21 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
async execute(args: any): Promise<string> {
|
||||
const data = await readFile(args.file_path);
|
||||
const filename = args.file_path.split('/').pop() || 'file';
|
||||
// **必须发真正的 multipart。**
|
||||
//
|
||||
// 早先这里发的是 `Content-Type: application/octet-stream` 加一个
|
||||
// `X-Filename` 头,而服务端走 `ParseMultipartForm` + `FormFile("file")` ——
|
||||
// 于是 **这个工具从来没成功过一次**,每次都回「解析 multipart 失败」。
|
||||
// 模型甚至把它当成了文件存在性探针(存在→报 multipart 错、
|
||||
// 不存在→ENOENT),那是对症状的准确利用,但不是它应该做的事。
|
||||
//
|
||||
// 不设 Content-Type:交给 FormData 自己带 boundary,手写的一定对不上。
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([data]), filename);
|
||||
const res = await fetch(`${client.baseURL}/api/v1/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { ...client.authHeaders(), 'Content-Type': 'application/octet-stream', 'X-Filename': filename },
|
||||
body: data,
|
||||
headers: client.authHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const json = await res.json() as any;
|
||||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
@ -757,8 +847,185 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── 寻址发现工具(读 Agent 侧只读端点)───
|
||||
//
|
||||
// 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
|
||||
// 而拼错不报错:生产上本插件猜了 `opencode@/home`,投递成功,
|
||||
// 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'suggest_address',
|
||||
description:
|
||||
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的工作目录;' +
|
||||
'name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,' +
|
||||
'不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。',
|
||||
parameters: {
|
||||
name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' },
|
||||
path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
const name = String(args.name || '').trim();
|
||||
const path = String(args.path || '').trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (name) qs.set('name', name);
|
||||
if (path) qs.set('path', path);
|
||||
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
|
||||
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
|
||||
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
|
||||
switch (data?.kind) {
|
||||
case 'name': return renderNameSuggestions(data.suggestions);
|
||||
case 'path': return renderPathSuggestions(data.suggestions, name);
|
||||
default: return renderSessionSuggestions(data, name, path);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'list_contacts',
|
||||
description:
|
||||
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
|
||||
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
|
||||
parameters: {
|
||||
limit: { type: 'number', description: '最多列出多少条,默认 20' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
const data = await client.get('/agent/contacts');
|
||||
return renderContacts(data, args.limit || 20);
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'session_participants',
|
||||
description:
|
||||
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' +
|
||||
'并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。',
|
||||
parameters: {
|
||||
session_id: { type: 'string', required: true, description: '会话 ID' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
const data = await client.get(`/agent/sessions/${args.session_id}/participants`);
|
||||
return renderParticipants(data);
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read_thread',
|
||||
description:
|
||||
'查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' +
|
||||
'用它确认别人已经说了什么,避免重复提问或重复汇报。',
|
||||
parameters: {
|
||||
mail_id: { type: 'string', required: true, description: '线索中任一封邮件的 ID' },
|
||||
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
const qs = args.offset ? `?offset=${args.offset}` : '';
|
||||
const data = await client.get(`/agent/mail/${args.mail_id}/thread${qs}`);
|
||||
return renderThread(data, AGENT_NAME);
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read_mail',
|
||||
description:
|
||||
'读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' +
|
||||
'收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。',
|
||||
parameters: {
|
||||
mail_id: { type: 'string', required: true, description: '邮件 ID' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
const data = await client.get(`/agent/mail/${args.mail_id}`);
|
||||
const m = data?.mail || {};
|
||||
const lines = [
|
||||
`发件人: ${m.from_name || '?'}`,
|
||||
`收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`,
|
||||
`主题: ${m.subject || '(无主题)'}`,
|
||||
`会话: #${data.session_alias || '未命名'}(session_id: ${m.session_id || '?'})`,
|
||||
];
|
||||
if (Array.isArray(m.cc_list) && m.cc_list.length) {
|
||||
lines.push(`抄送: ${m.cc_list.map((c: any) => c?.raw || c?.name).join('、')}`);
|
||||
}
|
||||
if (Array.isArray(m.attachments) && m.attachments.length) {
|
||||
lines.push(`附件: ${m.attachments
|
||||
.map((a: any) => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
|
||||
.join('、')}`);
|
||||
}
|
||||
lines.push('', m.body || '(空正文)', '');
|
||||
if (Array.isArray(data.participants) && data.participants.length) {
|
||||
lines.push('可投递地址: ' + data.participants
|
||||
.filter((p: any) => p.address && p.name !== AGENT_NAME)
|
||||
.map((p: any) => `${p.address}(${p.role})`)
|
||||
.join('、'));
|
||||
}
|
||||
if (data.reply_address) {
|
||||
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
},
|
||||
}));
|
||||
|
||||
// forward_mail —— 转发给新收件人。
|
||||
//
|
||||
// 之前 DSH 侧缺这个工具(opencode 侧一直有),于是本平台上「把这封信
|
||||
// 转给某人」只能退化成 send_mail 重抄一遍正文 —— 丢掉附件、丢掉
|
||||
// parent_mail_id,对话树上也看不出这条新线索从何而来。
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'forward_mail',
|
||||
description:
|
||||
'转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,' +
|
||||
'转发按目标地址另行定位会话(它是一条新线索)。只能转发自己参与过的邮件。',
|
||||
parameters: {
|
||||
mail_id: { type: 'string', required: true, description: '要转发的邮件 ID' },
|
||||
to: { type: 'string', required: true, description: '新收件人的三维地址(先用 suggest_address 确认)' },
|
||||
comment: { type: 'string', description: '转发说明,置于引用原文之前' },
|
||||
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
|
||||
subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' },
|
||||
session_alias: { type: 'string', description: '仅当目标地址以 .new 结尾时生效:给新会话命名' },
|
||||
},
|
||||
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/${args.mail_id}/forward`, {
|
||||
to: args.to,
|
||||
comment: args.comment || '',
|
||||
cc: args.cc || '',
|
||||
subject: args.subject || '',
|
||||
session_alias: args.session_alias || '',
|
||||
});
|
||||
// 转发也是一次「模型亲手发信」,要计入 explicitSends,
|
||||
// 否则本轮结束时自动转发会再把同一段话发一遍。
|
||||
noteExplicitSend(toolCtx?.sessionID, args.to, '');
|
||||
return `已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`;
|
||||
},
|
||||
}));
|
||||
|
||||
return () => {
|
||||
for (const n of ['send_mail', 'read_inbox', 'upload_attachment', 'download_attachment']) {
|
||||
for (const n of [
|
||||
'send_mail', 'read_inbox', 'read_mail', 'forward_mail',
|
||||
'upload_attachment', 'download_attachment',
|
||||
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
|
||||
]) {
|
||||
try { ctx.tools.unregister(n); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
145
plugins/dsh-mail-bridge/test/addressing.test.mjs
Normal file
145
plugins/dsh-mail-bridge/test/addressing.test.mjs
Normal file
@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatAddress,
|
||||
roleOf,
|
||||
replyAddressFor,
|
||||
selfAddressFor,
|
||||
participantsOfMail,
|
||||
} from '../lib/addressing.js';
|
||||
|
||||
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
|
||||
// 「拼出来的东西还能不能被正确解析回三段」上。
|
||||
|
||||
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
|
||||
// 生产事故:朴素拼接得到 admin.silent-harbor,没有 @,
|
||||
// 整串被 ParseAddress 当成名字,session 位静默丢失。
|
||||
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('formatAddress: 省略 session 位', () => {
|
||||
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
|
||||
// 名字与 path 都有但都不带会话 → 默认会话语义
|
||||
assert.equal(formatAddress('dsh', '', ''), 'dsh');
|
||||
});
|
||||
|
||||
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
|
||||
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
|
||||
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
|
||||
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
|
||||
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
|
||||
});
|
||||
|
||||
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
|
||||
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
|
||||
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
|
||||
assert.equal(formatAddress('', '/p', 'a'), '');
|
||||
assert.equal(formatAddress(null, '/p', 'a'), '');
|
||||
});
|
||||
|
||||
test('formatAddress: 去掉首尾空白', () => {
|
||||
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
|
||||
});
|
||||
|
||||
const ccMail = {
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
};
|
||||
|
||||
test('roleOf: 区分主收件人与抄送方', () => {
|
||||
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
|
||||
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
|
||||
assert.equal(roleOf(ccMail, 'dsh'), 'to');
|
||||
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
|
||||
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
|
||||
});
|
||||
|
||||
test('roleOf: 名字为空时不猜', () => {
|
||||
assert.equal(roleOf(ccMail, ''), 'unknown');
|
||||
assert.equal(roleOf(ccMail, undefined), 'unknown');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
|
||||
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
|
||||
const addr = replyAddressFor(ccMail);
|
||||
assert.equal(addr, 'admin@.silent-harbor');
|
||||
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 发件人一侧不带 path', () => {
|
||||
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
|
||||
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
|
||||
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
|
||||
assert.equal(replyAddressFor(mail), 'dsh@.x');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 无别名时退回默认会话形式', () => {
|
||||
const mail = { from_name: 'admin', session_alias: '' };
|
||||
const addr = replyAddressFor(mail);
|
||||
assert.equal(addr, 'admin');
|
||||
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
|
||||
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
|
||||
});
|
||||
|
||||
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
|
||||
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path,
|
||||
// 「我是谁」这句话就指向了别人的目录。
|
||||
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
|
||||
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
|
||||
|
||||
assert.equal(byName.opencode.path, '/home');
|
||||
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
|
||||
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
|
||||
// 发件人 path 留空,理由同 replyAddressFor
|
||||
assert.equal(byName.admin.address, 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
|
||||
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名,
|
||||
// 否则「回给抄收方」这个动作每次都会新开会话。
|
||||
for (const p of participantsOfMail(ccMail, 'dsh')) {
|
||||
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('participantsOfMail: 自己被标记而不是被剔除', () => {
|
||||
// 剔掉的话模型无法确认这封信是不是也发给了自己,
|
||||
// 也就无法判断自己该不该回。
|
||||
const parts = participantsOfMail(ccMail, 'opencode');
|
||||
const me = parts.find(p => p.name === 'opencode');
|
||||
assert.ok(me, '自己应出现在参与方列表里');
|
||||
assert.equal(me.is_self, true);
|
||||
assert.equal(parts.filter(p => p.is_self).length, 1);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
|
||||
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 无抄送时只有两方', () => {
|
||||
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 跳过空名字条目', () => {
|
||||
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
|
||||
const mail = {
|
||||
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
|
||||
cc_list: [{ name: '', path: '/x' }, {}],
|
||||
session_alias: 'a',
|
||||
};
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
for (const p of parts) assert.notEqual(p.address, '');
|
||||
});
|
||||
218
plugins/dsh-mail-bridge/test/discovery.test.mjs
Normal file
218
plugins/dsh-mail-bridge/test/discovery.test.mjs
Normal file
@ -0,0 +1,218 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
|
||||
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
|
||||
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
|
||||
|
||||
test('renderNameSuggestions 只给名字并指向下一步', () => {
|
||||
// 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」——
|
||||
// 那不一定是调用方想要的那条。
|
||||
const got = renderNameSuggestions(['opencode', 'admin']);
|
||||
assert.match(got, /opencode/);
|
||||
assert.match(got, /admin/);
|
||||
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
|
||||
});
|
||||
|
||||
test('renderNameSuggestions 空列表给明确文案', () => {
|
||||
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
|
||||
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
|
||||
// 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
const got = renderPathSuggestions([], 'admin');
|
||||
assert.match(got, /可以留空/);
|
||||
assert.match(got, /admin/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 列出目录并指向下一步', () => {
|
||||
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
|
||||
assert.match(got, /\/home\/program\/agentmail/);
|
||||
assert.match(got, /最近使用/);
|
||||
assert.match(got, /suggest_address\(name="opencode", path="/);
|
||||
});
|
||||
|
||||
const sessionData = {
|
||||
kind: 'session',
|
||||
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
|
||||
addresses: [
|
||||
'opencode@/home.silent-harbor',
|
||||
'opencode@/home.happy-tiger',
|
||||
'opencode@/home.new',
|
||||
],
|
||||
candidates: [
|
||||
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
|
||||
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
|
||||
{ alias: 'new', title: '新建会话', source: 'new' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
|
||||
// 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions
|
||||
// 同序由服务端保证,直接用。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /opencode@\/home\.happy-tiger/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 带出标题与未读数', () => {
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /联调 llmsproxy/);
|
||||
assert.match(got, /2 封未读/);
|
||||
});
|
||||
|
||||
test('不变量:new 不与已存在会话混列,且带警告', () => {
|
||||
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
const lines = got.split('\n');
|
||||
const newLineIdx = lines.findIndex(l => l.includes('.new'));
|
||||
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
|
||||
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
|
||||
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
|
||||
// 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。
|
||||
// 不传的话服务端会自动命名,但模型不知道那个名字。
|
||||
const got = renderSessionSuggestions(
|
||||
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
|
||||
'dsh', '/tmp',
|
||||
);
|
||||
assert.match(got, /还没有可续谈的会话/);
|
||||
assert.match(got, /session_alias/);
|
||||
});
|
||||
|
||||
const participantData = {
|
||||
session_id: 'f3d824ce',
|
||||
session_alias: 'silent-harbor',
|
||||
participants: [
|
||||
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
|
||||
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
|
||||
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderParticipants 给出每个参与方的地址', () => {
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /admin@\.silent-harbor/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('不变量:标出「尚未回应」的人', () => {
|
||||
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
|
||||
// 正是为了让这个判断成立。
|
||||
const got = renderParticipants(participantData);
|
||||
const line = got.split('\n').find(l => l.includes('opencode'));
|
||||
assert.match(line, /尚未回应/);
|
||||
// 自己不该被标「尚未回应」—— 自己正在处理这封
|
||||
const selfLine = got.split('\n').find(l => l.includes('dsh'));
|
||||
assert.ok(!selfLine.includes('尚未回应'));
|
||||
assert.match(selfLine, /就是你/);
|
||||
});
|
||||
|
||||
test('renderParticipants 用中文角色标签', () => {
|
||||
// 模型读到「抄送方」比读到 cc 更容易判对分工。
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /抄送方/);
|
||||
assert.match(got, /发件人/);
|
||||
});
|
||||
|
||||
test('renderParticipants 无地址时说明原因', () => {
|
||||
const got = renderParticipants({
|
||||
session_alias: '',
|
||||
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
|
||||
});
|
||||
assert.match(got, /尚未命名/);
|
||||
});
|
||||
|
||||
test('renderParticipants 空会话不崩', () => {
|
||||
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
|
||||
assert.match(renderParticipants({}), /还没有参与方/);
|
||||
});
|
||||
|
||||
test('renderContacts 未读优先排序', () => {
|
||||
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
|
||||
const got = renderContacts({
|
||||
contacts: [
|
||||
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
|
||||
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
|
||||
],
|
||||
});
|
||||
const lines = got.split('\n').filter(l => l.startsWith('- '));
|
||||
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
|
||||
assert.match(lines[0], /3 封未读/);
|
||||
});
|
||||
|
||||
test('renderContacts 带出剩余预算', () => {
|
||||
const got = renderContacts({
|
||||
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
|
||||
});
|
||||
assert.match(got, /剩 3\/20 个来回/);
|
||||
});
|
||||
|
||||
test('renderContacts 未命名会话说明只能 reply_to', () => {
|
||||
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
|
||||
assert.match(got, /reply_to/);
|
||||
});
|
||||
|
||||
test('renderContacts 空列表', () => {
|
||||
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
|
||||
});
|
||||
|
||||
const threadData = {
|
||||
anchor_mail_id: 'm-2',
|
||||
total: 3,
|
||||
hidden: 1,
|
||||
nodes: [
|
||||
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
|
||||
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
|
||||
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderThread 用缩进表示层级', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const lines = got.split('\n');
|
||||
const l1 = lines.find(l => l.includes('m-1'));
|
||||
const l2 = lines.find(l => l.includes('m-2'));
|
||||
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
|
||||
});
|
||||
|
||||
test('不变量:detached 必须标出来', () => {
|
||||
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const line = got.split('\n').find(l => l.includes('m-3'));
|
||||
assert.match(line, /父邮件无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 标出自己发的与当前这封', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
|
||||
});
|
||||
|
||||
test('renderThread 报告不可见数量', () => {
|
||||
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
|
||||
// 不说的话模型会以为自己看到了全貌。
|
||||
assert.match(renderThread(threadData), /另有 1 封无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 有更多时给出 offset', () => {
|
||||
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
|
||||
assert.match(got, /offset=60/);
|
||||
});
|
||||
|
||||
test('renderThread 空线索不崩', () => {
|
||||
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
|
||||
assert.match(renderThread({}), /没有可见的邮件/);
|
||||
});
|
||||
@ -117,6 +117,96 @@ test('附件字段不是数组时忽略', () => {
|
||||
assert.ok(!got.includes('抄送'));
|
||||
});
|
||||
|
||||
// ─── 收件人与身份(只有知道自己是谁才能判定)───
|
||||
|
||||
test('不变量:收件人要显示出来', () => {
|
||||
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
|
||||
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
|
||||
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
|
||||
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
|
||||
});
|
||||
|
||||
test('收件人无工作目录时只显名字', () => {
|
||||
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
|
||||
assert.match(got, /收件人: admin$/m);
|
||||
});
|
||||
|
||||
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
|
||||
const got = renderMail(mail({ to_name: 'dsh' }));
|
||||
assert.ok(!got.includes('你的身份'));
|
||||
});
|
||||
|
||||
test('不变量:区分收件人与抄送方身份', () => {
|
||||
// 两者职责不同。不区分的话两方都会以为自己是负责人,
|
||||
// 或者都以为自己只是旁观者。
|
||||
const m = mail({
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
|
||||
});
|
||||
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
|
||||
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
|
||||
// 不相关的名字不编造身份
|
||||
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
|
||||
});
|
||||
|
||||
// ─── 可投递地址(「精准发信」的关键)───
|
||||
|
||||
const joint = () => mail({
|
||||
mail_id: 'm-7',
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
});
|
||||
|
||||
test('不变量:给出每个参与方的可投递地址', () => {
|
||||
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`,
|
||||
// 而那个地址回过去只会再建一条平行会话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /可投递地址/);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor(抄送方)/);
|
||||
assert.match(got, /admin@\.silent-harbor(发件人)/);
|
||||
});
|
||||
|
||||
test('不变量:可投递地址里绝不出现 .new', () => {
|
||||
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
|
||||
// 把它当回信地址会让双方各说各话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(line, '应有可投递地址行');
|
||||
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
|
||||
});
|
||||
|
||||
test('可投递地址不列自己', () => {
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
|
||||
});
|
||||
|
||||
test('同时给出 reply_to 这条更稳的路', () => {
|
||||
// 地址可能拼错,reply_to 不会 —— 两条路都告诉模型。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /reply_to=m-7/);
|
||||
});
|
||||
|
||||
test('无会话别名时不给地址(宁可不给不可给错)', () => {
|
||||
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
|
||||
// 实际指向默认会话的地址,比不给危险。
|
||||
const got = renderMail(mail({
|
||||
to_name: 'dsh', session_alias: '',
|
||||
cc_list: [{ name: 'opencode', path: '/home' }],
|
||||
}), 200, 'dsh');
|
||||
assert.ok(!got.includes('可投递地址'));
|
||||
});
|
||||
|
||||
test('renderInbox 透传 selfName', () => {
|
||||
const got = renderInbox([joint()], 200, 'opencode');
|
||||
assert.match(got, /你的身份: 抄送方/);
|
||||
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor(收件人)/);
|
||||
});
|
||||
|
||||
// ─── renderInbox ───
|
||||
|
||||
test('renderInbox 空收件箱给明确文案', () => {
|
||||
|
||||
@ -12,6 +12,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeModels,
|
||||
snapshotDshModels,
|
||||
snapshotPiModels,
|
||||
modelAttemptOrder,
|
||||
renderFailureReport,
|
||||
MAX_CATALOG,
|
||||
@ -106,6 +107,46 @@ test('目录截断到 MAX_CATALOG', () => {
|
||||
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── pi 目录 ───
|
||||
|
||||
test('pi 目录用 provider + id', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
|
||||
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.deepEqual(got[1], {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
display_name: 'Claude Sonnet 4.6',
|
||||
});
|
||||
});
|
||||
|
||||
test('pi 目录跳过缺 provider 或 id 的条目', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: '', id: 'x' },
|
||||
{ provider: 'p' },
|
||||
{ provider: 'p', id: 'ok' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].model, 'ok');
|
||||
});
|
||||
|
||||
test('pi 目录容错:非数组不崩', () => {
|
||||
assert.deepEqual(snapshotPiModels(undefined), []);
|
||||
assert.deepEqual(snapshotPiModels(null), []);
|
||||
assert.deepEqual(snapshotPiModels('oops'), []);
|
||||
});
|
||||
|
||||
test('pi 目录同样受 MAX_CATALOG 截断', () => {
|
||||
// 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。
|
||||
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
|
||||
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
|
||||
provider: 'p', id: `m${i}`, name: `M${i}`,
|
||||
}));
|
||||
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── modelAttemptOrder ───
|
||||
|
||||
test('管理员划定范围时按 rank 顺序尝试', () => {
|
||||
|
||||
@ -12,6 +12,8 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeSessions,
|
||||
snapshotDshSessions,
|
||||
snapshotPiSessions,
|
||||
isUnusableName,
|
||||
slugFromTitle,
|
||||
MAX_REPORTED,
|
||||
} from '../lib/session-snapshot.js';
|
||||
@ -226,3 +228,99 @@ test('不同标题不受去重影响', () => {
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
});
|
||||
|
||||
// ─── pi:名字来自会话文件的 session_info ───
|
||||
|
||||
const piSession = (over = {}) => ({
|
||||
id: '01a064cc-df57-7b2d-bebb-736776105485',
|
||||
cwd: '/home/program/agentmail',
|
||||
name: '重构导入路径',
|
||||
messageCount: 6,
|
||||
created: new Date(1788300000000),
|
||||
modified: new Date(1788344476744),
|
||||
...over,
|
||||
});
|
||||
|
||||
test('pi 快照取 cwd 与 session_info 名字', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.workspace, '/home/program/agentmail');
|
||||
assert.equal(got.title, '重构导入路径');
|
||||
assert.equal(got.slug, '重构导入路径');
|
||||
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
|
||||
});
|
||||
|
||||
test('不变量:pi 无名会话不上报', () => {
|
||||
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
|
||||
// (「你收到一封新邮件(AgentMail)…」)—— 拿它当别名毫无区分度,且条条撞名。
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'named', name: '有名字' }),
|
||||
piSession({ id: 'anon', name: undefined }),
|
||||
piSession({ id: 'blank', name: '' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['named']);
|
||||
});
|
||||
|
||||
test('不变量:pi 老会话的空 cwd 照实上报', () => {
|
||||
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
|
||||
// 那条会话在补全里挂到一个它其实不属于的工作区下。
|
||||
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
|
||||
assert.equal(got.workspace, '');
|
||||
});
|
||||
|
||||
test('不变量:pi 的 updated_at 取 modified(文件 mtime)', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
|
||||
});
|
||||
|
||||
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
|
||||
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'new');
|
||||
});
|
||||
|
||||
test('pi 快照标记邮件驱动的会话', () => {
|
||||
const got = snapshotPiSessions(
|
||||
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
|
||||
(id) => id === 'mail-one'
|
||||
);
|
||||
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
|
||||
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
|
||||
});
|
||||
|
||||
// ─── isUnusableName:pi-web 标题生成器的思维链泄漏 ───
|
||||
|
||||
test('不变量:思维链泄漏的标题判废', () => {
|
||||
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
|
||||
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
|
||||
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
|
||||
assert.equal(
|
||||
isUnusableName('我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:Opencode源测试。或者更简'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isUnusableName 放过正常标题', () => {
|
||||
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
|
||||
assert.equal(isUnusableName('查看Agent接入群聊'), false);
|
||||
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
|
||||
assert.equal(isUnusableName('homeagent-gateway'), false);
|
||||
assert.equal(isUnusableName('重构导入路径'), false);
|
||||
assert.equal(isUnusableName('Fix flaky auth test'), false);
|
||||
});
|
||||
|
||||
test('isUnusableName 判废空名字', () => {
|
||||
assert.equal(isUnusableName(''), true);
|
||||
assert.equal(isUnusableName(' '), true);
|
||||
assert.equal(isUnusableName(undefined), true);
|
||||
});
|
||||
|
||||
test('判废的名字不进快照', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
|
||||
piSession({ id: 'clean', name: '正常标题' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
|
||||
});
|
||||
|
||||
@ -20,6 +20,14 @@ import {
|
||||
DEFAULT_INBOX_STATUS,
|
||||
DEFAULT_INBOX_LIMIT,
|
||||
} from "./lib/inbox-format.js";
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from "./lib/discovery.js";
|
||||
import {
|
||||
explicitSends,
|
||||
noteExplicitSend,
|
||||
@ -242,7 +250,11 @@ const readInboxTool = {
|
||||
|
||||
// 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关,
|
||||
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
|
||||
const listed = renderInbox(data.mails);
|
||||
//
|
||||
// 传 AGENT_NAME 是为了让渲染能判定「我是收件人还是抄送方」并给出
|
||||
// 可投递地址 —— 不传的话模型只能从抄送行里抄一个 `.new`,而那是
|
||||
// 一次性的,回过去只会再建一条平行会话。
|
||||
const listed = renderInbox(data.mails, 200, AGENT_NAME);
|
||||
|
||||
const ids = idsToMarkRead(args.filter, data.mails);
|
||||
if (ids.length) {
|
||||
@ -317,6 +329,130 @@ const downloadAttachmentTool = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 寻址发现工具(读 Agent 侧只读端点)───
|
||||
//
|
||||
// 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段。人类侧
|
||||
// 从来不是这样:三段式输入框逐段查候选。Agent 只能猜,而猜错不报错 ——
|
||||
// 生产上 dsh 猜了 `opencode@/home`,投递成功,但那不是 opencode 的工作目录,
|
||||
// 那个错误路径静默变成了新会话的 workspace。
|
||||
//
|
||||
// 渲染逻辑在 lib/discovery.js(与平台 SDK 无关,三平台共用)。
|
||||
|
||||
const suggestAddressTool = {
|
||||
description:
|
||||
"查询可用的收件人地址,用于精准发信。分三段逐步查:不带参数给候选收件人名;" +
|
||||
"带 name 给它可用的工作目录;name+path 都带则给该目录下可续谈的会话别名与现成地址。" +
|
||||
"**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。",
|
||||
args: {
|
||||
name: z.string().optional().describe("收件人名;留空则列出所有候选收件人"),
|
||||
path: z.string().optional().describe("工作目录;与 name 同时给出才列会话"),
|
||||
},
|
||||
async execute(args) {
|
||||
const name = (args.name || "").trim();
|
||||
const path = (args.path || "").trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (name) qs.set("name", name);
|
||||
if (path) qs.set("path", path);
|
||||
const data = await apiGet(`/agent/contacts/suggest?${qs.toString()}`);
|
||||
|
||||
// 按服务端回的 kind 分派,而不是按本地参数判断:省略 path 与传空串在
|
||||
// 服务端是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
|
||||
switch (data?.kind) {
|
||||
case "name":
|
||||
return renderNameSuggestions(data.suggestions);
|
||||
case "path":
|
||||
return renderPathSuggestions(data.suggestions, name);
|
||||
default:
|
||||
return renderSessionSuggestions(data, name, path);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const listContactsTool = {
|
||||
description:
|
||||
"列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。" +
|
||||
"用于回答「我还有什么没处理」以及「上次跟某人聊的那条线索地址是什么」。",
|
||||
args: {
|
||||
limit: z.number().optional().describe("最多列出多少条,默认 20"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet("/agent/contacts");
|
||||
return renderContacts(data, args.limit || 20);
|
||||
},
|
||||
};
|
||||
|
||||
const sessionParticipantsTool = {
|
||||
description:
|
||||
"列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址," +
|
||||
"并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
|
||||
args: {
|
||||
session_id: z.string().describe("会话 ID(read_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet(`/agent/sessions/${args.session_id}/participants`);
|
||||
return renderParticipants(data);
|
||||
},
|
||||
};
|
||||
|
||||
const readThreadTool = {
|
||||
description:
|
||||
"查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时" +
|
||||
"用它确认别人已经说了什么,避免重复提问或重复汇报。",
|
||||
args: {
|
||||
mail_id: z.string().describe("线索中任一封邮件的 ID"),
|
||||
offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"),
|
||||
},
|
||||
async execute(args) {
|
||||
const qs = args.offset ? `?offset=${args.offset}` : "";
|
||||
const data = await apiGet(`/agent/mail/${args.mail_id}/thread${qs}`);
|
||||
return renderThread(data, AGENT_NAME);
|
||||
},
|
||||
};
|
||||
|
||||
const readMailTool = {
|
||||
description:
|
||||
"读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。" +
|
||||
"收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。",
|
||||
args: {
|
||||
mail_id: z.string().describe("邮件 ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet(`/agent/mail/${args.mail_id}`);
|
||||
const m = data?.mail || {};
|
||||
const lines = [
|
||||
`发件人: ${m.from_name || "?"}`,
|
||||
`收件人: ${m.to_name || "?"}${m.to_workspace ? "@" + m.to_workspace : ""}`,
|
||||
`主题: ${m.subject || "(无主题)"}`,
|
||||
`会话: #${data.session_alias || "未命名"}(session_id: ${m.session_id || "?"})`,
|
||||
];
|
||||
if (Array.isArray(m.cc_list) && m.cc_list.length) {
|
||||
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join("、")}`);
|
||||
}
|
||||
if (Array.isArray(m.attachments) && m.attachments.length) {
|
||||
lines.push(
|
||||
`附件: ${m.attachments
|
||||
.map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
|
||||
.join("、")}`
|
||||
);
|
||||
}
|
||||
lines.push("", m.body || "(空正文)", "");
|
||||
// 参与方地址由服务端拼好(session 位已是真实别名,不是 .new)
|
||||
if (Array.isArray(data.participants) && data.participants.length) {
|
||||
lines.push(
|
||||
"可投递地址: " +
|
||||
data.participants
|
||||
.filter(p => p.address && p.name !== AGENT_NAME)
|
||||
.map(p => `${p.address}(${p.role})`)
|
||||
.join("、")
|
||||
);
|
||||
}
|
||||
if (data.reply_address) {
|
||||
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
},
|
||||
};
|
||||
|
||||
// 平台原生权限询问 → 邮件。
|
||||
//
|
||||
// **不作为工具暴露给模型**:opencode 自己就有权限机制(permission.ask 钩子 /
|
||||
@ -1052,10 +1188,16 @@ export default async function mailBridge(input) {
|
||||
tool: {
|
||||
send_mail: sendMailTool,
|
||||
read_inbox: readInboxTool,
|
||||
read_mail: readMailTool,
|
||||
forward_mail: forwardMailTool,
|
||||
upload_attachment: uploadAttachmentTool,
|
||||
download_attachment: downloadAttachmentTool,
|
||||
connect_to_server: connectToServerTool,
|
||||
// 寻址发现:让模型选地址而不是拼地址
|
||||
suggest_address: suggestAddressTool,
|
||||
list_contacts: listContactsTool,
|
||||
session_participants: sessionParticipantsTool,
|
||||
read_thread: readThreadTool,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
141
plugins/opencode-mail-bridge/lib/addressing.js
Normal file
141
plugins/opencode-mail-bridge/lib/addressing.js
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 三维寻址的构造与判读 —— 所有平台插件共用。
|
||||
*
|
||||
* 为什么这些函数必须共用、且必须是纯函数:
|
||||
*
|
||||
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
|
||||
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
|
||||
* 生产上真实发生过两次:
|
||||
*
|
||||
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
|
||||
* 再建一条平行会话,双方从此各说各话。
|
||||
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`,
|
||||
* 整串被当成名字,session 位静默丢失。
|
||||
*
|
||||
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 拼一个可寻址的 `name@path.session`。
|
||||
*
|
||||
* **空 path 也必须留下 `@` 与 `.`**:`admin@.silent-harbor` 才解析成
|
||||
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
|
||||
* `admin.silent-harbor` 会被整串当作名字。
|
||||
*
|
||||
* session 省略时不写那一位(默认会话语义)。
|
||||
*
|
||||
* @param {string} name 收件方名(Agent 名或人类用户名)
|
||||
* @param {string} [path] 工作目录,可为空
|
||||
* @param {string} [session] 会话别名;空则省略该位
|
||||
* @returns {string} 地址,name 为空时返回空串
|
||||
*/
|
||||
export function formatAddress(name, path, session) {
|
||||
const n = String(name ?? '').trim();
|
||||
const p = String(path ?? '').trim();
|
||||
const s = String(session ?? '').trim();
|
||||
if (!n) return '';
|
||||
if (!s) return p ? `${n}@${p}` : n;
|
||||
return `${n}@${p}.${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断自己在这封邮件里是收件人还是抄送方。
|
||||
*
|
||||
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
|
||||
* admin 主发 dsh、抄送 opencode,分工是「dsh 提供源码解读、opencode 提供部署
|
||||
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
|
||||
* 或者都以为自己只是旁观者。
|
||||
*
|
||||
* @param {any} mail `/mail/inbox` 返回的一封邮件
|
||||
* @param {string} selfName 自己的 Agent 名
|
||||
* @returns {'to'|'cc'|'unknown'}
|
||||
*/
|
||||
export function roleOf(mail, selfName) {
|
||||
const self = String(selfName ?? '').trim();
|
||||
if (!self) return 'unknown';
|
||||
if (mail?.to_name === self) return 'to';
|
||||
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
|
||||
return 'cc';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 给出「把回信发回这条会话」的地址。
|
||||
*
|
||||
* 发件人一侧**不带 path**:Agent 回信时 `from_workspace` 存的是 Agent 名而不是
|
||||
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
|
||||
* 人类发件人本来就没有工作目录。
|
||||
*
|
||||
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
|
||||
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {string}
|
||||
*/
|
||||
export function replyAddressFor(mail, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
return formatAddress(mail?.from_name, '', a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
|
||||
*
|
||||
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
|
||||
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} selfName 自己的 Agent 名
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {string}
|
||||
*/
|
||||
export function selfAddressFor(mail, selfName, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
|
||||
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
|
||||
let path = mail?.to_workspace ?? '';
|
||||
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
|
||||
const mine = mail.cc_list.find(c => c?.name === selfName);
|
||||
if (mine) path = mine.path ?? '';
|
||||
}
|
||||
return formatAddress(selfName, path, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出这封邮件的全部参与方及各自可投递的地址。
|
||||
*
|
||||
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
|
||||
* 抄送方的 path 取它自己那个地址的 path 位。
|
||||
*
|
||||
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
|
||||
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} [selfName] 自己的名字,用于标记 is_self
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
|
||||
*/
|
||||
export function participantsOfMail(mail, selfName, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
const self = String(selfName ?? '').trim();
|
||||
const out = [];
|
||||
const add = (role, name, path) => {
|
||||
const n = String(name ?? '').trim();
|
||||
if (!n) return;
|
||||
out.push({
|
||||
role,
|
||||
name: n,
|
||||
path: String(path ?? ''),
|
||||
address: formatAddress(n, path, a),
|
||||
is_self: !!self && n === self,
|
||||
});
|
||||
};
|
||||
// 发件人一侧 path 留空,理由同 replyAddressFor
|
||||
add('from', mail?.from_name, '');
|
||||
add('to', mail?.to_name, mail?.to_workspace);
|
||||
if (Array.isArray(mail?.cc_list)) {
|
||||
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
237
plugins/opencode-mail-bridge/lib/discovery.js
Normal file
237
plugins/opencode-mail-bridge/lib/discovery.js
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
|
||||
*
|
||||
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
|
||||
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
|
||||
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
|
||||
*
|
||||
* # 这一组端点解决的问题
|
||||
*
|
||||
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
|
||||
* 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从
|
||||
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
|
||||
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
|
||||
* 那个错误路径静默变成了新会话的 workspace。
|
||||
*
|
||||
* # 渲染的取舍
|
||||
*
|
||||
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
|
||||
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
|
||||
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 渲染候选收件人清单(`kind: "name"`)。
|
||||
*
|
||||
* 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的
|
||||
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
|
||||
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
|
||||
*
|
||||
* @param {string[]} names
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderNameSuggestions(names) {
|
||||
const list = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||
if (list.length === 0) return '当前没有可投递的收件人。';
|
||||
return [
|
||||
`可投递的收件人(${list.length} 个):`,
|
||||
list.map(n => `- ${n}`).join('\n'),
|
||||
'',
|
||||
'下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染工作目录候选(`kind: "path"`)。
|
||||
*
|
||||
* 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录),
|
||||
* 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
*
|
||||
* @param {string[]} paths
|
||||
* @param {string} name 正在查的收件人名,用于拼下一步的提示
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderPathSuggestions(paths, name) {
|
||||
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
return [
|
||||
`${name} 没有记录在案的工作目录。`,
|
||||
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
|
||||
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`,
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`${name} 用过的工作目录(按最近使用排序):`,
|
||||
list.map(p => `- ${p}`).join('\n'),
|
||||
'',
|
||||
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染会话候选(`kind: "session"`)。
|
||||
*
|
||||
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`:
|
||||
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
|
||||
*
|
||||
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
|
||||
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
*
|
||||
* @param {object} data `/agent/contacts/suggest` 的返回体
|
||||
* @param {string} name
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderSessionSuggestions(data, name, path) {
|
||||
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
|
||||
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
|
||||
|
||||
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
|
||||
const existing = aliases.filter(a => a !== 'new');
|
||||
if (existing.length === 0) {
|
||||
return [
|
||||
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
|
||||
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`,
|
||||
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
|
||||
for (let i = 0; i < aliases.length; i++) {
|
||||
const alias = aliases[i];
|
||||
const addr = addresses[i] || '';
|
||||
const c = candidates[i] || {};
|
||||
if (alias === 'new') continue; // new 单独放最后
|
||||
const bits = [];
|
||||
if (c.title) bits.push(c.title);
|
||||
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
|
||||
if (c.source === 'platform') bits.push('平台侧会话');
|
||||
lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
|
||||
const newAddr = addressAt(addresses, aliases, 'new');
|
||||
if (newAddr) {
|
||||
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 按别名在同序的 addresses 里取地址。 */
|
||||
function addressAt(addresses, aliases, alias) {
|
||||
const i = aliases.indexOf(alias);
|
||||
return i >= 0 ? addresses[i] || '' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染会话参与方清单。
|
||||
*
|
||||
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
|
||||
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
|
||||
* 「作为发件人」的邮件,正是为了让这个判断成立。
|
||||
*
|
||||
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderParticipants(data) {
|
||||
const parts = Array.isArray(data?.participants) ? data.participants : [];
|
||||
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
|
||||
|
||||
const alias = data?.session_alias || '';
|
||||
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
|
||||
for (const p of parts) {
|
||||
const tags = [];
|
||||
if (p.is_self) tags.push('就是你');
|
||||
if (Array.isArray(p.roles) && p.roles.length) {
|
||||
tags.push(p.roles.map(roleLabel).join('/'));
|
||||
}
|
||||
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
|
||||
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
|
||||
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染联系人清单(本 Agent 参与过的全部会话)。
|
||||
*
|
||||
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
|
||||
* 有未读的那些才是答案。
|
||||
*
|
||||
* @param {object} data `/agent/contacts` 的返回体
|
||||
* @param {number} limit 最多列出多少条
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderContacts(data, limit = 20) {
|
||||
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
|
||||
if (list.length === 0) return '还没有任何往来会话。';
|
||||
|
||||
list.sort((a, b) => {
|
||||
const ua = a?.unread_count || 0;
|
||||
const ub = b?.unread_count || 0;
|
||||
if (ua !== ub) return ub - ua;
|
||||
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
|
||||
});
|
||||
|
||||
const shown = list.slice(0, limit);
|
||||
const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`];
|
||||
for (const c of shown) {
|
||||
const bits = [];
|
||||
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
|
||||
if (c.subject) bits.push(c.subject);
|
||||
if (c.max_rounds > 0) {
|
||||
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
|
||||
bits.push(`剩 ${left}/${c.max_rounds} 个来回`);
|
||||
}
|
||||
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
|
||||
lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||||
function roleLabel(role) {
|
||||
switch (role) {
|
||||
case 'from': return '发件人';
|
||||
case 'to': return '收件人';
|
||||
case 'cc': return '抄送方';
|
||||
default: return String(role);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染对话树,回答「谁已经回了、谁还没回」。
|
||||
*
|
||||
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
|
||||
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
|
||||
*
|
||||
* @param {object} data `/agent/mail/{id}/thread` 的返回体
|
||||
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderThread(data, selfName = '') {
|
||||
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||||
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
|
||||
|
||||
const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`];
|
||||
for (const n of nodes) {
|
||||
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
|
||||
const indent = ' '.repeat(Math.min(depth, 8));
|
||||
const marks = [];
|
||||
if (selfName && n?.from_name === selfName) marks.push('你发的');
|
||||
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
|
||||
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
|
||||
lines.push(
|
||||
`${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
|
||||
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}`
|
||||
);
|
||||
}
|
||||
if (data?.has_more) {
|
||||
lines.push('');
|
||||
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@ -6,6 +6,8 @@
|
||||
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
|
||||
*/
|
||||
|
||||
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
|
||||
|
||||
/** 人类可读的字节数,用于附件清单展示。 */
|
||||
export function formatSize(n) {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
|
||||
@ -19,19 +21,41 @@ export function formatSize(n) {
|
||||
*
|
||||
* @param {any} m `/mail/inbox` 返回的一封邮件
|
||||
* @param {number} bodyLimit 正文截断长度
|
||||
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
|
||||
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderMail(m, bodyLimit = 200) {
|
||||
export function renderMail(m, bodyLimit = 200, selfName = '') {
|
||||
const alias = m?.session_alias || '';
|
||||
const lines = [
|
||||
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
|
||||
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
|
||||
`会话: #${m?.session_alias || '未命名'}`,
|
||||
`会话: #${alias || '未命名'}`,
|
||||
];
|
||||
|
||||
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
|
||||
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
|
||||
// 抄送方却看不到收件人叫什么。
|
||||
if (m?.to_name) {
|
||||
let toLine = `收件人: ${m.to_name}`;
|
||||
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
|
||||
lines.push(toLine);
|
||||
}
|
||||
|
||||
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
|
||||
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
|
||||
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
|
||||
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
|
||||
}
|
||||
|
||||
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
|
||||
// 以为自己是负责人,或者都以为自己只是旁观者。
|
||||
if (selfName) {
|
||||
const role = roleOf(m, selfName);
|
||||
if (role === 'to') lines.push('你的身份: 收件人(主办)');
|
||||
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
|
||||
}
|
||||
|
||||
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
|
||||
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
|
||||
lines.push(
|
||||
@ -42,22 +66,52 @@ export function renderMail(m, bodyLimit = 200) {
|
||||
);
|
||||
lines.push('下载附件请用 download_attachment 工具。');
|
||||
}
|
||||
|
||||
// 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。
|
||||
const body = m?.body_preview || m?.body || '';
|
||||
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
|
||||
|
||||
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
|
||||
//
|
||||
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
|
||||
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
|
||||
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
|
||||
if (selfName && alias) {
|
||||
const parts = participantsOfMail(m, selfName, alias);
|
||||
const others = parts.filter(p => !p.is_self && p.address);
|
||||
if (others.length > 0) {
|
||||
lines.push(
|
||||
'可投递地址: ' +
|
||||
others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、')
|
||||
);
|
||||
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||||
function roleLabel(role) {
|
||||
switch (role) {
|
||||
case 'from': return '发件人';
|
||||
case 'to': return '收件人';
|
||||
case 'cc': return '抄送方';
|
||||
default: return role;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染整个收件箱。
|
||||
* @param {any[]} mails
|
||||
* @param {number} bodyLimit
|
||||
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderInbox(mails, bodyLimit = 200) {
|
||||
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
|
||||
const list = Array.isArray(mails) ? mails : [];
|
||||
if (list.length === 0) return '收件箱为空。';
|
||||
return list.map(m => renderMail(m, bodyLimit)).join('\n\n');
|
||||
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -65,6 +65,37 @@ export function snapshotDshModels(entries) {
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pi 的模型列表整理成上报格式。
|
||||
*
|
||||
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
|
||||
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
|
||||
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
|
||||
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
|
||||
*
|
||||
* pi 的 Model 对象上,provider 在 `provider` 字段、模型 id 在 `id` 字段,
|
||||
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
|
||||
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
|
||||
*
|
||||
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotPiModels(models) {
|
||||
const list = Array.isArray(models) ? models : [];
|
||||
const out = [];
|
||||
for (const m of list) {
|
||||
const provider = typeof m?.provider === 'string' ? m.provider : '';
|
||||
const model = typeof m?.id === 'string' ? m.id : '';
|
||||
if (!provider || !model) continue;
|
||||
out.push({
|
||||
provider,
|
||||
model,
|
||||
display_name: typeof m?.name === 'string' ? m.name : '',
|
||||
});
|
||||
}
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定这一轮按什么顺序尝试模型。
|
||||
*
|
||||
|
||||
@ -83,6 +83,83 @@ export function snapshotDshSessions(entries, isMailDriven = () => false) {
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
|
||||
*
|
||||
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
|
||||
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
|
||||
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
|
||||
* - **pi 内核(SDK)自己不生成**:桥用 createAgentSession 起的会话没有名字,
|
||||
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming)
|
||||
*
|
||||
* 与另两个平台的差异:pi 的 SessionInfo 里**没有 subagent 标记**。
|
||||
* pi-subagents 把子会话写在自定义 sessionDir(run 根目录)下,默认会话目录
|
||||
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
|
||||
*
|
||||
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
|
||||
* @param {(id: string) => boolean} isMailDriven
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotPiSessions(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;
|
||||
const name = typeof e?.name === 'string' ? e.name : '';
|
||||
// 没有名字的会话不报(S-1):pi 的列表在无名时显示首条消息,
|
||||
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
|
||||
if (!name) continue;
|
||||
// 模型把思维链当标题写进来的那些不报(见 isUnusableName)
|
||||
if (isUnusableName(name)) continue;
|
||||
const slug = slugFromTitle(name);
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
platform_id: id,
|
||||
// 老会话的 cwd 是空串(pi 的 SessionInfo 注释里写明了),照实上报,
|
||||
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
|
||||
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
|
||||
slug,
|
||||
title: name,
|
||||
mail_driven: Boolean(isMailDriven(id)),
|
||||
updated_at: toISO(e?.modified ?? e?.created),
|
||||
});
|
||||
}
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个平台侧名字是否不适合当别名。
|
||||
*
|
||||
* 这条判废是 pi 特有的:pi-web 的标题生成器(`sessionNameGenerator`)只做了
|
||||
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
|
||||
*
|
||||
* "The user is asking me to generate a title for a coding-agent"
|
||||
* "我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:…"
|
||||
*
|
||||
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
|
||||
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
|
||||
*
|
||||
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
|
||||
* 而漏掉一个坏名字只是别名难看。
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUnusableName(name) {
|
||||
const s = String(name ?? '').trim();
|
||||
if (!s) return true;
|
||||
// 自指标题生成任务 = 模型把系统提示词复述了出来
|
||||
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
|
||||
return true;
|
||||
}
|
||||
// 以第三人称叙述用户意图开头 = 思维链的典型开场
|
||||
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(,|,))/i.test(s)) return true;
|
||||
// 又长又分句 = 一段话而不是一个标题(pi-web 截断上限是 60)
|
||||
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
|
||||
function isSubagent(e) {
|
||||
if (e?.origin === 'subagent') return true;
|
||||
@ -131,11 +208,17 @@ export function slugFromTitle(title) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** 毫秒时间戳或 ISO 串 → ISO 串;无法解析时返回 undefined。 */
|
||||
/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */
|
||||
function toISO(v) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
// pi 的 SessionInfo 给的是 Date 实例(created/modified),不是时间戳。
|
||||
// 少了这一支会让整份快照的 updated_at 全是 undefined,于是服务端只能按
|
||||
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
|
||||
if (v instanceof Date) {
|
||||
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
|
||||
}
|
||||
if (typeof v === 'string' && v) {
|
||||
const d = new Date(v);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
|
||||
145
plugins/opencode-mail-bridge/test/addressing.test.mjs
Normal file
145
plugins/opencode-mail-bridge/test/addressing.test.mjs
Normal file
@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatAddress,
|
||||
roleOf,
|
||||
replyAddressFor,
|
||||
selfAddressFor,
|
||||
participantsOfMail,
|
||||
} from '../lib/addressing.js';
|
||||
|
||||
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
|
||||
// 「拼出来的东西还能不能被正确解析回三段」上。
|
||||
|
||||
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
|
||||
// 生产事故:朴素拼接得到 admin.silent-harbor,没有 @,
|
||||
// 整串被 ParseAddress 当成名字,session 位静默丢失。
|
||||
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('formatAddress: 省略 session 位', () => {
|
||||
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
|
||||
// 名字与 path 都有但都不带会话 → 默认会话语义
|
||||
assert.equal(formatAddress('dsh', '', ''), 'dsh');
|
||||
});
|
||||
|
||||
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
|
||||
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
|
||||
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
|
||||
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
|
||||
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
|
||||
});
|
||||
|
||||
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
|
||||
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
|
||||
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
|
||||
assert.equal(formatAddress('', '/p', 'a'), '');
|
||||
assert.equal(formatAddress(null, '/p', 'a'), '');
|
||||
});
|
||||
|
||||
test('formatAddress: 去掉首尾空白', () => {
|
||||
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
|
||||
});
|
||||
|
||||
const ccMail = {
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
};
|
||||
|
||||
test('roleOf: 区分主收件人与抄送方', () => {
|
||||
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
|
||||
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
|
||||
assert.equal(roleOf(ccMail, 'dsh'), 'to');
|
||||
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
|
||||
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
|
||||
});
|
||||
|
||||
test('roleOf: 名字为空时不猜', () => {
|
||||
assert.equal(roleOf(ccMail, ''), 'unknown');
|
||||
assert.equal(roleOf(ccMail, undefined), 'unknown');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
|
||||
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
|
||||
const addr = replyAddressFor(ccMail);
|
||||
assert.equal(addr, 'admin@.silent-harbor');
|
||||
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 发件人一侧不带 path', () => {
|
||||
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
|
||||
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
|
||||
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
|
||||
assert.equal(replyAddressFor(mail), 'dsh@.x');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 无别名时退回默认会话形式', () => {
|
||||
const mail = { from_name: 'admin', session_alias: '' };
|
||||
const addr = replyAddressFor(mail);
|
||||
assert.equal(addr, 'admin');
|
||||
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
|
||||
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
|
||||
});
|
||||
|
||||
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
|
||||
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path,
|
||||
// 「我是谁」这句话就指向了别人的目录。
|
||||
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
|
||||
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
|
||||
|
||||
assert.equal(byName.opencode.path, '/home');
|
||||
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
|
||||
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
|
||||
// 发件人 path 留空,理由同 replyAddressFor
|
||||
assert.equal(byName.admin.address, 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
|
||||
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名,
|
||||
// 否则「回给抄收方」这个动作每次都会新开会话。
|
||||
for (const p of participantsOfMail(ccMail, 'dsh')) {
|
||||
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('participantsOfMail: 自己被标记而不是被剔除', () => {
|
||||
// 剔掉的话模型无法确认这封信是不是也发给了自己,
|
||||
// 也就无法判断自己该不该回。
|
||||
const parts = participantsOfMail(ccMail, 'opencode');
|
||||
const me = parts.find(p => p.name === 'opencode');
|
||||
assert.ok(me, '自己应出现在参与方列表里');
|
||||
assert.equal(me.is_self, true);
|
||||
assert.equal(parts.filter(p => p.is_self).length, 1);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
|
||||
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 无抄送时只有两方', () => {
|
||||
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 跳过空名字条目', () => {
|
||||
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
|
||||
const mail = {
|
||||
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
|
||||
cc_list: [{ name: '', path: '/x' }, {}],
|
||||
session_alias: 'a',
|
||||
};
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
for (const p of parts) assert.notEqual(p.address, '');
|
||||
});
|
||||
218
plugins/opencode-mail-bridge/test/discovery.test.mjs
Normal file
218
plugins/opencode-mail-bridge/test/discovery.test.mjs
Normal file
@ -0,0 +1,218 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
|
||||
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
|
||||
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
|
||||
|
||||
test('renderNameSuggestions 只给名字并指向下一步', () => {
|
||||
// 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」——
|
||||
// 那不一定是调用方想要的那条。
|
||||
const got = renderNameSuggestions(['opencode', 'admin']);
|
||||
assert.match(got, /opencode/);
|
||||
assert.match(got, /admin/);
|
||||
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
|
||||
});
|
||||
|
||||
test('renderNameSuggestions 空列表给明确文案', () => {
|
||||
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
|
||||
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
|
||||
// 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
const got = renderPathSuggestions([], 'admin');
|
||||
assert.match(got, /可以留空/);
|
||||
assert.match(got, /admin/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 列出目录并指向下一步', () => {
|
||||
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
|
||||
assert.match(got, /\/home\/program\/agentmail/);
|
||||
assert.match(got, /最近使用/);
|
||||
assert.match(got, /suggest_address\(name="opencode", path="/);
|
||||
});
|
||||
|
||||
const sessionData = {
|
||||
kind: 'session',
|
||||
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
|
||||
addresses: [
|
||||
'opencode@/home.silent-harbor',
|
||||
'opencode@/home.happy-tiger',
|
||||
'opencode@/home.new',
|
||||
],
|
||||
candidates: [
|
||||
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
|
||||
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
|
||||
{ alias: 'new', title: '新建会话', source: 'new' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
|
||||
// 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions
|
||||
// 同序由服务端保证,直接用。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /opencode@\/home\.happy-tiger/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 带出标题与未读数', () => {
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /联调 llmsproxy/);
|
||||
assert.match(got, /2 封未读/);
|
||||
});
|
||||
|
||||
test('不变量:new 不与已存在会话混列,且带警告', () => {
|
||||
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
const lines = got.split('\n');
|
||||
const newLineIdx = lines.findIndex(l => l.includes('.new'));
|
||||
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
|
||||
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
|
||||
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
|
||||
// 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。
|
||||
// 不传的话服务端会自动命名,但模型不知道那个名字。
|
||||
const got = renderSessionSuggestions(
|
||||
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
|
||||
'dsh', '/tmp',
|
||||
);
|
||||
assert.match(got, /还没有可续谈的会话/);
|
||||
assert.match(got, /session_alias/);
|
||||
});
|
||||
|
||||
const participantData = {
|
||||
session_id: 'f3d824ce',
|
||||
session_alias: 'silent-harbor',
|
||||
participants: [
|
||||
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
|
||||
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
|
||||
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderParticipants 给出每个参与方的地址', () => {
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /admin@\.silent-harbor/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('不变量:标出「尚未回应」的人', () => {
|
||||
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
|
||||
// 正是为了让这个判断成立。
|
||||
const got = renderParticipants(participantData);
|
||||
const line = got.split('\n').find(l => l.includes('opencode'));
|
||||
assert.match(line, /尚未回应/);
|
||||
// 自己不该被标「尚未回应」—— 自己正在处理这封
|
||||
const selfLine = got.split('\n').find(l => l.includes('dsh'));
|
||||
assert.ok(!selfLine.includes('尚未回应'));
|
||||
assert.match(selfLine, /就是你/);
|
||||
});
|
||||
|
||||
test('renderParticipants 用中文角色标签', () => {
|
||||
// 模型读到「抄送方」比读到 cc 更容易判对分工。
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /抄送方/);
|
||||
assert.match(got, /发件人/);
|
||||
});
|
||||
|
||||
test('renderParticipants 无地址时说明原因', () => {
|
||||
const got = renderParticipants({
|
||||
session_alias: '',
|
||||
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
|
||||
});
|
||||
assert.match(got, /尚未命名/);
|
||||
});
|
||||
|
||||
test('renderParticipants 空会话不崩', () => {
|
||||
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
|
||||
assert.match(renderParticipants({}), /还没有参与方/);
|
||||
});
|
||||
|
||||
test('renderContacts 未读优先排序', () => {
|
||||
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
|
||||
const got = renderContacts({
|
||||
contacts: [
|
||||
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
|
||||
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
|
||||
],
|
||||
});
|
||||
const lines = got.split('\n').filter(l => l.startsWith('- '));
|
||||
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
|
||||
assert.match(lines[0], /3 封未读/);
|
||||
});
|
||||
|
||||
test('renderContacts 带出剩余预算', () => {
|
||||
const got = renderContacts({
|
||||
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
|
||||
});
|
||||
assert.match(got, /剩 3\/20 个来回/);
|
||||
});
|
||||
|
||||
test('renderContacts 未命名会话说明只能 reply_to', () => {
|
||||
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
|
||||
assert.match(got, /reply_to/);
|
||||
});
|
||||
|
||||
test('renderContacts 空列表', () => {
|
||||
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
|
||||
});
|
||||
|
||||
const threadData = {
|
||||
anchor_mail_id: 'm-2',
|
||||
total: 3,
|
||||
hidden: 1,
|
||||
nodes: [
|
||||
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
|
||||
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
|
||||
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderThread 用缩进表示层级', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const lines = got.split('\n');
|
||||
const l1 = lines.find(l => l.includes('m-1'));
|
||||
const l2 = lines.find(l => l.includes('m-2'));
|
||||
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
|
||||
});
|
||||
|
||||
test('不变量:detached 必须标出来', () => {
|
||||
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const line = got.split('\n').find(l => l.includes('m-3'));
|
||||
assert.match(line, /父邮件无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 标出自己发的与当前这封', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
|
||||
});
|
||||
|
||||
test('renderThread 报告不可见数量', () => {
|
||||
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
|
||||
// 不说的话模型会以为自己看到了全貌。
|
||||
assert.match(renderThread(threadData), /另有 1 封无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 有更多时给出 offset', () => {
|
||||
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
|
||||
assert.match(got, /offset=60/);
|
||||
});
|
||||
|
||||
test('renderThread 空线索不崩', () => {
|
||||
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
|
||||
assert.match(renderThread({}), /没有可见的邮件/);
|
||||
});
|
||||
@ -117,6 +117,96 @@ test('附件字段不是数组时忽略', () => {
|
||||
assert.ok(!got.includes('抄送'));
|
||||
});
|
||||
|
||||
// ─── 收件人与身份(只有知道自己是谁才能判定)───
|
||||
|
||||
test('不变量:收件人要显示出来', () => {
|
||||
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
|
||||
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
|
||||
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
|
||||
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
|
||||
});
|
||||
|
||||
test('收件人无工作目录时只显名字', () => {
|
||||
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
|
||||
assert.match(got, /收件人: admin$/m);
|
||||
});
|
||||
|
||||
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
|
||||
const got = renderMail(mail({ to_name: 'dsh' }));
|
||||
assert.ok(!got.includes('你的身份'));
|
||||
});
|
||||
|
||||
test('不变量:区分收件人与抄送方身份', () => {
|
||||
// 两者职责不同。不区分的话两方都会以为自己是负责人,
|
||||
// 或者都以为自己只是旁观者。
|
||||
const m = mail({
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
|
||||
});
|
||||
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
|
||||
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
|
||||
// 不相关的名字不编造身份
|
||||
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
|
||||
});
|
||||
|
||||
// ─── 可投递地址(「精准发信」的关键)───
|
||||
|
||||
const joint = () => mail({
|
||||
mail_id: 'm-7',
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
});
|
||||
|
||||
test('不变量:给出每个参与方的可投递地址', () => {
|
||||
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`,
|
||||
// 而那个地址回过去只会再建一条平行会话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /可投递地址/);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor(抄送方)/);
|
||||
assert.match(got, /admin@\.silent-harbor(发件人)/);
|
||||
});
|
||||
|
||||
test('不变量:可投递地址里绝不出现 .new', () => {
|
||||
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
|
||||
// 把它当回信地址会让双方各说各话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(line, '应有可投递地址行');
|
||||
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
|
||||
});
|
||||
|
||||
test('可投递地址不列自己', () => {
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
|
||||
});
|
||||
|
||||
test('同时给出 reply_to 这条更稳的路', () => {
|
||||
// 地址可能拼错,reply_to 不会 —— 两条路都告诉模型。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /reply_to=m-7/);
|
||||
});
|
||||
|
||||
test('无会话别名时不给地址(宁可不给不可给错)', () => {
|
||||
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
|
||||
// 实际指向默认会话的地址,比不给危险。
|
||||
const got = renderMail(mail({
|
||||
to_name: 'dsh', session_alias: '',
|
||||
cc_list: [{ name: 'opencode', path: '/home' }],
|
||||
}), 200, 'dsh');
|
||||
assert.ok(!got.includes('可投递地址'));
|
||||
});
|
||||
|
||||
test('renderInbox 透传 selfName', () => {
|
||||
const got = renderInbox([joint()], 200, 'opencode');
|
||||
assert.match(got, /你的身份: 抄送方/);
|
||||
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor(收件人)/);
|
||||
});
|
||||
|
||||
// ─── renderInbox ───
|
||||
|
||||
test('renderInbox 空收件箱给明确文案', () => {
|
||||
|
||||
@ -12,6 +12,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeModels,
|
||||
snapshotDshModels,
|
||||
snapshotPiModels,
|
||||
modelAttemptOrder,
|
||||
renderFailureReport,
|
||||
MAX_CATALOG,
|
||||
@ -106,6 +107,46 @@ test('目录截断到 MAX_CATALOG', () => {
|
||||
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── pi 目录 ───
|
||||
|
||||
test('pi 目录用 provider + id', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
|
||||
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.deepEqual(got[1], {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
display_name: 'Claude Sonnet 4.6',
|
||||
});
|
||||
});
|
||||
|
||||
test('pi 目录跳过缺 provider 或 id 的条目', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: '', id: 'x' },
|
||||
{ provider: 'p' },
|
||||
{ provider: 'p', id: 'ok' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].model, 'ok');
|
||||
});
|
||||
|
||||
test('pi 目录容错:非数组不崩', () => {
|
||||
assert.deepEqual(snapshotPiModels(undefined), []);
|
||||
assert.deepEqual(snapshotPiModels(null), []);
|
||||
assert.deepEqual(snapshotPiModels('oops'), []);
|
||||
});
|
||||
|
||||
test('pi 目录同样受 MAX_CATALOG 截断', () => {
|
||||
// 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。
|
||||
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
|
||||
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
|
||||
provider: 'p', id: `m${i}`, name: `M${i}`,
|
||||
}));
|
||||
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── modelAttemptOrder ───
|
||||
|
||||
test('管理员划定范围时按 rank 顺序尝试', () => {
|
||||
|
||||
@ -12,6 +12,8 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeSessions,
|
||||
snapshotDshSessions,
|
||||
snapshotPiSessions,
|
||||
isUnusableName,
|
||||
slugFromTitle,
|
||||
MAX_REPORTED,
|
||||
} from '../lib/session-snapshot.js';
|
||||
@ -226,3 +228,99 @@ test('不同标题不受去重影响', () => {
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
});
|
||||
|
||||
// ─── pi:名字来自会话文件的 session_info ───
|
||||
|
||||
const piSession = (over = {}) => ({
|
||||
id: '01a064cc-df57-7b2d-bebb-736776105485',
|
||||
cwd: '/home/program/agentmail',
|
||||
name: '重构导入路径',
|
||||
messageCount: 6,
|
||||
created: new Date(1788300000000),
|
||||
modified: new Date(1788344476744),
|
||||
...over,
|
||||
});
|
||||
|
||||
test('pi 快照取 cwd 与 session_info 名字', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.workspace, '/home/program/agentmail');
|
||||
assert.equal(got.title, '重构导入路径');
|
||||
assert.equal(got.slug, '重构导入路径');
|
||||
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
|
||||
});
|
||||
|
||||
test('不变量:pi 无名会话不上报', () => {
|
||||
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
|
||||
// (「你收到一封新邮件(AgentMail)…」)—— 拿它当别名毫无区分度,且条条撞名。
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'named', name: '有名字' }),
|
||||
piSession({ id: 'anon', name: undefined }),
|
||||
piSession({ id: 'blank', name: '' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['named']);
|
||||
});
|
||||
|
||||
test('不变量:pi 老会话的空 cwd 照实上报', () => {
|
||||
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
|
||||
// 那条会话在补全里挂到一个它其实不属于的工作区下。
|
||||
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
|
||||
assert.equal(got.workspace, '');
|
||||
});
|
||||
|
||||
test('不变量:pi 的 updated_at 取 modified(文件 mtime)', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
|
||||
});
|
||||
|
||||
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
|
||||
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'new');
|
||||
});
|
||||
|
||||
test('pi 快照标记邮件驱动的会话', () => {
|
||||
const got = snapshotPiSessions(
|
||||
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
|
||||
(id) => id === 'mail-one'
|
||||
);
|
||||
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
|
||||
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
|
||||
});
|
||||
|
||||
// ─── isUnusableName:pi-web 标题生成器的思维链泄漏 ───
|
||||
|
||||
test('不变量:思维链泄漏的标题判废', () => {
|
||||
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
|
||||
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
|
||||
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
|
||||
assert.equal(
|
||||
isUnusableName('我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:Opencode源测试。或者更简'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isUnusableName 放过正常标题', () => {
|
||||
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
|
||||
assert.equal(isUnusableName('查看Agent接入群聊'), false);
|
||||
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
|
||||
assert.equal(isUnusableName('homeagent-gateway'), false);
|
||||
assert.equal(isUnusableName('重构导入路径'), false);
|
||||
assert.equal(isUnusableName('Fix flaky auth test'), false);
|
||||
});
|
||||
|
||||
test('isUnusableName 判废空名字', () => {
|
||||
assert.equal(isUnusableName(''), true);
|
||||
assert.equal(isUnusableName(' '), true);
|
||||
assert.equal(isUnusableName(undefined), true);
|
||||
});
|
||||
|
||||
test('判废的名字不进快照', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
|
||||
piSession({ id: 'clean', name: '正常标题' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
|
||||
});
|
||||
|
||||
141
plugins/pi-mail-bridge/lib/addressing.js
Normal file
141
plugins/pi-mail-bridge/lib/addressing.js
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 三维寻址的构造与判读 —— 所有平台插件共用。
|
||||
*
|
||||
* 为什么这些函数必须共用、且必须是纯函数:
|
||||
*
|
||||
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
|
||||
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
|
||||
* 生产上真实发生过两次:
|
||||
*
|
||||
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
|
||||
* 再建一条平行会话,双方从此各说各话。
|
||||
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`,
|
||||
* 整串被当成名字,session 位静默丢失。
|
||||
*
|
||||
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 拼一个可寻址的 `name@path.session`。
|
||||
*
|
||||
* **空 path 也必须留下 `@` 与 `.`**:`admin@.silent-harbor` 才解析成
|
||||
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
|
||||
* `admin.silent-harbor` 会被整串当作名字。
|
||||
*
|
||||
* session 省略时不写那一位(默认会话语义)。
|
||||
*
|
||||
* @param {string} name 收件方名(Agent 名或人类用户名)
|
||||
* @param {string} [path] 工作目录,可为空
|
||||
* @param {string} [session] 会话别名;空则省略该位
|
||||
* @returns {string} 地址,name 为空时返回空串
|
||||
*/
|
||||
export function formatAddress(name, path, session) {
|
||||
const n = String(name ?? '').trim();
|
||||
const p = String(path ?? '').trim();
|
||||
const s = String(session ?? '').trim();
|
||||
if (!n) return '';
|
||||
if (!s) return p ? `${n}@${p}` : n;
|
||||
return `${n}@${p}.${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断自己在这封邮件里是收件人还是抄送方。
|
||||
*
|
||||
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
|
||||
* admin 主发 dsh、抄送 opencode,分工是「dsh 提供源码解读、opencode 提供部署
|
||||
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
|
||||
* 或者都以为自己只是旁观者。
|
||||
*
|
||||
* @param {any} mail `/mail/inbox` 返回的一封邮件
|
||||
* @param {string} selfName 自己的 Agent 名
|
||||
* @returns {'to'|'cc'|'unknown'}
|
||||
*/
|
||||
export function roleOf(mail, selfName) {
|
||||
const self = String(selfName ?? '').trim();
|
||||
if (!self) return 'unknown';
|
||||
if (mail?.to_name === self) return 'to';
|
||||
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
|
||||
return 'cc';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 给出「把回信发回这条会话」的地址。
|
||||
*
|
||||
* 发件人一侧**不带 path**:Agent 回信时 `from_workspace` 存的是 Agent 名而不是
|
||||
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
|
||||
* 人类发件人本来就没有工作目录。
|
||||
*
|
||||
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
|
||||
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {string}
|
||||
*/
|
||||
export function replyAddressFor(mail, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
return formatAddress(mail?.from_name, '', a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
|
||||
*
|
||||
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
|
||||
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} selfName 自己的 Agent 名
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {string}
|
||||
*/
|
||||
export function selfAddressFor(mail, selfName, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
|
||||
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
|
||||
let path = mail?.to_workspace ?? '';
|
||||
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
|
||||
const mine = mail.cc_list.find(c => c?.name === selfName);
|
||||
if (mine) path = mine.path ?? '';
|
||||
}
|
||||
return formatAddress(selfName, path, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出这封邮件的全部参与方及各自可投递的地址。
|
||||
*
|
||||
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
|
||||
* 抄送方的 path 取它自己那个地址的 path 位。
|
||||
*
|
||||
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
|
||||
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} [selfName] 自己的名字,用于标记 is_self
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
|
||||
*/
|
||||
export function participantsOfMail(mail, selfName, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
const self = String(selfName ?? '').trim();
|
||||
const out = [];
|
||||
const add = (role, name, path) => {
|
||||
const n = String(name ?? '').trim();
|
||||
if (!n) return;
|
||||
out.push({
|
||||
role,
|
||||
name: n,
|
||||
path: String(path ?? ''),
|
||||
address: formatAddress(n, path, a),
|
||||
is_self: !!self && n === self,
|
||||
});
|
||||
};
|
||||
// 发件人一侧 path 留空,理由同 replyAddressFor
|
||||
add('from', mail?.from_name, '');
|
||||
add('to', mail?.to_name, mail?.to_workspace);
|
||||
if (Array.isArray(mail?.cc_list)) {
|
||||
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
74
plugins/pi-mail-bridge/lib/catchup.js
Normal file
74
plugins/pi-mail-bridge/lib/catchup.js
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 启动补拉:把插件离线期间到的邮件变成与 SSE 事件同形的投递任务。
|
||||
*
|
||||
* 为什么需要它:**SSE 只推连上之后的事件**。插件重启前发来的邮件不会再推一次,
|
||||
* 心跳响应的 `pending_mails` 是唯一线索。不补拉的后果是那封邮件永远躺在
|
||||
* 收件箱里,而发件人以为 Agent 收到了 —— 这比明确的失败更难排查。
|
||||
*
|
||||
* 两个平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 一次补拉最多处理几封。
|
||||
*
|
||||
* 上限存在的理由:每封都要起一轮模型。攒了 80 封的时候一次性全放出去,
|
||||
* 等于对上游打 80 个并发请求,且最后那几封要等前面全部跑完。
|
||||
* 超出的部分留在收件箱里,下次重启或人工触发时再处理。
|
||||
*/
|
||||
export const MAX_CATCHUP = 5;
|
||||
|
||||
/**
|
||||
* 把收件箱里的一封邮件转成 SSE `new_mail` 那个形状。
|
||||
*
|
||||
* 补拉与 SSE 走同一条投递路径(deliverMail),因此形状必须一致 ——
|
||||
* 两条路径各写一遍投递逻辑的话,某一条上的修复会漏掉另一条。
|
||||
*
|
||||
* @param {any} mail `/mail/inbox` 返回的一行
|
||||
* @returns {{mail_id: string, session_id: string, from_name: string,
|
||||
* subject: string, mail_type: string, role: string,
|
||||
* to_workspace: string, catchup: true}}
|
||||
*/
|
||||
export function mailToEvent(mail) {
|
||||
return {
|
||||
mail_id: mail?.mail_id || '',
|
||||
session_id: mail?.session_id || '',
|
||||
from_name: mail?.from_name || '',
|
||||
subject: mail?.subject || '',
|
||||
mail_type: mail?.mail_type || 'normal',
|
||||
role: 'to',
|
||||
to_workspace: mail?.to_workspace || '',
|
||||
// 标记来源,投递侧可据此决定是否在提示词里说明「这是积压的邮件」
|
||||
catchup: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从收件箱挑出该补投的邮件。
|
||||
*
|
||||
* @param {any[]} mails `/mail/inbox?status=unread` 的结果
|
||||
* @param {Set<string>} seen 已经通过 SSE 投过的 mail_id(避免重复投递)
|
||||
* @param {number} [max] 上限,默认 MAX_CATCHUP
|
||||
* @returns {any[]} 与 SSE 事件同形的投递任务,按时间正序(老的先处理)
|
||||
*/
|
||||
export function selectCatchup(mails, seen, max = MAX_CATCHUP) {
|
||||
if (!Array.isArray(mails) || mails.length === 0) return [];
|
||||
|
||||
const picked = [];
|
||||
for (const m of mails) {
|
||||
const id = m?.mail_id;
|
||||
if (!id) continue;
|
||||
// 心跳与 SSE 建连之间有个窗口:那期间到的邮件既在 pending_mails 里、
|
||||
// 也会被 SSE 推一次。不去重就会投两遍,模型回两封信。
|
||||
if (seen && seen.has(id)) continue;
|
||||
// permission 类邮件不补投:它是给人看的询问,Agent 侧没有可恢复的上下文
|
||||
// (原来的工具调用早随进程一起没了),投过去只会让模型困惑。
|
||||
if (m?.mail_type && m.mail_type !== 'normal') continue;
|
||||
picked.push(m);
|
||||
}
|
||||
|
||||
// 收件箱按时间倒序返回,补投要按正序 —— 先来的先处理,
|
||||
// 否则同一会话里的多封邮件会被倒着塞进去,上下文顺序是乱的。
|
||||
picked.reverse();
|
||||
|
||||
return picked.slice(0, Math.max(0, max)).map(mailToEvent);
|
||||
}
|
||||
237
plugins/pi-mail-bridge/lib/discovery.js
Normal file
237
plugins/pi-mail-bridge/lib/discovery.js
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
|
||||
*
|
||||
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
|
||||
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
|
||||
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
|
||||
*
|
||||
* # 这一组端点解决的问题
|
||||
*
|
||||
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
|
||||
* 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从
|
||||
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
|
||||
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
|
||||
* 那个错误路径静默变成了新会话的 workspace。
|
||||
*
|
||||
* # 渲染的取舍
|
||||
*
|
||||
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
|
||||
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
|
||||
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 渲染候选收件人清单(`kind: "name"`)。
|
||||
*
|
||||
* 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的
|
||||
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
|
||||
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
|
||||
*
|
||||
* @param {string[]} names
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderNameSuggestions(names) {
|
||||
const list = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||
if (list.length === 0) return '当前没有可投递的收件人。';
|
||||
return [
|
||||
`可投递的收件人(${list.length} 个):`,
|
||||
list.map(n => `- ${n}`).join('\n'),
|
||||
'',
|
||||
'下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染工作目录候选(`kind: "path"`)。
|
||||
*
|
||||
* 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录),
|
||||
* 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
*
|
||||
* @param {string[]} paths
|
||||
* @param {string} name 正在查的收件人名,用于拼下一步的提示
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderPathSuggestions(paths, name) {
|
||||
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
return [
|
||||
`${name} 没有记录在案的工作目录。`,
|
||||
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
|
||||
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`,
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`${name} 用过的工作目录(按最近使用排序):`,
|
||||
list.map(p => `- ${p}`).join('\n'),
|
||||
'',
|
||||
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染会话候选(`kind: "session"`)。
|
||||
*
|
||||
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`:
|
||||
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
|
||||
*
|
||||
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
|
||||
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
*
|
||||
* @param {object} data `/agent/contacts/suggest` 的返回体
|
||||
* @param {string} name
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderSessionSuggestions(data, name, path) {
|
||||
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
|
||||
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
|
||||
|
||||
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
|
||||
const existing = aliases.filter(a => a !== 'new');
|
||||
if (existing.length === 0) {
|
||||
return [
|
||||
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
|
||||
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`,
|
||||
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
|
||||
for (let i = 0; i < aliases.length; i++) {
|
||||
const alias = aliases[i];
|
||||
const addr = addresses[i] || '';
|
||||
const c = candidates[i] || {};
|
||||
if (alias === 'new') continue; // new 单独放最后
|
||||
const bits = [];
|
||||
if (c.title) bits.push(c.title);
|
||||
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
|
||||
if (c.source === 'platform') bits.push('平台侧会话');
|
||||
lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
|
||||
const newAddr = addressAt(addresses, aliases, 'new');
|
||||
if (newAddr) {
|
||||
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 按别名在同序的 addresses 里取地址。 */
|
||||
function addressAt(addresses, aliases, alias) {
|
||||
const i = aliases.indexOf(alias);
|
||||
return i >= 0 ? addresses[i] || '' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染会话参与方清单。
|
||||
*
|
||||
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
|
||||
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
|
||||
* 「作为发件人」的邮件,正是为了让这个判断成立。
|
||||
*
|
||||
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderParticipants(data) {
|
||||
const parts = Array.isArray(data?.participants) ? data.participants : [];
|
||||
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
|
||||
|
||||
const alias = data?.session_alias || '';
|
||||
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
|
||||
for (const p of parts) {
|
||||
const tags = [];
|
||||
if (p.is_self) tags.push('就是你');
|
||||
if (Array.isArray(p.roles) && p.roles.length) {
|
||||
tags.push(p.roles.map(roleLabel).join('/'));
|
||||
}
|
||||
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
|
||||
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
|
||||
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染联系人清单(本 Agent 参与过的全部会话)。
|
||||
*
|
||||
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
|
||||
* 有未读的那些才是答案。
|
||||
*
|
||||
* @param {object} data `/agent/contacts` 的返回体
|
||||
* @param {number} limit 最多列出多少条
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderContacts(data, limit = 20) {
|
||||
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
|
||||
if (list.length === 0) return '还没有任何往来会话。';
|
||||
|
||||
list.sort((a, b) => {
|
||||
const ua = a?.unread_count || 0;
|
||||
const ub = b?.unread_count || 0;
|
||||
if (ua !== ub) return ub - ua;
|
||||
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
|
||||
});
|
||||
|
||||
const shown = list.slice(0, limit);
|
||||
const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`];
|
||||
for (const c of shown) {
|
||||
const bits = [];
|
||||
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
|
||||
if (c.subject) bits.push(c.subject);
|
||||
if (c.max_rounds > 0) {
|
||||
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
|
||||
bits.push(`剩 ${left}/${c.max_rounds} 个来回`);
|
||||
}
|
||||
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
|
||||
lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||||
function roleLabel(role) {
|
||||
switch (role) {
|
||||
case 'from': return '发件人';
|
||||
case 'to': return '收件人';
|
||||
case 'cc': return '抄送方';
|
||||
default: return String(role);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染对话树,回答「谁已经回了、谁还没回」。
|
||||
*
|
||||
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
|
||||
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
|
||||
*
|
||||
* @param {object} data `/agent/mail/{id}/thread` 的返回体
|
||||
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderThread(data, selfName = '') {
|
||||
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||||
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
|
||||
|
||||
const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`];
|
||||
for (const n of nodes) {
|
||||
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
|
||||
const indent = ' '.repeat(Math.min(depth, 8));
|
||||
const marks = [];
|
||||
if (selfName && n?.from_name === selfName) marks.push('你发的');
|
||||
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
|
||||
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
|
||||
lines.push(
|
||||
`${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
|
||||
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}`
|
||||
);
|
||||
}
|
||||
if (data?.has_more) {
|
||||
lines.push('');
|
||||
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
144
plugins/pi-mail-bridge/lib/inbox-format.js
Normal file
144
plugins/pi-mail-bridge/lib/inbox-format.js
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 收件箱渲染与已读策略 —— 所有平台插件共用。
|
||||
*
|
||||
* 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与
|
||||
* 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool,
|
||||
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
|
||||
*/
|
||||
|
||||
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
|
||||
|
||||
/** 人类可读的字节数,用于附件清单展示。 */
|
||||
export function formatSize(n) {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一封邮件渲染成模型可读的文本块。
|
||||
*
|
||||
* @param {any} m `/mail/inbox` 返回的一封邮件
|
||||
* @param {number} bodyLimit 正文截断长度
|
||||
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
|
||||
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderMail(m, bodyLimit = 200, selfName = '') {
|
||||
const alias = m?.session_alias || '';
|
||||
const lines = [
|
||||
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
|
||||
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
|
||||
`会话: #${alias || '未命名'}`,
|
||||
];
|
||||
|
||||
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
|
||||
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
|
||||
// 抄送方却看不到收件人叫什么。
|
||||
if (m?.to_name) {
|
||||
let toLine = `收件人: ${m.to_name}`;
|
||||
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
|
||||
lines.push(toLine);
|
||||
}
|
||||
|
||||
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
|
||||
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
|
||||
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
|
||||
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
|
||||
}
|
||||
|
||||
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
|
||||
// 以为自己是负责人,或者都以为自己只是旁观者。
|
||||
if (selfName) {
|
||||
const role = roleOf(m, selfName);
|
||||
if (role === 'to') lines.push('你的身份: 收件人(主办)');
|
||||
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
|
||||
}
|
||||
|
||||
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
|
||||
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
|
||||
lines.push(
|
||||
'附件: ' +
|
||||
m.attachments
|
||||
.map(a => `${a?.filename ?? '?'}(${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'})`)
|
||||
.join('、')
|
||||
);
|
||||
lines.push('下载附件请用 download_attachment 工具。');
|
||||
}
|
||||
|
||||
// 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。
|
||||
const body = m?.body_preview || m?.body || '';
|
||||
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
|
||||
|
||||
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
|
||||
//
|
||||
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
|
||||
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
|
||||
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
|
||||
if (selfName && alias) {
|
||||
const parts = participantsOfMail(m, selfName, alias);
|
||||
const others = parts.filter(p => !p.is_self && p.address);
|
||||
if (others.length > 0) {
|
||||
lines.push(
|
||||
'可投递地址: ' +
|
||||
others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、')
|
||||
);
|
||||
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||||
function roleLabel(role) {
|
||||
switch (role) {
|
||||
case 'from': return '发件人';
|
||||
case 'to': return '收件人';
|
||||
case 'cc': return '抄送方';
|
||||
default: return role;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染整个收件箱。
|
||||
* @param {any[]} mails
|
||||
* @param {number} bodyLimit
|
||||
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
|
||||
const list = Array.isArray(mails) ? mails : [];
|
||||
if (list.length === 0) return '收件箱为空。';
|
||||
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断本次读取该标记哪些邮件为已读。
|
||||
*
|
||||
* 两条规则:
|
||||
*
|
||||
* 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过,
|
||||
* 一并标掉等于让它们凭空消失。
|
||||
* 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读
|
||||
* 会让下一轮真正的新邮件混在里面认不出来。
|
||||
*
|
||||
* 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起,
|
||||
* 模型分不清哪封该回。
|
||||
*
|
||||
* @param {string|undefined} status 本次查询用的过滤条件
|
||||
* @param {any[]} mails 本次返回的邮件
|
||||
* @returns {string[]} 待标记的 mail_id,空数组表示不需要标记
|
||||
*/
|
||||
export function idsToMarkRead(status, mails) {
|
||||
if (status === 'all') return [];
|
||||
const list = Array.isArray(mails) ? mails : [];
|
||||
return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id);
|
||||
}
|
||||
|
||||
/** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */
|
||||
export const DEFAULT_INBOX_STATUS = 'unread';
|
||||
|
||||
/** 收件箱默认返回条数。 */
|
||||
export const DEFAULT_INBOX_LIMIT = 5;
|
||||
169
plugins/pi-mail-bridge/lib/model-scope.js
Normal file
169
plugins/pi-mail-bridge/lib/model-scope.js
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 平台模型目录的整理与降级选择 —— 所有平台插件共用。
|
||||
*
|
||||
* 两个职责:
|
||||
* 1. 把各平台的 provider/model 结构整理成统一的上报格式(随心跳发给 Gateway)
|
||||
* 2. 按管理员划定的范围决定「先试哪个、再试哪个」
|
||||
*
|
||||
* 为什么随心跳上报而不是只在注册时报一次:模型清单会在运行中变(换 provider
|
||||
* 配置、上游上下线、换 API key)。只在注册时报的话目录会静静变陈,而管理员
|
||||
* 在配置页上看到的是上次重启时的快照 —— 选中一个平台已经调不到的模型,
|
||||
* 失败要到真发邮件时才暴露。
|
||||
*/
|
||||
|
||||
/** 单次上报的模型数上限。与服务端的 maxCatalogModels 一致。 */
|
||||
export const MAX_CATALOG = 300;
|
||||
|
||||
/**
|
||||
* 把 opencode 的 `/config/providers` 响应整理成上报格式。
|
||||
*
|
||||
* @param {any} config `client.config.providers()` 的结果
|
||||
* @returns {object[]} `[{ provider, model, display_name }]`
|
||||
*/
|
||||
export function snapshotOpencodeModels(config) {
|
||||
const providers = Array.isArray(config?.providers) ? config.providers : [];
|
||||
const out = [];
|
||||
for (const p of providers) {
|
||||
const provider = typeof p?.id === 'string' ? p.id : '';
|
||||
if (!provider) continue;
|
||||
// models 是对象而非数组:键是 model id,值是元数据
|
||||
const models = p?.models && typeof p.models === 'object' ? p.models : {};
|
||||
for (const [id, meta] of Object.entries(models)) {
|
||||
if (!id) continue;
|
||||
out.push({
|
||||
provider,
|
||||
model: id,
|
||||
display_name: typeof meta?.name === 'string' ? meta.name : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 DSH 的 provider/model 列表整理成上报格式。
|
||||
*
|
||||
* DSH 侧要先 `ctx.llm.listProviders()` 再对每个 provider `listModels()`,
|
||||
* 因此这里收的是已经拍平的结果。
|
||||
*
|
||||
* @param {any[]} entries `[{ provider, id, name }]`
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotDshModels(entries) {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
const out = [];
|
||||
for (const m of list) {
|
||||
const provider = typeof m?.provider === 'string' ? m.provider : '';
|
||||
const model = typeof m?.id === 'string' ? m.id : '';
|
||||
if (!provider || !model) continue;
|
||||
out.push({
|
||||
provider,
|
||||
model,
|
||||
display_name: typeof m?.name === 'string' ? m.name : '',
|
||||
});
|
||||
}
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pi 的模型列表整理成上报格式。
|
||||
*
|
||||
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
|
||||
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
|
||||
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
|
||||
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
|
||||
*
|
||||
* pi 的 Model 对象上,provider 在 `provider` 字段、模型 id 在 `id` 字段,
|
||||
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
|
||||
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
|
||||
*
|
||||
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotPiModels(models) {
|
||||
const list = Array.isArray(models) ? models : [];
|
||||
const out = [];
|
||||
for (const m of list) {
|
||||
const provider = typeof m?.provider === 'string' ? m.provider : '';
|
||||
const model = typeof m?.id === 'string' ? m.id : '';
|
||||
if (!provider || !model) continue;
|
||||
out.push({
|
||||
provider,
|
||||
model,
|
||||
display_name: typeof m?.name === 'string' ? m.name : '',
|
||||
});
|
||||
}
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定这一轮按什么顺序尝试模型。
|
||||
*
|
||||
* 三种情形:
|
||||
*
|
||||
* 1. **管理员划定了范围** → 按 rank 顺序(服务端已排好),逐个降级
|
||||
* 2. **没划定范围**(`allowed` 为空)→ 返回 `[undefined]`,
|
||||
* 表示「用平台自己的默认模型试一次」。**不是**空数组:
|
||||
* 空数组会让调用方一次都不试,等于让 Agent 彻底哑掉,
|
||||
* 而「管理员没配」的正确含义是不限定。
|
||||
* 3. **插件配了 `AGENTMAIL_REPLY_PROVIDER`/`MODEL`** → 那是部署方的显式指定,
|
||||
* 优先于「平台默认」,但**不优先于管理员划定的范围**:
|
||||
* 范围是运行时可改的策略,环境变量是部署时的兜底。
|
||||
*
|
||||
* @param {readonly {provider: string, model: string}[]} allowed 管理员划定的范围(按 rank)
|
||||
* @param {{provider?: string, model?: string}|undefined} envDefault 环境变量指定的模型
|
||||
* @returns {(({provider: string, model: string})|undefined)[]} 依次尝试的候选;
|
||||
* `undefined` 表示这一次不指定模型、交给平台
|
||||
*/
|
||||
export function modelAttemptOrder(allowed, envDefault) {
|
||||
const list = Array.isArray(allowed) ? allowed.filter(m => m?.provider && m?.model) : [];
|
||||
if (list.length > 0) return list.map(m => ({ provider: m.provider, model: m.model }));
|
||||
if (envDefault?.provider && envDefault?.model) {
|
||||
return [{ provider: envDefault.provider, model: envDefault.model }];
|
||||
}
|
||||
return [undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把多次尝试的失败原因整理成一封邮件正文。
|
||||
*
|
||||
* 全部失败时必须发这封信:模型一次都没跑起来,会话里没有任何 assistant 消息,
|
||||
* 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
|
||||
*
|
||||
* @param {{provider?: string, model?: string, error: string}[]} failures 每次尝试的失败
|
||||
* @param {string} subject 原邮件主题
|
||||
* @returns {string} Markdown 正文
|
||||
*/
|
||||
export function renderFailureReport(failures, subject) {
|
||||
const list = Array.isArray(failures) ? failures : [];
|
||||
const lines = [
|
||||
`本次未能处理「${subject || '(无主题)'}」:划定范围内的模型全部调用失败。`,
|
||||
'',
|
||||
`已尝试 ${list.length} 个:`,
|
||||
'',
|
||||
];
|
||||
list.forEach((f, i) => {
|
||||
const route = f?.provider && f?.model ? `${f.provider}/${f.model}` : '(平台默认模型)';
|
||||
lines.push(`${i + 1}. **${route}**`);
|
||||
// 缩进四格让报错原文成为代码块,避免其中的 Markdown 字符影响排版
|
||||
lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`);
|
||||
});
|
||||
lines.push('');
|
||||
lines.push('可能的原因:模型已下线、API key 失效、上游限流,或该 provider 未在平台侧配置。');
|
||||
lines.push('调整可用模型范围:配置页 → Agent 模型范围。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 去重(provider/model 组合)并截断。 */
|
||||
function dedupeAndCap(list) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const m of list) {
|
||||
const key = `${m.provider}/${m.model}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(m);
|
||||
if (out.length >= MAX_CATALOG) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
58
plugins/pi-mail-bridge/lib/relay-dedup.js
Normal file
58
plugins/pi-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;
|
||||
}
|
||||
234
plugins/pi-mail-bridge/lib/session-snapshot.js
Normal file
234
plugins/pi-mail-bridge/lib/session-snapshot.js
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 平台会话快照:把 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
|
||||
*
|
||||
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
|
||||
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
|
||||
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
|
||||
* - **pi 内核(SDK)自己不生成**:桥用 createAgentSession 起的会话没有名字,
|
||||
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming)
|
||||
*
|
||||
* 与另两个平台的差异:pi 的 SessionInfo 里**没有 subagent 标记**。
|
||||
* pi-subagents 把子会话写在自定义 sessionDir(run 根目录)下,默认会话目录
|
||||
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
|
||||
*
|
||||
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
|
||||
* @param {(id: string) => boolean} isMailDriven
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotPiSessions(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;
|
||||
const name = typeof e?.name === 'string' ? e.name : '';
|
||||
// 没有名字的会话不报(S-1):pi 的列表在无名时显示首条消息,
|
||||
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
|
||||
if (!name) continue;
|
||||
// 模型把思维链当标题写进来的那些不报(见 isUnusableName)
|
||||
if (isUnusableName(name)) continue;
|
||||
const slug = slugFromTitle(name);
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
platform_id: id,
|
||||
// 老会话的 cwd 是空串(pi 的 SessionInfo 注释里写明了),照实上报,
|
||||
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
|
||||
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
|
||||
slug,
|
||||
title: name,
|
||||
mail_driven: Boolean(isMailDriven(id)),
|
||||
updated_at: toISO(e?.modified ?? e?.created),
|
||||
});
|
||||
}
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个平台侧名字是否不适合当别名。
|
||||
*
|
||||
* 这条判废是 pi 特有的:pi-web 的标题生成器(`sessionNameGenerator`)只做了
|
||||
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
|
||||
*
|
||||
* "The user is asking me to generate a title for a coding-agent"
|
||||
* "我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:…"
|
||||
*
|
||||
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
|
||||
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
|
||||
*
|
||||
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
|
||||
* 而漏掉一个坏名字只是别名难看。
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUnusableName(name) {
|
||||
const s = String(name ?? '').trim();
|
||||
if (!s) return true;
|
||||
// 自指标题生成任务 = 模型把系统提示词复述了出来
|
||||
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
|
||||
return true;
|
||||
}
|
||||
// 以第三人称叙述用户意图开头 = 思维链的典型开场
|
||||
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(,|,))/i.test(s)) return true;
|
||||
// 又长又分句 = 一段话而不是一个标题(pi-web 截断上限是 60)
|
||||
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 判断一条会话是否为 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 串或 Date → ISO 串;无法解析时返回 undefined。 */
|
||||
function toISO(v) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
// pi 的 SessionInfo 给的是 Date 实例(created/modified),不是时间戳。
|
||||
// 少了这一支会让整份快照的 updated_at 全是 undefined,于是服务端只能按
|
||||
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
|
||||
if (v instanceof Date) {
|
||||
return Number.isNaN(v.getTime()) ? undefined : 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);
|
||||
}
|
||||
77
plugins/pi-mail-bridge/lib/workspace.js
Normal file
77
plugins/pi-mail-bridge/lib/workspace.js
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 邮件寻址里的工作目录(三维地址 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';
|
||||
|
||||
/**
|
||||
* 校验寻址里的工作目录,不可用时返回调用方给的兜底。
|
||||
*
|
||||
* 决策顺序:
|
||||
* 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组)
|
||||
* 2. path 位非空但目录不存在 → **不创建**,返回兜底
|
||||
* 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底
|
||||
*
|
||||
* 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`)
|
||||
* 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 ——
|
||||
* 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。
|
||||
*
|
||||
* 为什么拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,
|
||||
* 那是个与邮件语义无关的量(systemd 下通常是 `/`)。
|
||||
*
|
||||
* 兜底由调用方给,因为各平台的兜底不同:opencode 有插件启动时的 directory
|
||||
* 可用,DSH 没有、只能落到 `~/.dsh/mail-sessions/<会话>`(见 mailSessionFallback)。
|
||||
*
|
||||
* @param {string} workspace 事件里的 to_workspace
|
||||
* @param {string} fallback 不可用时的兜底目录(可为空串 = 交给平台自己决定)
|
||||
* @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里
|
||||
*/
|
||||
export function resolveWorkspaceCwd(workspace, fallback) {
|
||||
const raw = typeof workspace === 'string' ? workspace.trim() : '';
|
||||
const fb = typeof fallback === 'string' ? fallback : '';
|
||||
|
||||
if (!raw || !isAbsolute(raw)) return { cwd: fb, grouped: false };
|
||||
|
||||
const abs = resolve(raw);
|
||||
try {
|
||||
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
||||
return { cwd: abs, grouped: true };
|
||||
}
|
||||
} catch {
|
||||
// 权限不足等:当作不可用
|
||||
}
|
||||
return { cwd: fb, grouped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有天然兜底的平台(DSH)用这个:`~/.dsh/mail-sessions/<会话 id>`。
|
||||
* @param {string} sessionKey 会话标识
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mailSessionFallback(sessionKey) {
|
||||
return join(homedir(), '.dsh', 'mail-sessions', String(sessionKey || 'default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中),
|
||||
* 只有兜底目录需要现建。
|
||||
* @param {string} cwd resolveWorkspaceCwd 的结果
|
||||
* @param {boolean} grouped 是否落在寻址指定的目录里
|
||||
*/
|
||||
export function ensureCwd(cwd, grouped) {
|
||||
if (grouped || !cwd) return;
|
||||
try {
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
} catch {
|
||||
// 建不出来就让 harness 自己报错,这里不该吞掉真实原因
|
||||
}
|
||||
}
|
||||
17
plugins/pi-mail-bridge/package.json
Normal file
17
plugins/pi-mail-bridge/package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "pi-mail-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "pi (@earendil-works/pi-coding-agent) 桥:邮件驱动多智能体协作平台接入",
|
||||
"type": "module",
|
||||
"main": "src/index.mjs",
|
||||
"bin": {
|
||||
"pi-mail-bridge": "src/index.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": ">=0.84.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/index.mjs",
|
||||
"test": "node --test 'test/*.test.mjs'"
|
||||
}
|
||||
}
|
||||
235
plugins/pi-mail-bridge/src/gateway.mjs
Normal file
235
plugins/pi-mail-bridge/src/gateway.mjs
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* AgentMail Gateway 客户端 —— HTTP + SSE。
|
||||
*
|
||||
* 与另两个插件同构(同样的认证头、同样的手写 SSE 解析),区别只在这里是
|
||||
* 独立守护进程,所以密钥解析与 Last-Event-ID 的状态都归它自己管。
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
|
||||
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
||||
|
||||
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
|
||||
export function readLocalKey() {
|
||||
try {
|
||||
if (!existsSync(KEY_FILE)) return null;
|
||||
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
|
||||
return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次安装时本地生成密钥并落盘(0600),**并把全文打印到日志**(B-1.1)。
|
||||
*
|
||||
* 打印是必须的:密钥要管理员在后台登记之后才能接入,不打印就没人知道登记什么。
|
||||
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl(契约 9.8)。
|
||||
*/
|
||||
export function generateLocalKey(log = console.error) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
KEY_FILE,
|
||||
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
|
||||
log(`已在 ${KEY_FILE} 生成本地密钥。`);
|
||||
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`);
|
||||
log(` ${token}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 把 gateway 地址与身份记到 config.json,便于换机时人工核对。 */
|
||||
export function saveConfig(extra) {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
let cur = {};
|
||||
if (existsSync(CONFIG_FILE)) {
|
||||
try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ }
|
||||
}
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 });
|
||||
} catch (e) {
|
||||
console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayClient {
|
||||
/**
|
||||
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
|
||||
*/
|
||||
constructor({ url, agentName, agentKey, agentSecret }) {
|
||||
this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, '');
|
||||
this.agentName = agentName;
|
||||
this.agentKey = agentKey || '';
|
||||
this.agentSecret = agentSecret || '';
|
||||
this.sseAbort = null;
|
||||
// SSE 重连时带上,首次连接**不带**(B-1.4 / N-11):
|
||||
// 带上会收到一批已处理过的旧事件,插件重启一次就把历史邮件重投一遍。
|
||||
this.lastEventID = '';
|
||||
}
|
||||
|
||||
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
|
||||
authHeaders() {
|
||||
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 get(path) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async post(path, body) {
|
||||
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().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
|
||||
async register() {
|
||||
return this.post('/agent/register', {
|
||||
name: this.agentName,
|
||||
secret: this.agentSecret || '',
|
||||
workspaces: [],
|
||||
platform: 'pi',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传附件。
|
||||
*
|
||||
* 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`,
|
||||
* 且**不认 `X-Filename` 头**(grep 过 handler/attachments.go,没有这个分支)。
|
||||
* 直接 POST 二进制体会得到 400「缺少 file 字段」。
|
||||
*
|
||||
* 不手动设 Content-Type:让 undici 按 FormData 自己生成 boundary。
|
||||
*/
|
||||
async uploadFile(buf, filename) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([buf]), filename);
|
||||
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`);
|
||||
return data.attachment;
|
||||
}
|
||||
|
||||
async downloadFile(attachmentID) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 SSE 长连并自动重连。
|
||||
*
|
||||
* 手写解析而不用 EventSource:Node 内建的那个不支持自定义请求头,
|
||||
* 而认证头是必须的。协议这一小块(`id:` / `event:` / `data:` + 空行分隔)
|
||||
* 比引一个依赖划算。
|
||||
*
|
||||
* 断线重连带 `Last-Event-ID`(D-7.2):服务端有 per-agent 环形缓冲,
|
||||
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
|
||||
*/
|
||||
startSSE(onEvent, log = console.error) {
|
||||
this.sseAbort?.abort();
|
||||
this.sseAbort = new AbortController();
|
||||
const signal = this.sseAbort.signal;
|
||||
|
||||
const reconnect = (delay) => {
|
||||
if (signal.aborted) return;
|
||||
setTimeout(() => this.#connect(onEvent, reconnect, log), delay);
|
||||
};
|
||||
this.#connect(onEvent, reconnect, log);
|
||||
}
|
||||
|
||||
#connect(onEvent, reconnect, log) {
|
||||
const signal = this.sseAbort?.signal;
|
||||
if (!signal || signal.aborted) return;
|
||||
|
||||
const headers = { ...this.authHeaders(), Accept: 'text/event-stream' };
|
||||
// 重连时带上断点(D-7.2)。**首次连接必须不带**(N-11):那会让服务端
|
||||
// 把缓冲区里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
||||
// 只有 lastEventID 非空(= 已经收过事件)时才是重连。
|
||||
if (this.lastEventID) {
|
||||
headers['Last-Event-ID'] = this.lastEventID;
|
||||
log(`SSE 重连,从事件 ${this.lastEventID} 之后续传`);
|
||||
}
|
||||
|
||||
fetch(`${this.baseURL}/api/v1/events/stream`, { headers, signal })
|
||||
.then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
log(`SSE 建连失败: HTTP ${res.status}`);
|
||||
return reconnect(5000);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let id = '';
|
||||
let evt = '';
|
||||
let data = '';
|
||||
|
||||
const read = () => {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) return reconnect(3000);
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('id: ')) id = line.slice(4).trim();
|
||||
else if (line.startsWith('event: ')) evt = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) data = line.slice(6);
|
||||
else if (line === '' && evt) {
|
||||
// 事件 id 要在**分发之前**记下:分发里抛异常也不该让它丢,
|
||||
// 否则重连会从更早的位置回放,已处理的邮件再来一遍。
|
||||
if (id) this.lastEventID = id;
|
||||
try { onEvent(evt, JSON.parse(data)); } catch (e) {
|
||||
log(`SSE 事件处理失败: ${e?.message || e}`);
|
||||
}
|
||||
id = ''; evt = ''; data = '';
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch((e) => {
|
||||
if (signal.aborted) return;
|
||||
log(`SSE 读取中断: ${e?.message || e}`);
|
||||
reconnect(5000);
|
||||
});
|
||||
};
|
||||
read();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal.aborted) return;
|
||||
log(`SSE 连接错误: ${e?.message || e}`);
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
|
||||
stopSSE() {
|
||||
this.sseAbort?.abort();
|
||||
this.sseAbort = null;
|
||||
}
|
||||
}
|
||||
693
plugins/pi-mail-bridge/src/index.mjs
Normal file
693
plugins/pi-mail-bridge/src/index.mjs
Normal file
@ -0,0 +1,693 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* AgentMail ↔ pi 桥(pi-mail-bridge)
|
||||
*
|
||||
* 形态是**常驻守护进程**,不是 pi 扩展。原因见 src/session-pool.mjs 顶部:
|
||||
* 扩展被加载进一条已存在的会话,cwd 由启动 pi 的人决定;而 B-3.1 要求每封邮件的
|
||||
* to_workspace 成为会话 cwd。桥用 SDK 的 createAgentSession 按邮件起会话,
|
||||
* 一个进程里并存多条不同 cwd 的会话(实测可行)。
|
||||
*
|
||||
* 契约实现对照(docs/PLUGIN-CONTRACT.md):
|
||||
* B-1 启动 → main()
|
||||
* B-2 心跳 → beat(),30 秒
|
||||
* B-3 new_mail → deliverMail()
|
||||
* B-4 决策 → handlePermissionDecision()
|
||||
* B-5 转发 → relaySummary(),挂在 agent_end 上
|
||||
* B-6 失败回信 → deliverMail() 末尾的 renderFailureReport
|
||||
* B-7 补拉 → catchUp()
|
||||
* B-8 权限 → permissionExtension() 的 tool_call 钩子
|
||||
* B-9 关停 → shutdown()
|
||||
*/
|
||||
|
||||
import { mkdirSync, openSync, closeSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
import { GatewayClient, readLocalKey, generateLocalKey, saveConfig } from './gateway.mjs';
|
||||
import { createMailTools } from './tools.mjs';
|
||||
import { openSession, runTurn } from './session-pool.mjs';
|
||||
import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError } from './turn.mjs';
|
||||
import { planNamingSync, planWriteBack } from './naming.mjs';
|
||||
import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
|
||||
import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib/model-scope.js';
|
||||
import { snapshotPiSessions } from '../lib/session-snapshot.js';
|
||||
import { selectCatchup } from '../lib/catchup.js';
|
||||
import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
|
||||
|
||||
// ─── 配置 ───
|
||||
|
||||
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180';
|
||||
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || 'pi';
|
||||
const AGENT_SECRET = process.env.AGENTMAIL_AGENT_SECRET || '';
|
||||
const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || '';
|
||||
const REPLY_MODEL = process.env.AGENTMAIL_REPLY_MODEL || '';
|
||||
const TURN_TIMEOUT_MS = Number(process.env.AGENTMAIL_TURN_TIMEOUT_MS || 60_000);
|
||||
const LOCK_FILE = join(process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail'), 'pi-bridge.lock');
|
||||
|
||||
/** 日志一律 console.error:它一定进 journalctl(契约 9.8)。 */
|
||||
const log = (...args) => console.error('[pi-mail-bridge]', ...args);
|
||||
|
||||
// ─── 进程内状态 ───
|
||||
//
|
||||
// 全部只在内存,重启即丢 —— 这是契约第六节列明的已知取舍。
|
||||
// 要持久化的话该落在 pi 的会话元数据里,而不是桥自己的文件。
|
||||
|
||||
const sessions = new Map(); // agentmail session_id -> { session, sessionManager, cwd }
|
||||
const reverseMap = new Map(); // pi session id -> agentmail session_id
|
||||
const mailDriven = new Set(); // pi session id
|
||||
const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID }
|
||||
const relayedSummaries = new Map(); // pi session id -> 已转发过的 relay_key
|
||||
const syncedNames = new Map(); // pi session id -> 上次提交给 Gateway 的名字
|
||||
const pendingPermissions = new Map(); // relay_key -> { resolve, piSessionId }
|
||||
const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3)
|
||||
|
||||
let allowedModels = [];
|
||||
let modelRuntime = null;
|
||||
let client = null;
|
||||
let heartbeatTimer = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
// ─── 单实例锁 ───
|
||||
//
|
||||
// 两个桥同时跑的后果不是「慢一点」而是错的:两条 SSE 各收到同一封邮件,
|
||||
// 各起一条 pi 会话,发件人收到两封回信;而 deliveredMails 在各自内存里,去重不了。
|
||||
|
||||
function acquireLock() {
|
||||
mkdirSync(join(LOCK_FILE, '..'), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
// O_EXCL 原子创建。存在则说明有别的实例(或上次崩溃留下的陈锁)。
|
||||
const fd = openSync(LOCK_FILE, 'wx');
|
||||
writeFileSync(fd, String(process.pid));
|
||||
closeSync(fd);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e?.code !== 'EEXIST') throw e;
|
||||
}
|
||||
// 陈锁判定:文件里的 pid 还活着吗
|
||||
let pid = 0;
|
||||
try { pid = Number(readFileSync(LOCK_FILE, 'utf8').trim()); } catch { /* 读不到当陈锁 */ }
|
||||
if (pid > 0) {
|
||||
try {
|
||||
// signal 0 只探测存在性,不真的发信号
|
||||
process.kill(pid, 0);
|
||||
log(`已有实例在运行(pid ${pid}),本进程退出。`);
|
||||
return false;
|
||||
} catch {
|
||||
// ESRCH:进程没了,是陈锁
|
||||
}
|
||||
}
|
||||
log(`清理陈锁 ${LOCK_FILE}(原 pid ${pid || '未知'} 已不存在)`);
|
||||
try { unlinkSync(LOCK_FILE); } catch { /* 竞态下别人清掉了也行 */ }
|
||||
return acquireLock();
|
||||
}
|
||||
|
||||
function releaseLock() {
|
||||
try {
|
||||
// 只删自己的锁:pid 不符说明这把锁已被别的实例接管
|
||||
if (Number(readFileSync(LOCK_FILE, 'utf8').trim()) === process.pid) unlinkSync(LOCK_FILE);
|
||||
} catch { /* 已经没了 */ }
|
||||
}
|
||||
|
||||
// ─── 权限钩子(B-8)───
|
||||
|
||||
/**
|
||||
* 内联 pi 扩展:把 pi 拦下的危险工具调用转成一封邮件问人。
|
||||
*
|
||||
* 这是 `I-1` 最直接的体现 —— 被平台真正拦下的那一次才是事实,
|
||||
* 不依赖模型「记得」调 request_permission(它会忘,也会在不需要时乱调)。
|
||||
*
|
||||
* pi 的 `tool_call` 钩子**可以 await**(C-9 实测成立:处理器里 await 300ms
|
||||
* 再返回 {block:true},pi 会等),所以这里能真的等人做决定,
|
||||
* 不必走「先拒一次再重试」的退化路径。
|
||||
*
|
||||
* @param {string} piSessionIdRef 用一个 getter 拿会话 id:扩展工厂在
|
||||
* createAgentSession **内部**被调用,那时 session 对象还没返回给桥。
|
||||
*/
|
||||
function permissionExtension(getMailContext) {
|
||||
// pi 默认放行内建工具;桥只拦真正有副作用的那几个。
|
||||
// read/grep/ls 之类不拦:每一步都问人会让 Agent 什么也做不成,
|
||||
// 而人也会很快开始无脑点同意(那比不问更危险)。
|
||||
const GUARDED = new Set(['bash', 'write', 'edit']);
|
||||
|
||||
return (pi) => {
|
||||
pi.on('tool_call', async (event, ctx) => {
|
||||
if (!GUARDED.has(event.toolName)) return;
|
||||
|
||||
const piSessionId = ctx?.sessionManager?.getSessionId?.() || '';
|
||||
const mailSessionId = reverseMap.get(piSessionId);
|
||||
// 不是邮件驱动的会话 → 让位给 pi 自己的本地 UI(B-8.2)。
|
||||
// 占着钩子不放会让人在 TUI 里干活时每一步都卡住等邮件。
|
||||
if (!mailSessionId) return;
|
||||
|
||||
// relay_key 用 pi 给的 toolCallId(B-8.1):服务端会随决策事件回传它,
|
||||
// 桥重启丢了 pendingPermissions 也能对上(B-4.2)。自造随机 id 做不到。
|
||||
const relayKey = `${piSessionId}:${event.toolCallId}`;
|
||||
const ctxInfo = getMailContext(mailSessionId);
|
||||
|
||||
try {
|
||||
await client.post('/permission/request', {
|
||||
question: `是否允许执行 ${event.toolName}?`,
|
||||
options: ['同意', '一直同意', '拒绝'],
|
||||
context: describeToolCall(event),
|
||||
session_id: mailSessionId,
|
||||
to: ctxInfo?.replyTo || '',
|
||||
relay_key: relayKey,
|
||||
});
|
||||
} catch (e) {
|
||||
// 转发失败 → 让位给 pi 本地 UI(B-8.2)。返回 undefined 表示
|
||||
// 「这个钩子不表态」,pi 会走它自己的批准流程。
|
||||
log(`权限转发失败,让位给本地决策: ${describeError(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`权限询问已发出(${event.toolName},key=${relayKey}),等待决策…`);
|
||||
const decision = await new Promise((resolve) => {
|
||||
pendingPermissions.set(relayKey, { resolve, piSessionId });
|
||||
});
|
||||
|
||||
// fail closed(B-9.2 / N-9):只有明确的同意才放行。
|
||||
// 关停时 shutdown() 会用 'shutdown' 唤醒所有等待者,落到这里的 else。
|
||||
if (/^(同意|一直同意|allow|approve|always|yes)/i.test(decision)) {
|
||||
log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`);
|
||||
return;
|
||||
}
|
||||
return { block: true, reason: `用户${decision === 'shutdown' ? '未及决策(桥已关停)' : `拒绝了这次 ${event.toolName} 调用`}` };
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** 把一次工具调用摘要成人能判断的文本(B-8.4)。 */
|
||||
function describeToolCall(event) {
|
||||
const input = event?.input ?? {};
|
||||
if (event.toolName === 'bash') {
|
||||
return `命令:\n${String(input.command ?? '').slice(0, 800)}`;
|
||||
}
|
||||
if (event.toolName === 'write' || event.toolName === 'edit') {
|
||||
return `文件:${input.file_path ?? input.path ?? '(未给出)'}`;
|
||||
}
|
||||
return JSON.stringify(input).slice(0, 800);
|
||||
}
|
||||
|
||||
// ─── 会话解析(B-3)───
|
||||
|
||||
/**
|
||||
* 没有可用 `to_workspace` 时的兜底目录。
|
||||
*
|
||||
* 与 DSH 的 `mailSessionFallback` 同构,但目录名是 `.pi`:那个函数在
|
||||
* lib/ 下(三平台逐字节相同),写死了 `.dsh`,不能为 pi 改。
|
||||
* 让 pi 的会话落进 `~/.dsh/` 会让人以为是 DSH 在干活。
|
||||
*/
|
||||
function piMailFallback(sessionKey) {
|
||||
return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 找到(或建立)这封邮件该落进的 pi 会话。
|
||||
*
|
||||
* Gateway 已经按三维地址的 session 位做完了「复用默认 / 新建 / 具名必须存在」
|
||||
* 的判定,推来的 session_id 就是判定结果 —— 桥只负责忠实映射,
|
||||
* 不自己决定开不开新会话(N-8:404 后自动改用 .new 是禁止的)。
|
||||
*/
|
||||
async function resolveSession(data, mailTools) {
|
||||
const mailSessionID = data.session_id;
|
||||
const bound = mailSessionID ? sessions.get(mailSessionID) : undefined;
|
||||
if (bound) return { ...bound, reused: true };
|
||||
|
||||
// cwd 取寻址里的 path 位(B-3.1)。校验走共用模块:目录不存在时**不创建**
|
||||
// (N-2:笔误会在磁盘上落下真目录,而 Agent 在里面一无所获),拒绝相对路径(N-3)。
|
||||
//
|
||||
// 兜底用 `~/.pi/mail-sessions/<会话>` 而不是共用模块里的 mailSessionFallback ——
|
||||
// 后者写死了 `.dsh` 目录名(那是 DSH 的家),pi 的会话落进去会让人以为
|
||||
// DSH 在干活。lib/ 里的函数三平台逐字节相同,不能为 pi 改它。
|
||||
const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, piMailFallback(mailSessionID));
|
||||
if (!grouped && data.to_workspace) {
|
||||
log(`工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
|
||||
}
|
||||
ensureCwd(cwd, grouped);
|
||||
|
||||
const opened = await openSession({
|
||||
cwd,
|
||||
modelRuntime,
|
||||
customTools: mailTools,
|
||||
extension: permissionExtension((id) => mailContexts.get(id)),
|
||||
});
|
||||
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.add(piSessionId);
|
||||
}
|
||||
|
||||
// 一轮结束就转发总结(B-5)。挂 agent_end 而不是 message_end:
|
||||
// 后者在流式生成中反复触发,转出去的是半截话。
|
||||
// subscribe 收的是一个普通函数(AgentSessionEventListener),不是 {onEvent}。
|
||||
opened.session.subscribe((event) => {
|
||||
if (event?.type === 'agent_end') {
|
||||
// willRetry 为真表示 pi 自己要重试(auto_retry),这一轮还没定论 —— 不转。
|
||||
if (event.willRetry) return;
|
||||
relaySummary(piSessionId).catch((e) => log(`自动转发失败: ${describeError(e)}`));
|
||||
}
|
||||
// pi 侧改名(pi-web 生成标题、人在 TUI 里 /name)→ 同步给 Gateway
|
||||
if (event?.type === 'session_info_changed') {
|
||||
syncNaming(piSessionId, event.name).catch((e) => log(`命名同步失败: ${describeError(e)}`));
|
||||
}
|
||||
});
|
||||
|
||||
log(`新建 pi 会话 ${piSessionId}(cwd=${cwd})`);
|
||||
return { ...entry, reused: false };
|
||||
}
|
||||
|
||||
// ─── 命名一致(C-11 / W-7)───
|
||||
|
||||
/**
|
||||
* pi 的名字 → Gateway → 定稿别名回写进 pi。
|
||||
*
|
||||
* 完整推理见 src/naming.mjs 顶部。这里只是把那套决策接上 I/O。
|
||||
*/
|
||||
async function syncNaming(piSessionId, platformName) {
|
||||
const mailSessionID = reverseMap.get(piSessionId);
|
||||
if (!mailSessionID) return; // 不是邮件驱动的会话,不碰
|
||||
|
||||
const plan = planNamingSync({
|
||||
platformName,
|
||||
mailSubject: mailContexts.get(mailSessionID)?.subject,
|
||||
lastSynced: syncedNames.get(piSessionId),
|
||||
});
|
||||
if (plan.skip) return;
|
||||
|
||||
// 先记下指纹再发请求:响应回来时 setSessionName 会再次触发
|
||||
// session_info_changed,这一步是防自激循环的关键。
|
||||
syncedNames.set(piSessionId, plan.signature);
|
||||
|
||||
const res = await client.post(`/sessions/${mailSessionID}/sync`, {
|
||||
alias: plan.alias,
|
||||
title: plan.title,
|
||||
});
|
||||
|
||||
const entry = sessions.get(mailSessionID);
|
||||
const back = planWriteBack({
|
||||
finalAlias: res?.alias,
|
||||
currentPiName: entry?.session?.sessionName,
|
||||
});
|
||||
log(`命名同步 ${piSessionId}: alias=${res?.alias || '(未变)'} 来源=${plan.source}`);
|
||||
|
||||
if (back.write && entry?.session) {
|
||||
// 顺序要紧:先更新指纹,再改名。
|
||||
//
|
||||
// setSessionName **同步**触发 session_info_changed(实测),于是本函数会在
|
||||
// 这一行里被重入。指纹在改名之后才更新的话,重入那次看到的还是旧指纹,
|
||||
// 于是又打一次 sync —— 每条会话两次请求,内容完全相同。
|
||||
//
|
||||
// 记的是「把定稿别名当作平台名字」会算出的指纹:重入那次的 platformName
|
||||
// 正是 back.name,来源判定成 platform,算出来的就是这个值。
|
||||
syncedNames.set(piSessionId, `platform:${back.name}|${back.name}`);
|
||||
// 只用 setSessionName(走 pi 自己的写入路径)。绝不自己拼路径写会话文件:
|
||||
// 首条 assistant 消息落盘前文件还不存在,pi 首次落盘用 openSync(file,"wx"),
|
||||
// 抢先创建会让它抛 EEXIST(实测)。
|
||||
entry.session.setSessionName(back.name);
|
||||
log(`别名回写 pi:${back.name}(${back.reason})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 自动转发(B-5)───
|
||||
|
||||
async function relaySummary(piSessionId) {
|
||||
const mailSessionID = reverseMap.get(piSessionId);
|
||||
if (!mailSessionID) return;
|
||||
// 只对邮件驱动的会话转发(B-5.5):人在 pi 里正常干活时不该往邮箱灌总结
|
||||
if (!mailDriven.has(piSessionId)) return;
|
||||
|
||||
const entry = sessions.get(mailSessionID);
|
||||
if (!entry) return;
|
||||
|
||||
// 一轮结束是命名的自然时机(C-11 / D-5)。
|
||||
//
|
||||
// 这一步不能只挂在 session_info_changed 上:桥用 SDK 起的会话**永远不会**
|
||||
// 触发那个事件 —— pi 的标题生成器在 pi-web 里,不在内核里,SDK 路径上没有它。
|
||||
// 只等事件的话别名永远是空的,于是 `name@path.<别名>` 续谈无从下手
|
||||
// (实测过:第一封邮件跑通了,sessions.session_alias 仍是空串)。
|
||||
//
|
||||
// 放在转发**之前**:回信里会带上会话别名,收件人看到的第一封回信就能用它续谈。
|
||||
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);
|
||||
if (!text) return; // 空文本不发空邮件(B-5.4)
|
||||
|
||||
const ctx = mailContexts.get(mailSessionID);
|
||||
if (!ctx?.replyTo) return; // 不知道回给谁
|
||||
|
||||
// 幂等键用 pi 的会话 id + 会话树叶子 id:两者都落盘,重启重放也是同一个键。
|
||||
const relayKey = relayKeyFor(piSessionId, entry.sessionManager.getLeafId?.());
|
||||
if (relayedSummaries.get(piSessionId) === relayKey) return;
|
||||
|
||||
// 模型这一轮已亲手回过这条线索 → 让位(B-5.3)。
|
||||
// 否则收件箱里是两封说同一件事的邮件(生产实测过)。
|
||||
if (shouldSkipAutoRelay(explicitSends.get(piSessionId), ctx.replyTo, ctx.mailID)) {
|
||||
explicitSends.delete(piSessionId);
|
||||
relayedSummaries.set(piSessionId, relayKey);
|
||||
log(`本轮模型已主动回信 ${ctx.replyTo},跳过自动转发`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.post('/mail/send', {
|
||||
to: ctx.replyTo,
|
||||
subject: replySubject(ctx.subject),
|
||||
body: text,
|
||||
reply_to: ctx.mailID || '',
|
||||
// relay + relay_key 走免配额通道(I-2):模型已经把话说完了,
|
||||
// 桥只是把它搬到邮件里。对搬运收费会让配额用尽时 Agent 连交代都做不了。
|
||||
relay: 'summary',
|
||||
relay_key: relayKey,
|
||||
});
|
||||
relayedSummaries.set(piSessionId, relayKey);
|
||||
explicitSends.delete(piSessionId); // 一轮结束,窗口关闭
|
||||
log(`已转发本轮总结给 ${ctx.replyTo}(${text.length} 字)`);
|
||||
}
|
||||
|
||||
// ─── 投递(B-3 / B-6)───
|
||||
|
||||
async function deliverMail(data, kind, mailTools) {
|
||||
const { session, reused } = await resolveSession(data, mailTools);
|
||||
const piSessionId = session.sessionId;
|
||||
|
||||
// 新一轮开始:清掉上一轮「模型主动发过信」的记录。不清的话,
|
||||
// 上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。
|
||||
explicitSends.delete(piSessionId);
|
||||
|
||||
if (kind === 'mail' && data.session_id) {
|
||||
// 一个会话里可能来过多封信,只留最近那封 —— 回信要落回最新的线索
|
||||
mailContexts.set(data.session_id, {
|
||||
replyTo: data.from_name || '',
|
||||
subject: data.subject || '',
|
||||
mailID: data.mail_id || '',
|
||||
});
|
||||
}
|
||||
|
||||
const prompt = buildMailPrompt({ agentName: AGENT_NAME, data, kind, reused });
|
||||
|
||||
// 续谈:会话已经存在,模型也已经定了(pi 的模型在 createAgentSession 时绑定),
|
||||
// 所以这一支不做模型降级。runTurn 内部按 isStreaming 分流:
|
||||
// 空闲就直接起一轮,正在跑就排到当轮之后(不打断上一封邮件的工作)。
|
||||
if (reused) {
|
||||
const outcome = await runTurn(session, prompt, TURN_TIMEOUT_MS);
|
||||
log(`续谈 ${piSessionId}(mail ${data.mail_id}${outcome.queued ? ',已排队' : ''})`);
|
||||
// 续谈失败不换模型重试(换模型要换会话,会丢掉整条上下文 ——
|
||||
// 而上下文正是发件人指定这条会话的原因),但要让失败可见。
|
||||
if (!outcome.ok) throw new Error(`续谈失败: ${outcome.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 按管理员划定的范围逐个尝试(D-3)。
|
||||
// 关键点:`prompt()` resolve **不代表模型跑成功了** —— 无凭证的 provider
|
||||
// 会让它 reject(实测 `No API key found for amazon-bedrock.`),
|
||||
// 而上游报错走 stopReason==='error'。判定交给 classifyTurnOutcome。
|
||||
const attempts = modelAttemptOrder(allowedModels, {
|
||||
provider: REPLY_PROVIDER,
|
||||
model: REPLY_MODEL,
|
||||
});
|
||||
const failures = [];
|
||||
|
||||
for (const route of attempts) {
|
||||
const label = route ? `${route.provider}/${route.model}` : '(平台默认)';
|
||||
if (route) {
|
||||
const model = modelRuntime.getModel(route.provider, route.model);
|
||||
if (!model) {
|
||||
// 目录里根本没有这个路由:同步就能判定,不必起一轮
|
||||
failures.push({ ...route, error: `平台目录里没有 ${label}` });
|
||||
log(`模型 ${label} 不存在,跳过`);
|
||||
continue;
|
||||
}
|
||||
// 换模型要换会话:pi 的模型在 createAgentSession 时绑定。
|
||||
// 上一次尝试失败的会话没有任何 assistant 消息,丢掉不损失内容。
|
||||
const cwd = sessions.get(data.session_id)?.cwd;
|
||||
const current = sessions.get(data.session_id)?.session;
|
||||
current?.dispose?.();
|
||||
const retried = await openSession({
|
||||
cwd,
|
||||
modelRuntime,
|
||||
model,
|
||||
customTools: mailTools,
|
||||
extension: permissionExtension((id) => mailContexts.get(id)),
|
||||
});
|
||||
rebind(data.session_id, current?.sessionId ?? piSessionId, retried, cwd);
|
||||
const outcome = await runTurn(retried.session, prompt, TURN_TIMEOUT_MS);
|
||||
if (outcome.ok) {
|
||||
if (failures.length) log(`${label} 成功(前 ${failures.length} 个失败)`);
|
||||
return;
|
||||
}
|
||||
failures.push({ ...route, error: outcome.error });
|
||||
log(`模型 ${label} 失败: ${outcome.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const outcome = await runTurn(session, prompt, TURN_TIMEOUT_MS);
|
||||
if (outcome.ok) {
|
||||
if (failures.length) log(`${label} 成功(前 ${failures.length} 个失败)`);
|
||||
return;
|
||||
}
|
||||
failures.push({ error: outcome.error });
|
||||
log(`模型 ${label} 失败: ${outcome.error}`);
|
||||
}
|
||||
|
||||
// 全部失败 → 必须回信(B-6):模型一次都没跑起来,会话里没有任何
|
||||
// assistant 消息,自动转发因此什么也不会发 —— 发件人只会看到再无音讯。
|
||||
if (kind === 'mail' && data.from_name) {
|
||||
try {
|
||||
await client.post('/mail/send', {
|
||||
to: data.from_name,
|
||||
subject: `处理失败: ${data.subject || '(无主题)'}`,
|
||||
body: renderFailureReport(failures, data.subject),
|
||||
reply_to: data.mail_id || '',
|
||||
relay: 'summary',
|
||||
relay_key: `model-failure:${data.mail_id || piSessionId}`,
|
||||
});
|
||||
log(`已回报模型调用失败给 ${data.from_name}`);
|
||||
} catch (e) {
|
||||
log(`失败回报也发不出去: ${describeError(e)}`);
|
||||
}
|
||||
}
|
||||
// 发完仍要 throw(B-6.4):静默会让这次失败只存在于邮件里,日志上看不出来
|
||||
throw new Error(`范围内 ${failures.length} 个模型全部失败:${failures.map(f => f.error).join(' | ')}`);
|
||||
}
|
||||
|
||||
/** 换模型重开会话后,把三张映射表指向新会话。 */
|
||||
function rebind(mailSessionID, oldPiId, opened, cwd) {
|
||||
reverseMap.delete(oldPiId);
|
||||
mailDriven.delete(oldPiId);
|
||||
const piSessionId = opened.session.sessionId;
|
||||
// cwd 由调用方传:AgentSession 上没有 cwd getter(只有 sessionId /
|
||||
// sessionFile / sessionName),从 sessionManager.getCwd() 也行,
|
||||
// 但这里本来就有那个值,多绕一层没有意义。
|
||||
const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd };
|
||||
if (mailSessionID) {
|
||||
sessions.set(mailSessionID, entry);
|
||||
reverseMap.set(piSessionId, mailSessionID);
|
||||
mailDriven.add(piSessionId);
|
||||
}
|
||||
opened.session.subscribe((event) => {
|
||||
if (event?.type === 'agent_end' && !event.willRetry) {
|
||||
relaySummary(piSessionId).catch((e) => log(`自动转发失败: ${describeError(e)}`));
|
||||
}
|
||||
if (event?.type === 'session_info_changed') {
|
||||
syncNaming(piSessionId, event.name).catch((e) => log(`命名同步失败: ${describeError(e)}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 权限决策回来(B-4)───
|
||||
|
||||
async function handlePermissionDecision(data, mailTools) {
|
||||
const relayKey = data.relay_key || '';
|
||||
const pending = relayKey ? pendingPermissions.get(relayKey) : undefined;
|
||||
|
||||
if (pending) {
|
||||
pendingPermissions.delete(relayKey);
|
||||
pending.resolve(String(data.decision || '拒绝'));
|
||||
log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 找不到挂起项(桥重启丢了内存映射)→ 退化为把决策当一封通知投进原会话(B-4.2)。
|
||||
// 此时 pi 侧那次工具调用早已随进程消失,但人刚刚点了「同意」——
|
||||
// 什么都不做的话人以为自己批准了、Agent 却毫无反应。
|
||||
if (!data.session_id || !sessions.has(data.session_id)) {
|
||||
// **不得凭空新开会话**(B-4.3)
|
||||
log(`权限决策 ${relayKey} 无对应会话,忽略`);
|
||||
return;
|
||||
}
|
||||
log(`权限 ${relayKey} 无挂起项,退化为通知投递`);
|
||||
await deliverMail(data, 'permission', mailTools);
|
||||
}
|
||||
|
||||
// ─── 心跳(B-2)───
|
||||
|
||||
async function reportSessions() {
|
||||
try {
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent');
|
||||
// 不传参数:`listAll(dir)` 把字符串当**自定义会话目录**,传 getAgentDir()
|
||||
// 会去 ~/.pi/agent 下直接找 .jsonl(那里没有),得到空列表。
|
||||
// 不传时它用默认的 ~/.pi/agent/sessions,逐个 cwd 子目录扫。
|
||||
//
|
||||
// 用 listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关,
|
||||
// 按前者过滤会漏掉所有真正在干活的会话。
|
||||
const all = await SessionManager.listAll();
|
||||
return snapshotPiSessions(all, (id) => mailDriven.has(id));
|
||||
} catch (e) {
|
||||
// 拉不到就**省略字段**而不是传 [](N-7 / W-3):
|
||||
// 空数组的语义是「平台确实一条会话都没有」,会把服务端镜像抹掉。
|
||||
log(`会话列表读取失败: ${describeError(e)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function reportModels() {
|
||||
try {
|
||||
// getAvailable 而不是 getModels:后者本机有 1221 条,其中真能调起来的只有 1 条。
|
||||
// 上报目录的全部意义就是让管理员别选中一个注定失败的路由。
|
||||
const available = await modelRuntime.getAvailable();
|
||||
return snapshotPiModels(available);
|
||||
} catch (e) {
|
||||
log(`模型目录读取失败: ${describeError(e)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function catchUp(pending, mailTools) {
|
||||
if (!pending) return;
|
||||
try {
|
||||
const box = await client.get('/mail/inbox?status=unread&limit=20');
|
||||
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
|
||||
if (!tasks.length) return;
|
||||
log(`补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`);
|
||||
// 串行(B-7.2):每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
|
||||
for (const ev of tasks) {
|
||||
if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查(B-7.6)
|
||||
deliveredMails.add(ev.mail_id);
|
||||
try {
|
||||
await deliverMail(ev, 'mail', mailTools);
|
||||
} catch (e) {
|
||||
log(`补投 ${ev.mail_id} 失败: ${describeError(e)}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(`补投失败: ${describeError(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 启动 / 关停 ───
|
||||
|
||||
async function main() {
|
||||
if (!acquireLock()) process.exit(0);
|
||||
|
||||
// B-1.1:环境变量 → ~/.agentmail/agent.key → 本地生成并打印全文
|
||||
let agentKey = process.env.AGENTMAIL_AGENT_KEY || readLocalKey();
|
||||
if (!agentKey && !AGENT_SECRET) agentKey = generateLocalKey(log);
|
||||
|
||||
client = new GatewayClient({
|
||||
url: GATEWAY_URL,
|
||||
agentName: AGENT_NAME,
|
||||
agentKey,
|
||||
agentSecret: AGENT_SECRET,
|
||||
});
|
||||
|
||||
// ModelRuntime 建一次全进程共用:它要读 auth.json / models.json 并做
|
||||
// 可用性探测,每条会话建一个既慢又会重复打 provider 的探测请求。
|
||||
//
|
||||
// allowModelNetwork 保持默认的 false:桥启动时不去网上拉模型目录。
|
||||
// 拉了也没用 —— 上报给 Gateway 的是 getAvailable()(有凭证、真能调起来的),
|
||||
// 而那取决于本机 auth.json,不取决于目录里有多少条。开着只会让
|
||||
// 启动多等一个网络往返,而且断网时启动路径上多一个可失败点。
|
||||
modelRuntime = await ModelRuntime.create();
|
||||
const runtimeErr = modelRuntime.getError?.();
|
||||
if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`);
|
||||
|
||||
const mailTools = createMailTools({ client, log, agentName: AGENT_NAME });
|
||||
|
||||
try {
|
||||
await client.register(); // B-1.2
|
||||
saveConfig({ gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, registered_at: new Date().toISOString() });
|
||||
log(`已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}(${agentKey ? '密钥认证' : 'name/secret 认证'})。`);
|
||||
} catch (e) {
|
||||
// 密钥未登记时这里报「密钥无效」—— 必须说清该做什么,
|
||||
// 否则用户只看到一句 401,不知道要拿密钥去后台登记。
|
||||
log(`注册失败: ${describeError(e)}`);
|
||||
if (agentKey) log(`若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥。`);
|
||||
}
|
||||
|
||||
let caughtUp = false;
|
||||
const beat = async () => {
|
||||
const [platform_sessions, models] = await Promise.all([reportSessions(), reportModels()]);
|
||||
const body = {};
|
||||
if (platform_sessions) body.platform_sessions = platform_sessions;
|
||||
if (models) body.models = models;
|
||||
try {
|
||||
const res = await client.post('/agent/heartbeat', body);
|
||||
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models; // B-2.2
|
||||
if (!caughtUp) { // B-7.1:只在首个成功心跳后补一次
|
||||
caughtUp = true;
|
||||
await catchUp(res?.pending_mails, mailTools);
|
||||
}
|
||||
} catch {
|
||||
// B-2.1:心跳失败不重试不报错。真连不上时 Gateway 会把它判成离线,
|
||||
// 那才是可见的信号;桥自己打一串错误日志只会淹掉真正的问题。
|
||||
}
|
||||
};
|
||||
await beat(); // B-1.3:不等第一个 30 秒周期
|
||||
heartbeatTimer = setInterval(beat, 30_000); // B-1.5
|
||||
|
||||
client.startSSE((type, data) => { // B-1.4:首次不带 Last-Event-ID
|
||||
if (type === 'permission_decision') {
|
||||
handlePermissionDecision(data, mailTools).catch((e) =>
|
||||
log(`权限决策处理失败: ${describeError(e)}`));
|
||||
return;
|
||||
}
|
||||
if (type !== 'new_mail') return;
|
||||
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
|
||||
const id = data?.mail_id;
|
||||
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
|
||||
deliveredMails.add(id);
|
||||
deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`));
|
||||
}, log);
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig));
|
||||
}
|
||||
|
||||
function shutdown(reason) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log(`收到 ${reason},关停中…`);
|
||||
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer); // B-9.1
|
||||
client?.stopSSE();
|
||||
|
||||
// B-9.2 / N-9:所有未决权限询问 fail closed。
|
||||
// 不唤醒的话 pi 侧那些 await 永不返回,整条会话挂死;
|
||||
// 而默认放行一个没人批准的危险操作,比让它失败严重得多。
|
||||
for (const [key, p] of pendingPermissions) {
|
||||
log(`未决权限 ${key} fail closed`);
|
||||
p.resolve('shutdown');
|
||||
}
|
||||
pendingPermissions.clear();
|
||||
|
||||
for (const { session } of sessions.values()) {
|
||||
try { session.dispose?.(); } catch { /* 关停期的报错没有价值 */ }
|
||||
}
|
||||
releaseLock();
|
||||
// B-9.3:不发「插件下线」通知邮件
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
log(`启动失败: ${describeError(e)}`);
|
||||
releaseLock();
|
||||
process.exit(1);
|
||||
});
|
||||
115
plugins/pi-mail-bridge/src/naming.mjs
Normal file
115
plugins/pi-mail-bridge/src/naming.mjs
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 会话命名的双向一致(C-11 / W-7 / D-5)。
|
||||
*
|
||||
* 一句话:**Gateway 定稿,pi 接受定稿**。
|
||||
*
|
||||
* pi/pi-web 生成名字 ──①观测──▶ 桥 ──②POST /sessions/{id}/sync──▶ Gateway
|
||||
* pi 的 session_info ◀──④回写──── ③响应里的 final alias
|
||||
*
|
||||
* 为什么不能各自命名然后指望撞上:别名在 AgentMail 侧负有寻址唯一性义务
|
||||
* (partial unique index + 撞名自动追 -2/-3),pi 侧没有这个约束。
|
||||
* 而 `alias_source='manual'` 的会话(人在界面上改过名)永远不接受平台同步,
|
||||
* `SyncSessionAlias` 会把**当前别名原样返回**。所以只有用响应里的值回写,
|
||||
* 两边看到的才是同一个名字。单向推送做不到这一点。
|
||||
*
|
||||
* ④ 必须判「与上次写入的值不同」才执行,否则 setSessionName 触发
|
||||
* session_info_changed,钩子又去 sync,成自激循环。
|
||||
*
|
||||
* 三个实测出来的约束(探针脚本验证过,见 test/naming.test.mjs 里的注释):
|
||||
* - pi 首条 assistant 消息落盘前会话文件**不存在**,SessionManager 首次落盘用
|
||||
* `openSync(file, "wx")`;桥抢先按路径写会让 pi 侧 flush 抛 EEXIST。
|
||||
* → 回写只用 `session.setSessionName()`(走 pi 自己的写入路径),
|
||||
* 绝不自己拼路径写文件。
|
||||
* - 活着的 SessionManager 不 watch 文件;外部改名它看不见,之后它自己
|
||||
* append 一条 session_info 反而会盖掉外部的("最后一条生效")。
|
||||
* - 空名字是**清除**语义(`appendSessionInfo(" ")` 之后 getSessionName() 变
|
||||
* undefined),因此不能用空串表达「无变化」。
|
||||
*/
|
||||
|
||||
import { slugFromTitle, isUnusableName } from '../lib/session-snapshot.js';
|
||||
|
||||
/**
|
||||
* 决定这一轮要不要向 Gateway 同步命名,以及同步什么。
|
||||
*
|
||||
* 别名的降级阶梯(D-5):
|
||||
* 1. 平台生成的名字派生的 slug
|
||||
* 2. 名字不可用(pi-web 的思维链泄漏、纯符号)或**根本没有名字**
|
||||
* → 退到邮件主题派生
|
||||
* 3. 两者都没有 → **不写回**(W-7.2:绝不写占位别名)
|
||||
*
|
||||
* 第 2 步里的「根本没有名字」是 pi 的常态而非例外:桥用 SDK 起的会话不经过
|
||||
* pi-web 的标题生成器(那个生成器在 pi-web 包里,不在 pi 内核里),
|
||||
* 因此 `session.sessionName` 一直是 undefined。只等平台命名的话别名永远是空的,
|
||||
* `name@path.<别名>` 续谈无从下手 —— 实测过这个后果。
|
||||
*
|
||||
* 标题一律用平台原文(不派生、不清洗):`I-4` 说插件只搬运。
|
||||
* 唯一的例外是判废 —— 判废的结果是「不写」,不是「改写成别的」。
|
||||
*
|
||||
* 返回值里的 `signature` 是「本次提交内容的指纹」,调用方存下它并在下一轮
|
||||
* 作为 `lastSynced` 传回,用来判「没变化就别重复提交」。**不能用平台名字本身**
|
||||
* 充当这个角色:名字为空时(上面那个常态)它无法区分「还没提交过」与
|
||||
* 「提交过、内容没变」,于是每轮心跳都白打一次 sync。
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.platformName pi 侧 session_info 里的名字
|
||||
* @param {string} input.mailSubject 该会话最近一封来信的主题(兜底用)
|
||||
* @param {string} input.lastSynced 上一次提交的 signature
|
||||
* @returns {{skip: true, reason: string} | {skip: false, alias: string, title: string, source: string, signature: string}}
|
||||
*/
|
||||
export function planNamingSync({ platformName, mailSubject, lastSynced }) {
|
||||
const name = String(platformName ?? '').trim();
|
||||
const prev = String(lastSynced ?? '').trim();
|
||||
|
||||
const decide = () => {
|
||||
if (name && !isUnusableName(name)) {
|
||||
const alias = slugFromTitle(name);
|
||||
// 名字看着正常但全是分隔符("..." / "@@@")→ 派生不出别名,
|
||||
// 但**标题仍然值得写**:subject 那一列不负责寻址,没有字符限制。
|
||||
if (alias) return { alias, title: name, source: 'platform' };
|
||||
return { alias: '', title: name, source: 'platform-title-only' };
|
||||
}
|
||||
|
||||
// 平台名字不可用或不存在:退到邮件主题。它是人写的,
|
||||
// 天然比模型的思维链靠谱,而 SDK 起的会话本来就没有平台名字。
|
||||
const subject = String(mailSubject ?? '').trim();
|
||||
if (subject) {
|
||||
const alias = slugFromTitle(subject);
|
||||
if (alias) return { alias, title: '', source: 'mail-subject' };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const plan = decide();
|
||||
|
||||
// 什么都没有:不写。宁可让会话保持无别名(数据库允许 NULL),
|
||||
// 也不要写一个 "session-123" 这样的占位值 —— 那种别名对人毫无指代作用,
|
||||
// 而且一旦落库就把 alias 位占住了,真正的名字来了也只能追 -2 后缀。
|
||||
if (!plan) return { skip: true, reason: 'no-usable-name' };
|
||||
|
||||
const signature = `${plan.source}:${plan.alias}|${plan.title}`;
|
||||
if (signature === prev) return { skip: true, reason: 'unchanged' };
|
||||
return { ...plan, skip: false, signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定要不要把 Gateway 定稿的别名回写进 pi。
|
||||
*
|
||||
* 回写的三种触发情形:
|
||||
* - 撞名:提议 `fix-leak`,Gateway 给了 `fix-leak-2`
|
||||
* - manual 保护:人在界面上改成了 `紧急排查`,Gateway 原样返回它
|
||||
* - 规范化:提议里含 `.` `@` `/` 空白,被 normalizeAlias 换成了 `-`
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.finalAlias Gateway 响应里的 alias
|
||||
* @param {string} input.currentPiName pi 侧当前的名字
|
||||
* @returns {{write: boolean, name: string, reason: string}}
|
||||
*/
|
||||
export function planWriteBack({ finalAlias, currentPiName }) {
|
||||
const final = String(finalAlias ?? '').trim();
|
||||
// 服务端没回别名(本次只同步了标题)→ 没有定稿值可写
|
||||
if (!final) return { write: false, name: '', reason: 'no-alias-in-response' };
|
||||
const cur = String(currentPiName ?? '').trim();
|
||||
if (cur === final) return { write: false, name: '', reason: 'already-equal' };
|
||||
// 空名字是清除语义,这里 final 非空,所以安全
|
||||
return { write: true, name: final, reason: cur ? 'diverged' : 'pi-unnamed' };
|
||||
}
|
||||
137
plugins/pi-mail-bridge/src/session-pool.mjs
Normal file
137
plugins/pi-mail-bridge/src/session-pool.mjs
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* pi 会话池 —— 每条 AgentMail 会话对应一条 pi 会话。
|
||||
*
|
||||
* 为什么桥必须自己持有 pi 会话(而不是写成一个 pi 扩展):
|
||||
* 扩展被加载进**一条已经存在的**会话里,cwd 由启动 pi 的人决定;而 B-3.1 要求
|
||||
* 每封邮件的 to_workspace 成为会话 cwd。扩展做不到「按邮件新开一条 cwd 不同的
|
||||
* 会话」,所以桥是一个常驻进程(C-7),用 SDK 的 createAgentSession 起会话。
|
||||
*
|
||||
* 每条会话一套 SettingsManager / ResourceLoader / SessionManager:它们都按 cwd
|
||||
* 解析项目级配置(.pi/、skills、prompts),共用一份会把 A 项目的配置带进 B 项目。
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager, SettingsManager, DefaultResourceLoader, getAgentDir }
|
||||
from '@earendil-works/pi-coding-agent';
|
||||
|
||||
/**
|
||||
* 起一条 pi 会话。
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.cwd 会话工作目录(已由 resolveWorkspaceCwd 校验过存在)
|
||||
* @param {any} opts.modelRuntime 共享的 ModelRuntime(建一次很贵,池外传进来)
|
||||
* @param {any} [opts.model] 指定模型;省略则用 settings 里的默认
|
||||
* @param {any[]} opts.customTools 邮件工具(send_mail / read_inbox / …)
|
||||
* @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 }) {
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
// 关掉磁盘上的全局扩展。两个理由:
|
||||
// 1. 本机的 pi-a2a / pi-acp 在加载时 listen 固定端口(12010/12011),
|
||||
// 守护进程里加载会 EADDRINUSE,把整条会话拖死。
|
||||
// 2. 桥起的会话是给邮件用的,不该继承人类交互用的那套扩展(TUI 命令、
|
||||
// 快捷键、状态栏都没有意义)。
|
||||
// 邮件工具走 customTools,权限钩子走下面的 extensionFactories。
|
||||
noExtensions: true,
|
||||
extensionFactories: extension
|
||||
? [{ name: 'agentmail-bridge', factory: extension }]
|
||||
: [],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const sessionManager = SessionManager.create(cwd);
|
||||
const created = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
modelRuntime,
|
||||
// model 为 undefined 时 SDK 用 settings 里的默认模型,正好对应
|
||||
// modelAttemptOrder 里那个 `undefined`(= 不指定、交给平台)。
|
||||
...(model ? { model } : {}),
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
resourceLoader,
|
||||
customTools,
|
||||
});
|
||||
|
||||
return {
|
||||
session: created.session,
|
||||
sessionManager,
|
||||
diagnostics: created.extensionsResult?.diagnostics ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一轮并等到真正的结论(C-4 / D-3)。
|
||||
*
|
||||
* `session.prompt()` 的 promise 在**这一轮彻底结束**时才 resolve,所以不需要
|
||||
* 额外订阅 agent_end 去等。但它 resolve 了**不代表模型跑成功了** ——
|
||||
* 判定交给 classifyTurnOutcome(三条互不重叠的失败信号,见那里的注释)。
|
||||
*
|
||||
* 60 秒超时算成功(与另两个插件同一取舍):长任务很正常,把它判成失败会
|
||||
* 换模型重跑一遍,等于同一封邮件跑两次。超时只是「不再等着上报结论」,
|
||||
* 会话仍在跑,轮次结束后 agent_end 会照常触发自动转发。
|
||||
*
|
||||
* 会话正在跑时走排队(返回 queued),**不能**在那种情况下判结论:
|
||||
* prompt 排完队就 resolve,此时 session.messages 里最后一条是**上一轮**的,
|
||||
* 拿它判定会把上一轮的成败当成这一轮的。
|
||||
*
|
||||
* @param {any} session
|
||||
* @param {string} promptText
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<{ok: boolean, error: string, aborted: boolean, timedOut: boolean, queued: boolean}>}
|
||||
*/
|
||||
export async function runTurn(session, promptText, timeoutMs = 60_000) {
|
||||
const { classifyTurnOutcome } = await import('./turn.mjs');
|
||||
|
||||
// 排队分支:模型还在说话时又来一封邮件。
|
||||
//
|
||||
// streamingBehavior 必选,缺了 prompt 直接抛
|
||||
// "Agent is already processing. Specify streamingBehavior…"。
|
||||
// 取 followUp 而不是 steer:steer 会把当前这一轮打断,
|
||||
// 而当前这一轮正在处理**上一封邮件** —— 那封邮件的发件人也在等回信。
|
||||
if (session.isStreaming) {
|
||||
await session.prompt(promptText, { streamingBehavior: 'followUp' });
|
||||
return { ok: true, error: '', aborted: false, timedOut: false, queued: true };
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ ok: true, error: '', aborted: false, timedOut: true, queued: false }),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
const run = session.prompt(promptText)
|
||||
.then(() => ({ ...classifyTurnOutcome({ messages: session.messages }), timedOut: false, queued: false }))
|
||||
.catch((e) => ({ ...classifyTurnOutcome({ error: e }), timedOut: false, queued: false }));
|
||||
|
||||
try {
|
||||
return await Promise.race([run, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续谈:往一条已经存在的会话里追加一轮。
|
||||
*
|
||||
* 这就是 `runTurn` —— 不需要第二个函数。
|
||||
*
|
||||
* **不能**用 `session.followUp()`:那个方法只往 followUpQueue 里塞消息,
|
||||
* 队列**只在运行中的轮次末尾**被 drain(pi-agent-core/agent.js 的 run 循环,
|
||||
* 以及 `continue()`)。会话空闲时(上一轮早已结束)塞进去的消息永远没人取,
|
||||
* 于是这封邮件既没有回信也没有报错 —— 实测踩过:日志打了「续谈」,
|
||||
* 收件箱里只有来信没有回复。
|
||||
*
|
||||
* `runTurn` 按 `isStreaming` 分流,两种状态都正确:
|
||||
* - 空闲 → `prompt()` 直接起一轮
|
||||
* - 正在跑 → `prompt(text, {streamingBehavior:'followUp'})` 排到当轮之后
|
||||
*/
|
||||
export { runTurn as followUpTurn };
|
||||
355
plugins/pi-mail-bridge/src/tools.mjs
Normal file
355
plugins/pi-mail-bridge/src/tools.mjs
Normal file
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 邮件工具(T-1..T-6)—— 注册给 pi 里的模型。
|
||||
*
|
||||
* pi 的工具定义用 TypeBox schema,这里直接写等价的 JSON Schema 字面量:
|
||||
* TypeBox 的 `Type.Object({...})` 产出的就是这个形状,而桥是 .mjs(无编译步骤),
|
||||
* 少一个运行时依赖。
|
||||
*
|
||||
* `execute(toolCallId, params, signal, onUpdate, ctx)` 的 ctx 是 ExtensionContext,
|
||||
* 由此可以拿到 `ctx.sessionManager.getSessionId()` —— 这就是 C-6 要求的
|
||||
* 「工具能拿到当前会话 id」,自动转发去重(B-5.3)靠它把发信记到正确的会话上。
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
import {
|
||||
renderInbox,
|
||||
idsToMarkRead,
|
||||
formatSize,
|
||||
DEFAULT_INBOX_STATUS,
|
||||
DEFAULT_INBOX_LIMIT,
|
||||
} from '../lib/inbox-format.js';
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
import { noteExplicitSend } from '../lib/relay-dedup.js';
|
||||
|
||||
const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('./gateway.mjs').GatewayClient} deps.client
|
||||
* @param {(msg: string) => void} deps.log
|
||||
* @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定
|
||||
* 「我是收件人还是抄送方」并给出可投递地址。
|
||||
*/
|
||||
export function createMailTools({ client, log, agentName = '' }) {
|
||||
const sendMail = {
|
||||
name: 'send_mail',
|
||||
label: 'SendMail',
|
||||
description:
|
||||
'发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,' +
|
||||
'.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
to: { type: 'string', description: '收件人三维地址,如 admin@/home/program/x' },
|
||||
subject: { type: 'string', description: '邮件主题' },
|
||||
body: { type: 'string', description: '邮件正文(Markdown)' },
|
||||
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
|
||||
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
|
||||
session_alias: { type: 'string', description: '给新会话命名(仅 .new 时生效)' },
|
||||
attachment_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '附件 ID 列表(先用 upload_attachment 取得)',
|
||||
},
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
const result = await client.post('/mail/send', {
|
||||
to: params.to,
|
||||
subject: params.subject,
|
||||
body: params.body,
|
||||
cc: params.cc || '',
|
||||
reply_to: params.reply_to || '',
|
||||
session_alias: params.session_alias || '',
|
||||
attachment_ids: params.attachment_ids || [],
|
||||
// 这里**不带 relay**(N-5):模型的自主发信要计配额,
|
||||
// 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。
|
||||
});
|
||||
// 记下「模型这一轮亲手发过信」,供 B-5.3 让位判定。
|
||||
// 会话 id 从 ctx 取:工具不知道自己被哪条会话调用,就没法正确归属。
|
||||
noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, params.reply_to);
|
||||
const budget = typeof result.budget_remaining === 'number'
|
||||
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。`
|
||||
: '';
|
||||
return text(`邮件已发送(ID: ${result.mail_id})${budget}`);
|
||||
},
|
||||
};
|
||||
|
||||
const readInbox = {
|
||||
name: 'read_inbox',
|
||||
label: 'ReadInbox',
|
||||
description:
|
||||
'查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' +
|
||||
'每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', description: '过滤条件 unread|all,默认 unread' },
|
||||
limit: { type: 'number', description: '返回数量,默认 5' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const status = params.status || DEFAULT_INBOX_STATUS;
|
||||
const { mails } = await client.get(
|
||||
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`,
|
||||
);
|
||||
|
||||
// 渲染与已读策略走共用模块:与另两个平台必须一致,
|
||||
// 每条规则对应过一次真实的错误行为(见 lib/inbox-format.js)。
|
||||
//
|
||||
// 传 agentName 才能判定身份并给出可投递地址 —— 不传的话模型只能
|
||||
// 从抄送行里抄一个 `.new`,而那是一次性的,回过去只会再建一条平行会话。
|
||||
const listed = renderInbox(mails, 200, agentName);
|
||||
|
||||
const ids = idsToMarkRead(params.status, mails);
|
||||
if (ids.length) {
|
||||
// 标记失败不该让 read_inbox 失败:正文已经取到了,
|
||||
// 代价只是下次重复看到,比丢掉这次读取轻。
|
||||
client.post('/mail/read', { mail_ids: ids }).catch((e) =>
|
||||
log(`[pi-mail-bridge] 标记已读失败: ${e?.message || e}`));
|
||||
}
|
||||
return text(listed);
|
||||
},
|
||||
};
|
||||
|
||||
const forwardMail = {
|
||||
name: 'forward_mail',
|
||||
label: 'ForwardMail',
|
||||
description:
|
||||
'转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,' +
|
||||
'转发按目标地址另行定位会话。只能转发自己参与过的邮件。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mail_id: { type: 'string', description: '要转发的邮件 ID(从 read_inbox 获得)' },
|
||||
to: { type: 'string', description: '新收件人的三维地址' },
|
||||
comment: { type: 'string', description: '转发说明,置于引用原文之前' },
|
||||
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
|
||||
subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' },
|
||||
session_alias: { type: 'string', description: '仅在目标地址以 .new 结尾时生效:给新会话命名' },
|
||||
},
|
||||
required: ['mail_id', 'to'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
// 路径带 mail_id(POST /mail/{id}/forward),不是请求体里的字段
|
||||
const result = await client.post(`/mail/${params.mail_id}/forward`, {
|
||||
to: params.to,
|
||||
comment: params.comment || '',
|
||||
cc: params.cc || '',
|
||||
subject: params.subject || '',
|
||||
session_alias: params.session_alias || '',
|
||||
});
|
||||
noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, '');
|
||||
return text(`已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`);
|
||||
},
|
||||
};
|
||||
|
||||
const uploadAttachment = {
|
||||
name: 'upload_attachment',
|
||||
label: 'UploadAttachment',
|
||||
description: '上传本地文件作为邮件附件,返回 attachment_id。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: '本地文件的绝对路径' },
|
||||
},
|
||||
required: ['file_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const buf = await readFile(params.file_path);
|
||||
const a = await client.uploadFile(buf, basename(params.file_path) || 'file');
|
||||
return text(
|
||||
`已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const downloadAttachment = {
|
||||
name: 'download_attachment',
|
||||
label: 'DownloadAttachment',
|
||||
description: '下载邮件附件到本地文件。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
attachment_id: { type: 'string', description: '附件 ID(read_inbox 的清单里给出)' },
|
||||
save_path: { type: 'string', description: '保存路径' },
|
||||
},
|
||||
required: ['attachment_id', 'save_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const buf = await client.downloadFile(params.attachment_id);
|
||||
await writeFile(params.save_path, buf);
|
||||
return text(`已保存到 ${params.save_path}(${formatSize(buf.length)})`);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 寻址发现工具(读 Agent 侧只读端点)───
|
||||
//
|
||||
// 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
|
||||
// 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功,
|
||||
// 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。
|
||||
//
|
||||
// 渲染逻辑在 lib/discovery.js(三平台共用)。
|
||||
|
||||
const suggestAddress = {
|
||||
name: 'suggest_address',
|
||||
label: 'SuggestAddress',
|
||||
description:
|
||||
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' +
|
||||
'工作目录;name+path 都带则给该目录下可续谈的会话与现成地址。' +
|
||||
'**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' },
|
||||
path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const name = String(params.name || '').trim();
|
||||
const path = String(params.path || '').trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (name) qs.set('name', name);
|
||||
if (path) qs.set('path', path);
|
||||
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
|
||||
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
|
||||
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
|
||||
switch (data?.kind) {
|
||||
case 'name': return text(renderNameSuggestions(data.suggestions));
|
||||
case 'path': return text(renderPathSuggestions(data.suggestions, name));
|
||||
default: return text(renderSessionSuggestions(data, name, path));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const listContacts = {
|
||||
name: 'list_contacts',
|
||||
label: 'ListContacts',
|
||||
description:
|
||||
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
|
||||
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'number', description: '最多列出多少条,默认 20' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get('/agent/contacts');
|
||||
return text(renderContacts(data, params.limit || 20));
|
||||
},
|
||||
};
|
||||
|
||||
const sessionParticipants = {
|
||||
name: 'session_participants',
|
||||
label: 'SessionParticipants',
|
||||
description:
|
||||
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' +
|
||||
'并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
session_id: { type: 'string', description: '会话 ID' },
|
||||
},
|
||||
required: ['session_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get(`/agent/sessions/${params.session_id}/participants`);
|
||||
return text(renderParticipants(data));
|
||||
},
|
||||
};
|
||||
|
||||
const readThread = {
|
||||
name: 'read_thread',
|
||||
label: 'ReadThread',
|
||||
description:
|
||||
'查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' +
|
||||
'用它确认别人已经说了什么,避免重复提问或重复汇报。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mail_id: { type: 'string', description: '线索中任一封邮件的 ID' },
|
||||
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' },
|
||||
},
|
||||
required: ['mail_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const qs = params.offset ? `?offset=${params.offset}` : '';
|
||||
const data = await client.get(`/agent/mail/${params.mail_id}/thread${qs}`);
|
||||
return text(renderThread(data, agentName));
|
||||
},
|
||||
};
|
||||
|
||||
const readMail = {
|
||||
name: 'read_mail',
|
||||
label: 'ReadMail',
|
||||
description:
|
||||
'读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' +
|
||||
'收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mail_id: { type: 'string', description: '邮件 ID' },
|
||||
},
|
||||
required: ['mail_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get(`/agent/mail/${params.mail_id}`);
|
||||
const m = data?.mail || {};
|
||||
const lines = [
|
||||
`发件人: ${m.from_name || '?'}`,
|
||||
`收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`,
|
||||
`主题: ${m.subject || '(无主题)'}`,
|
||||
`会话: #${data.session_alias || '未命名'}(session_id: ${m.session_id || '?'})`,
|
||||
];
|
||||
if (Array.isArray(m.cc_list) && m.cc_list.length) {
|
||||
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join('、')}`);
|
||||
}
|
||||
if (Array.isArray(m.attachments) && m.attachments.length) {
|
||||
lines.push(`附件: ${m.attachments
|
||||
.map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
|
||||
.join('、')}`);
|
||||
}
|
||||
lines.push('', m.body || '(空正文)', '');
|
||||
if (Array.isArray(data.participants) && data.participants.length) {
|
||||
lines.push('可投递地址: ' + data.participants
|
||||
.filter(p => p.address && p.name !== agentName)
|
||||
.map(p => `${p.address}(${p.role})`)
|
||||
.join('、'));
|
||||
}
|
||||
if (data.reply_address) {
|
||||
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`);
|
||||
}
|
||||
return text(lines.join('\n'));
|
||||
},
|
||||
};
|
||||
|
||||
// 故意**没有** request_permission(N-1 / T-7):
|
||||
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
|
||||
// 而真正被 pi 拦下的那一次才是事实。
|
||||
return [
|
||||
sendMail, readInbox, readMail, forwardMail,
|
||||
uploadAttachment, downloadAttachment,
|
||||
// 寻址发现:让模型选地址而不是拼地址
|
||||
suggestAddress, listContacts, sessionParticipants, readThread,
|
||||
];
|
||||
}
|
||||
187
plugins/pi-mail-bridge/src/turn.mjs
Normal file
187
plugins/pi-mail-bridge/src/turn.mjs
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* pi 侧的纯逻辑:提示词、轮次结论判定、消息文本提取、回信主题。
|
||||
*
|
||||
* 单独一个文件而不是塞进 index.mjs:这几件事每一件都对应过一次真实的错误行为,
|
||||
* 而它们都不需要 pi SDK —— 因此可以直接用 node --test 钉住,不必起模型。
|
||||
*
|
||||
* 与 lib/ 的区别:lib/ 下的文件三个平台**逐字节相同**(deploy/check-shared-libs.sh
|
||||
* 校验),这里的东西是 pi 专属的(消息形状、stopReason 语义),不参与那个约束。
|
||||
*/
|
||||
|
||||
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */
|
||||
export function stripRe(subject) {
|
||||
return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, '');
|
||||
}
|
||||
|
||||
/** 自动转发时的回信主题。 */
|
||||
export function replySubject(subject) {
|
||||
const base = stripRe(subject).trim();
|
||||
return base ? `Re: ${base}` : '本轮工作总结';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取最后一条 assistant 消息里的纯文本。
|
||||
*
|
||||
* pi 的消息形状:`{ role, content: [{ type: 'text'|'thinking'|'toolCall', ... }] }`。
|
||||
*
|
||||
* **只取 `type === 'text'`**(B-5.1):thinking 块是思考过程,转进邮件对收件人
|
||||
* 没有意义,而且经常包含「我先假设…」这类会被误读为结论的话。
|
||||
*
|
||||
* 从后往前找第一条**有文本**的 assistant 消息,而不是「最后一条 assistant 消息」:
|
||||
* 一轮的收尾常常是纯工具调用消息(content 里只有 toolCall),
|
||||
* 取到它会得到空字符串,于是 B-5.4 判成「无话可说」而漏掉真正的结论。
|
||||
*
|
||||
* @param {any[]} messages `session.messages` 或 `agent_end` 事件里的 messages
|
||||
* @returns {string} 纯文本,找不到时为空串
|
||||
*/
|
||||
export function lastAssistantText(messages) {
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const m = list[i];
|
||||
if (m?.role !== 'assistant') continue;
|
||||
const blocks = Array.isArray(m.content) ? m.content : [];
|
||||
const text = blocks
|
||||
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定这一轮到底跑起来了没有(C-4 / D-3)。
|
||||
*
|
||||
* 「submit 返回了」不等于「模型跑了」—— 这是两次适配都踩过的坑(契约 9.2)。
|
||||
* pi 侧有三条互不重叠的失败信号,必须全查:
|
||||
*
|
||||
* 1. `prompt()` 直接 reject。凭证缺失就是这条:实测无 API key 的 provider
|
||||
* 抛 `No API key found for amazon-bedrock.`,一个事件都不发。
|
||||
* 2. 最后一条 assistant 消息 `stopReason === 'error'`,原因在 `errorMessage`。
|
||||
* 模型请求发出去了但上游报错走这条。
|
||||
* 3. 一条 assistant 消息都没有。既没抛也没报错却什么都没产出,
|
||||
* 当成功处理会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件。
|
||||
*
|
||||
* `stopReason: 'aborted'` **算失败**但要区别对待:那是有人主动打断
|
||||
* (Esc / dispose),不是模型故障,因此不该触发换模型重试。
|
||||
*
|
||||
* @param {{error?: any, messages?: any[]}} input
|
||||
* @returns {{ok: boolean, error: string, aborted: boolean}}
|
||||
*/
|
||||
export function classifyTurnOutcome({ error, messages } = {}) {
|
||||
if (error) {
|
||||
return { ok: false, error: describeError(error), aborted: false };
|
||||
}
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
let lastAssistant = null;
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i]?.role === 'assistant') { lastAssistant = list[i]; break; }
|
||||
}
|
||||
if (!lastAssistant) {
|
||||
return { ok: false, error: '模型没有产出任何回复(一条 assistant 消息都没有)', aborted: false };
|
||||
}
|
||||
const stop = lastAssistant.stopReason;
|
||||
if (stop === 'error') {
|
||||
return {
|
||||
ok: false,
|
||||
error: describeError(lastAssistant.errorMessage) || '模型报错但未给出原因',
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
if (stop === 'aborted') {
|
||||
return { ok: false, error: '本轮被中断(aborted)', aborted: true };
|
||||
}
|
||||
// 'stop' 正常收尾;'length' 是被 max tokens 截断 —— 内容不完整但**是模型的产出**,
|
||||
// 判成失败会让一封「说了一半」的回信变成「换个模型重试」,那更糟。
|
||||
// 'toolUse' 出现在这里说明轮次在等工具,正常流程下 agent_end 时不会是它。
|
||||
return { ok: true, error: '', aborted: false };
|
||||
}
|
||||
|
||||
/** 把各种形态的错误拼成一行可读文本。 */
|
||||
export function describeError(err) {
|
||||
if (!err) return '';
|
||||
if (typeof err === 'string') return err.split('\n')[0].trim();
|
||||
const parts = [err.code, err.message ?? String(err)].filter(Boolean);
|
||||
return parts.join(': ').split('\n')[0].trim() || '未知错误';
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递一封邮件时给模型的提示词。
|
||||
*
|
||||
* 三条硬要求(B-3.4 / B-3.5):
|
||||
* - 写明「回信由插件自动发」。不说的话模型会自己调 send_mail,
|
||||
* 而插件在轮次结束时也会转发一次 —— 同一件事两封邮件(生产里真实发生过)。
|
||||
* - 带上 mail_id,让模型能自己定位这一封。
|
||||
* - 让它先调 read_inbox:事件里只有主题,正文和附件清单都在收件箱里。
|
||||
*
|
||||
* @param {{agentName: string, data: any, kind: string, reused: boolean}} input
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildMailPrompt({ agentName, data, kind, reused }) {
|
||||
if (kind === 'permission') {
|
||||
return [
|
||||
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
|
||||
`(决策人:${data?.decided_by || '用户'})。`,
|
||||
`请据此继续后续工作。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const head = reused
|
||||
? '本会话收到一封新邮件(AgentMail 续谈)。'
|
||||
: '你收到一封新邮件(AgentMail)。';
|
||||
const lines = [
|
||||
head,
|
||||
'',
|
||||
`发件人:${data?.from_name || 'unknown'}`,
|
||||
`主题:${data?.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data?.mail_id || 'unknown'}`,
|
||||
];
|
||||
if (!reused) lines.push(`身份:你是 ${agentName}`);
|
||||
// 服务端算好的回信地址(`new_mail` 的 reply_address)。带上它是因为模型
|
||||
// **确实会**自己发信 —— 尤其是要抄送第三方、或分多封交代不同的事时。
|
||||
// 让它自己拼三维地址的话,`.new` 会被拼进去,于是回信静默开出一条新会话,
|
||||
// 原来的线索里再无下文。
|
||||
if (data?.reply_address) {
|
||||
lines.push(`回信地址:${data.reply_address}(如需自己发信,用这个地址)`);
|
||||
}
|
||||
if (data?.catchup) {
|
||||
// 补投的邮件要说明,否则模型会以为这是刚到的、按「立即响应」的语气回
|
||||
lines.push('说明:这是插件离线期间积压的邮件,现在补投给你。');
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
|
||||
'然后处理其中的请求。',
|
||||
'回信不用你自己发:把这一轮做完、把结论说出来就行,插件会把你最后那段话作为回信发出去。',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动转发的幂等键(W-6 / B-5.2)。
|
||||
*
|
||||
* 用 pi 侧的会话 id + 会话树叶子条目 id:两者都由 pi 生成且落盘,
|
||||
* 插件重启后重放同一轮也会得到同一个键。用「消息条数」之类的派生量不行 ——
|
||||
* 压缩(compaction)会改变条数,于是同一轮结论换了个键,被当成新消息再转一次。
|
||||
*
|
||||
* @param {string} piSessionId
|
||||
* @param {string} leafId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function relayKeyFor(piSessionId, leafId) {
|
||||
return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* pi 会话文件名里的 cwd 编码(`/home/x` → `--home-x--`)。
|
||||
*
|
||||
* 只用于日志与排查提示,不参与任何决策 —— 真正的路径一律用 SDK 给的
|
||||
* `session.sessionFile`。自己拼路径去读会话文件是错的:编码规则属于 pi。
|
||||
*
|
||||
* @param {string} cwd
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sessionDirLabel(cwd) {
|
||||
return `--${String(cwd ?? '').replace(/\//g, '-')}--`;
|
||||
}
|
||||
145
plugins/pi-mail-bridge/test/addressing.test.mjs
Normal file
145
plugins/pi-mail-bridge/test/addressing.test.mjs
Normal file
@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatAddress,
|
||||
roleOf,
|
||||
replyAddressFor,
|
||||
selfAddressFor,
|
||||
participantsOfMail,
|
||||
} from '../lib/addressing.js';
|
||||
|
||||
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
|
||||
// 「拼出来的东西还能不能被正确解析回三段」上。
|
||||
|
||||
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
|
||||
// 生产事故:朴素拼接得到 admin.silent-harbor,没有 @,
|
||||
// 整串被 ParseAddress 当成名字,session 位静默丢失。
|
||||
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('formatAddress: 省略 session 位', () => {
|
||||
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
|
||||
// 名字与 path 都有但都不带会话 → 默认会话语义
|
||||
assert.equal(formatAddress('dsh', '', ''), 'dsh');
|
||||
});
|
||||
|
||||
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
|
||||
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
|
||||
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
|
||||
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
|
||||
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
|
||||
});
|
||||
|
||||
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
|
||||
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
|
||||
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
|
||||
assert.equal(formatAddress('', '/p', 'a'), '');
|
||||
assert.equal(formatAddress(null, '/p', 'a'), '');
|
||||
});
|
||||
|
||||
test('formatAddress: 去掉首尾空白', () => {
|
||||
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
|
||||
});
|
||||
|
||||
const ccMail = {
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
};
|
||||
|
||||
test('roleOf: 区分主收件人与抄送方', () => {
|
||||
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
|
||||
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
|
||||
assert.equal(roleOf(ccMail, 'dsh'), 'to');
|
||||
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
|
||||
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
|
||||
});
|
||||
|
||||
test('roleOf: 名字为空时不猜', () => {
|
||||
assert.equal(roleOf(ccMail, ''), 'unknown');
|
||||
assert.equal(roleOf(ccMail, undefined), 'unknown');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
|
||||
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
|
||||
const addr = replyAddressFor(ccMail);
|
||||
assert.equal(addr, 'admin@.silent-harbor');
|
||||
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 发件人一侧不带 path', () => {
|
||||
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
|
||||
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
|
||||
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
|
||||
assert.equal(replyAddressFor(mail), 'dsh@.x');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 无别名时退回默认会话形式', () => {
|
||||
const mail = { from_name: 'admin', session_alias: '' };
|
||||
const addr = replyAddressFor(mail);
|
||||
assert.equal(addr, 'admin');
|
||||
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
|
||||
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
|
||||
});
|
||||
|
||||
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
|
||||
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path,
|
||||
// 「我是谁」这句话就指向了别人的目录。
|
||||
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
|
||||
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
|
||||
|
||||
assert.equal(byName.opencode.path, '/home');
|
||||
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
|
||||
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
|
||||
// 发件人 path 留空,理由同 replyAddressFor
|
||||
assert.equal(byName.admin.address, 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
|
||||
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名,
|
||||
// 否则「回给抄收方」这个动作每次都会新开会话。
|
||||
for (const p of participantsOfMail(ccMail, 'dsh')) {
|
||||
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('participantsOfMail: 自己被标记而不是被剔除', () => {
|
||||
// 剔掉的话模型无法确认这封信是不是也发给了自己,
|
||||
// 也就无法判断自己该不该回。
|
||||
const parts = participantsOfMail(ccMail, 'opencode');
|
||||
const me = parts.find(p => p.name === 'opencode');
|
||||
assert.ok(me, '自己应出现在参与方列表里');
|
||||
assert.equal(me.is_self, true);
|
||||
assert.equal(parts.filter(p => p.is_self).length, 1);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
|
||||
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 无抄送时只有两方', () => {
|
||||
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 跳过空名字条目', () => {
|
||||
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
|
||||
const mail = {
|
||||
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
|
||||
cc_list: [{ name: '', path: '/x' }, {}],
|
||||
session_alias: 'a',
|
||||
};
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
for (const p of parts) assert.notEqual(p.address, '');
|
||||
});
|
||||
81
plugins/pi-mail-bridge/test/catchup.test.mjs
Normal file
81
plugins/pi-mail-bridge/test/catchup.test.mjs
Normal file
@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js';
|
||||
|
||||
const mail = (over = {}) => ({
|
||||
mail_id: 'm1',
|
||||
session_id: 's1',
|
||||
from_name: 'admin',
|
||||
subject: '主题',
|
||||
mail_type: 'normal',
|
||||
to_workspace: '/tmp/ws',
|
||||
...over,
|
||||
});
|
||||
|
||||
test('mailToEvent 产出与 SSE new_mail 同形的对象', () => {
|
||||
const ev = mailToEvent(mail());
|
||||
// 投递侧读的就是这几个键,形状不一致会让补拉那条路径静默地少带信息
|
||||
for (const k of ['mail_id', 'session_id', 'from_name', 'subject', 'mail_type', 'to_workspace']) {
|
||||
assert.ok(k in ev, `缺少 ${k}`);
|
||||
}
|
||||
assert.equal(ev.role, 'to');
|
||||
assert.equal(ev.catchup, true);
|
||||
});
|
||||
|
||||
test('mailToEvent 对缺字段的行给出空串而非 undefined', () => {
|
||||
const ev = mailToEvent({});
|
||||
assert.equal(ev.mail_id, '');
|
||||
assert.equal(ev.to_workspace, '');
|
||||
assert.equal(ev.mail_type, 'normal');
|
||||
});
|
||||
|
||||
test('已经通过 SSE 投过的不再补投', () => {
|
||||
const mails = [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })];
|
||||
const got = selectCatchup(mails, new Set(['a']));
|
||||
assert.deepEqual(got.map(e => e.mail_id), ['b']);
|
||||
});
|
||||
|
||||
test('按时间正序补投(收件箱是倒序返回的)', () => {
|
||||
// 收件箱:新的在前
|
||||
const mails = [mail({ mail_id: 'new' }), mail({ mail_id: 'mid' }), mail({ mail_id: 'old' })];
|
||||
const got = selectCatchup(mails, new Set());
|
||||
assert.deepEqual(
|
||||
got.map(e => e.mail_id),
|
||||
['old', 'mid', 'new'],
|
||||
'先来的邮件必须先处理,否则同一会话里的上下文顺序是乱的',
|
||||
);
|
||||
});
|
||||
|
||||
test('permission 类邮件不补投', () => {
|
||||
const mails = [mail({ mail_id: 'p', mail_type: 'permission' }), mail({ mail_id: 'n' })];
|
||||
const got = selectCatchup(mails, new Set());
|
||||
assert.deepEqual(got.map(e => e.mail_id), ['n']);
|
||||
});
|
||||
|
||||
test('超过上限的部分留在收件箱里', () => {
|
||||
const mails = Array.from({ length: MAX_CATCHUP + 4 }, (_, i) => mail({ mail_id: 'm' + i }));
|
||||
const got = selectCatchup(mails, new Set());
|
||||
assert.equal(got.length, MAX_CATCHUP, '一次补拉不该把几十封邮件同时放出去');
|
||||
});
|
||||
|
||||
test('上限可显式压到 0(用于禁用补拉)', () => {
|
||||
const got = selectCatchup([mail()], new Set(), 0);
|
||||
assert.deepEqual(got, []);
|
||||
});
|
||||
|
||||
test('空输入与非数组不炸', () => {
|
||||
assert.deepEqual(selectCatchup([], new Set()), []);
|
||||
assert.deepEqual(selectCatchup(undefined, new Set()), []);
|
||||
assert.deepEqual(selectCatchup(null, new Set()), []);
|
||||
});
|
||||
|
||||
test('没有 mail_id 的行跳过', () => {
|
||||
const got = selectCatchup([mail({ mail_id: '' }), mail({ mail_id: 'ok' })], new Set());
|
||||
assert.deepEqual(got.map(e => e.mail_id), ['ok']);
|
||||
});
|
||||
|
||||
test('seen 传 undefined 时不去重也不报错', () => {
|
||||
const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
|
||||
assert.deepEqual(got.map(e => e.mail_id), ['x']);
|
||||
});
|
||||
218
plugins/pi-mail-bridge/test/discovery.test.mjs
Normal file
218
plugins/pi-mail-bridge/test/discovery.test.mjs
Normal file
@ -0,0 +1,218 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
|
||||
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
|
||||
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
|
||||
|
||||
test('renderNameSuggestions 只给名字并指向下一步', () => {
|
||||
// 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」——
|
||||
// 那不一定是调用方想要的那条。
|
||||
const got = renderNameSuggestions(['opencode', 'admin']);
|
||||
assert.match(got, /opencode/);
|
||||
assert.match(got, /admin/);
|
||||
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
|
||||
});
|
||||
|
||||
test('renderNameSuggestions 空列表给明确文案', () => {
|
||||
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
|
||||
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
|
||||
// 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
const got = renderPathSuggestions([], 'admin');
|
||||
assert.match(got, /可以留空/);
|
||||
assert.match(got, /admin/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 列出目录并指向下一步', () => {
|
||||
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
|
||||
assert.match(got, /\/home\/program\/agentmail/);
|
||||
assert.match(got, /最近使用/);
|
||||
assert.match(got, /suggest_address\(name="opencode", path="/);
|
||||
});
|
||||
|
||||
const sessionData = {
|
||||
kind: 'session',
|
||||
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
|
||||
addresses: [
|
||||
'opencode@/home.silent-harbor',
|
||||
'opencode@/home.happy-tiger',
|
||||
'opencode@/home.new',
|
||||
],
|
||||
candidates: [
|
||||
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
|
||||
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
|
||||
{ alias: 'new', title: '新建会话', source: 'new' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
|
||||
// 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions
|
||||
// 同序由服务端保证,直接用。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /opencode@\/home\.happy-tiger/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 带出标题与未读数', () => {
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /联调 llmsproxy/);
|
||||
assert.match(got, /2 封未读/);
|
||||
});
|
||||
|
||||
test('不变量:new 不与已存在会话混列,且带警告', () => {
|
||||
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
const lines = got.split('\n');
|
||||
const newLineIdx = lines.findIndex(l => l.includes('.new'));
|
||||
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
|
||||
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
|
||||
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
|
||||
// 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。
|
||||
// 不传的话服务端会自动命名,但模型不知道那个名字。
|
||||
const got = renderSessionSuggestions(
|
||||
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
|
||||
'dsh', '/tmp',
|
||||
);
|
||||
assert.match(got, /还没有可续谈的会话/);
|
||||
assert.match(got, /session_alias/);
|
||||
});
|
||||
|
||||
const participantData = {
|
||||
session_id: 'f3d824ce',
|
||||
session_alias: 'silent-harbor',
|
||||
participants: [
|
||||
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
|
||||
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
|
||||
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderParticipants 给出每个参与方的地址', () => {
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /admin@\.silent-harbor/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('不变量:标出「尚未回应」的人', () => {
|
||||
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
|
||||
// 正是为了让这个判断成立。
|
||||
const got = renderParticipants(participantData);
|
||||
const line = got.split('\n').find(l => l.includes('opencode'));
|
||||
assert.match(line, /尚未回应/);
|
||||
// 自己不该被标「尚未回应」—— 自己正在处理这封
|
||||
const selfLine = got.split('\n').find(l => l.includes('dsh'));
|
||||
assert.ok(!selfLine.includes('尚未回应'));
|
||||
assert.match(selfLine, /就是你/);
|
||||
});
|
||||
|
||||
test('renderParticipants 用中文角色标签', () => {
|
||||
// 模型读到「抄送方」比读到 cc 更容易判对分工。
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /抄送方/);
|
||||
assert.match(got, /发件人/);
|
||||
});
|
||||
|
||||
test('renderParticipants 无地址时说明原因', () => {
|
||||
const got = renderParticipants({
|
||||
session_alias: '',
|
||||
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
|
||||
});
|
||||
assert.match(got, /尚未命名/);
|
||||
});
|
||||
|
||||
test('renderParticipants 空会话不崩', () => {
|
||||
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
|
||||
assert.match(renderParticipants({}), /还没有参与方/);
|
||||
});
|
||||
|
||||
test('renderContacts 未读优先排序', () => {
|
||||
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
|
||||
const got = renderContacts({
|
||||
contacts: [
|
||||
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
|
||||
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
|
||||
],
|
||||
});
|
||||
const lines = got.split('\n').filter(l => l.startsWith('- '));
|
||||
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
|
||||
assert.match(lines[0], /3 封未读/);
|
||||
});
|
||||
|
||||
test('renderContacts 带出剩余预算', () => {
|
||||
const got = renderContacts({
|
||||
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
|
||||
});
|
||||
assert.match(got, /剩 3\/20 个来回/);
|
||||
});
|
||||
|
||||
test('renderContacts 未命名会话说明只能 reply_to', () => {
|
||||
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
|
||||
assert.match(got, /reply_to/);
|
||||
});
|
||||
|
||||
test('renderContacts 空列表', () => {
|
||||
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
|
||||
});
|
||||
|
||||
const threadData = {
|
||||
anchor_mail_id: 'm-2',
|
||||
total: 3,
|
||||
hidden: 1,
|
||||
nodes: [
|
||||
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
|
||||
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
|
||||
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderThread 用缩进表示层级', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const lines = got.split('\n');
|
||||
const l1 = lines.find(l => l.includes('m-1'));
|
||||
const l2 = lines.find(l => l.includes('m-2'));
|
||||
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
|
||||
});
|
||||
|
||||
test('不变量:detached 必须标出来', () => {
|
||||
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const line = got.split('\n').find(l => l.includes('m-3'));
|
||||
assert.match(line, /父邮件无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 标出自己发的与当前这封', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
|
||||
});
|
||||
|
||||
test('renderThread 报告不可见数量', () => {
|
||||
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
|
||||
// 不说的话模型会以为自己看到了全貌。
|
||||
assert.match(renderThread(threadData), /另有 1 封无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 有更多时给出 offset', () => {
|
||||
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
|
||||
assert.match(got, /offset=60/);
|
||||
});
|
||||
|
||||
test('renderThread 空线索不崩', () => {
|
||||
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
|
||||
assert.match(renderThread({}), /没有可见的邮件/);
|
||||
});
|
||||
266
plugins/pi-mail-bridge/test/inbox-format.test.mjs
Normal file
266
plugins/pi-mail-bridge/test/inbox-format.test.mjs
Normal file
@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 收件箱渲染与已读策略的测试。
|
||||
*
|
||||
* 每条断言都对应一次真实的错误行为(见 lib/inbox-format.js 里的注释):
|
||||
* 漏掉 attachment_id 模型就无从下载附件;漏掉抄送它会以为这是私信;
|
||||
* status=all 时标记已读会让下一轮的新邮件混在历史里认不出来。
|
||||
*
|
||||
* node --test 'test/*.test.mjs'
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatSize,
|
||||
renderMail,
|
||||
renderInbox,
|
||||
idsToMarkRead,
|
||||
DEFAULT_INBOX_STATUS,
|
||||
DEFAULT_INBOX_LIMIT,
|
||||
} from '../lib/inbox-format.js';
|
||||
|
||||
const mail = (over = {}) => ({
|
||||
mail_id: 'm-1',
|
||||
from_name: 'admin',
|
||||
subject: '缓存选型',
|
||||
status: 'unread',
|
||||
session_alias: 'brisk-harbor',
|
||||
body_preview: '我们需要评估一下缓存层',
|
||||
...over,
|
||||
});
|
||||
|
||||
// ─── formatSize ───
|
||||
|
||||
test('formatSize 分档', () => {
|
||||
assert.equal(formatSize(512), '512 B');
|
||||
assert.equal(formatSize(2048), '2.0 KB');
|
||||
assert.equal(formatSize(3 * 1024 * 1024), '3.0 MB');
|
||||
});
|
||||
|
||||
test('formatSize 容错', () => {
|
||||
assert.equal(formatSize(undefined), '?');
|
||||
assert.equal(formatSize(NaN), '?');
|
||||
assert.equal(formatSize('x'), '?');
|
||||
});
|
||||
|
||||
// ─── renderMail ───
|
||||
|
||||
test('renderMail 带出 mail_id 与会话别名', () => {
|
||||
const got = renderMail(mail());
|
||||
assert.match(got, /邮件 ID: m-1/);
|
||||
assert.match(got, /#brisk-harbor/);
|
||||
assert.match(got, /admin: 缓存选型/);
|
||||
});
|
||||
|
||||
test('无别名时显示「未命名」而不是空', () => {
|
||||
const got = renderMail(mail({ session_alias: '' }));
|
||||
assert.match(got, /#未命名/);
|
||||
});
|
||||
|
||||
test('不变量:附件必须带 attachment_id', () => {
|
||||
// 只说「有附件」模型就无从下载 —— download_attachment 要的正是这个 id。
|
||||
const got = renderMail(mail({
|
||||
attachments: [{ filename: 'report.md', size_bytes: 2048, attachment_id: 'att-9' }],
|
||||
}));
|
||||
assert.match(got, /id=att-9/, `附件行缺 id:${got}`);
|
||||
assert.match(got, /report\.md/);
|
||||
assert.match(got, /2\.0 KB/);
|
||||
assert.match(got, /download_attachment/, '要提示模型用哪个工具下载');
|
||||
});
|
||||
|
||||
test('多个附件都列出来', () => {
|
||||
const got = renderMail(mail({
|
||||
attachments: [
|
||||
{ filename: 'a.md', size_bytes: 10, attachment_id: 'att-1' },
|
||||
{ filename: 'b.md', size_bytes: 20, attachment_id: 'att-2' },
|
||||
],
|
||||
}));
|
||||
assert.match(got, /att-1/);
|
||||
assert.match(got, /att-2/);
|
||||
});
|
||||
|
||||
test('不变量:抄送人要显示出来', () => {
|
||||
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
|
||||
const got = renderMail(mail({
|
||||
cc_list: [{ name: 'opencode', raw: 'opencode@/home.new' }],
|
||||
}));
|
||||
assert.match(got, /抄送/);
|
||||
assert.match(got, /opencode@\/home\.new/, '应优先用 raw(带路径与会话段)');
|
||||
});
|
||||
|
||||
test('无抄送时不出现抄送行', () => {
|
||||
assert.ok(!renderMail(mail()).includes('抄送'));
|
||||
assert.ok(!renderMail(mail({ cc_list: [] })).includes('抄送'));
|
||||
});
|
||||
|
||||
test('正文优先取 body_preview,缺失时退回 body', () => {
|
||||
assert.match(renderMail(mail({ body_preview: '预览', body: '全文' })), /内容: 预览/);
|
||||
assert.match(renderMail(mail({ body_preview: '', body: '全文' })), /内容: 全文/);
|
||||
});
|
||||
|
||||
test('正文按 bodyLimit 截断', () => {
|
||||
const got = renderMail(mail({ body_preview: 'x'.repeat(500) }), 50);
|
||||
const line = got.split('\n').find(l => l.startsWith('内容: '));
|
||||
assert.equal(line.length, '内容: '.length + 50);
|
||||
});
|
||||
|
||||
test('renderMail 容错:字段全缺不崩', () => {
|
||||
const got = renderMail({});
|
||||
assert.match(got, /unknown/);
|
||||
const got2 = renderMail(undefined);
|
||||
assert.equal(typeof got2, 'string');
|
||||
});
|
||||
|
||||
test('附件字段不是数组时忽略', () => {
|
||||
const got = renderMail(mail({ attachments: 'oops', cc_list: 'oops' }));
|
||||
assert.ok(!got.includes('附件:'));
|
||||
assert.ok(!got.includes('抄送'));
|
||||
});
|
||||
|
||||
// ─── 收件人与身份(只有知道自己是谁才能判定)───
|
||||
|
||||
test('不变量:收件人要显示出来', () => {
|
||||
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
|
||||
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
|
||||
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
|
||||
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
|
||||
});
|
||||
|
||||
test('收件人无工作目录时只显名字', () => {
|
||||
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
|
||||
assert.match(got, /收件人: admin$/m);
|
||||
});
|
||||
|
||||
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
|
||||
const got = renderMail(mail({ to_name: 'dsh' }));
|
||||
assert.ok(!got.includes('你的身份'));
|
||||
});
|
||||
|
||||
test('不变量:区分收件人与抄送方身份', () => {
|
||||
// 两者职责不同。不区分的话两方都会以为自己是负责人,
|
||||
// 或者都以为自己只是旁观者。
|
||||
const m = mail({
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
|
||||
});
|
||||
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
|
||||
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
|
||||
// 不相关的名字不编造身份
|
||||
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
|
||||
});
|
||||
|
||||
// ─── 可投递地址(「精准发信」的关键)───
|
||||
|
||||
const joint = () => mail({
|
||||
mail_id: 'm-7',
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
});
|
||||
|
||||
test('不变量:给出每个参与方的可投递地址', () => {
|
||||
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`,
|
||||
// 而那个地址回过去只会再建一条平行会话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /可投递地址/);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor(抄送方)/);
|
||||
assert.match(got, /admin@\.silent-harbor(发件人)/);
|
||||
});
|
||||
|
||||
test('不变量:可投递地址里绝不出现 .new', () => {
|
||||
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
|
||||
// 把它当回信地址会让双方各说各话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(line, '应有可投递地址行');
|
||||
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
|
||||
});
|
||||
|
||||
test('可投递地址不列自己', () => {
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
|
||||
});
|
||||
|
||||
test('同时给出 reply_to 这条更稳的路', () => {
|
||||
// 地址可能拼错,reply_to 不会 —— 两条路都告诉模型。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /reply_to=m-7/);
|
||||
});
|
||||
|
||||
test('无会话别名时不给地址(宁可不给不可给错)', () => {
|
||||
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
|
||||
// 实际指向默认会话的地址,比不给危险。
|
||||
const got = renderMail(mail({
|
||||
to_name: 'dsh', session_alias: '',
|
||||
cc_list: [{ name: 'opencode', path: '/home' }],
|
||||
}), 200, 'dsh');
|
||||
assert.ok(!got.includes('可投递地址'));
|
||||
});
|
||||
|
||||
test('renderInbox 透传 selfName', () => {
|
||||
const got = renderInbox([joint()], 200, 'opencode');
|
||||
assert.match(got, /你的身份: 抄送方/);
|
||||
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor(收件人)/);
|
||||
});
|
||||
|
||||
// ─── renderInbox ───
|
||||
|
||||
test('renderInbox 空收件箱给明确文案', () => {
|
||||
assert.equal(renderInbox([]), '收件箱为空。');
|
||||
assert.equal(renderInbox(undefined), '收件箱为空。');
|
||||
assert.equal(renderInbox(null), '收件箱为空。');
|
||||
});
|
||||
|
||||
test('renderInbox 用空行分隔多封', () => {
|
||||
const got = renderInbox([mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
|
||||
assert.match(got, /邮件 ID: a[\s\S]*\n\n[\s\S]*邮件 ID: b/);
|
||||
});
|
||||
|
||||
// ─── idsToMarkRead ───
|
||||
|
||||
test('不变量:只标本次列出的那些', () => {
|
||||
// limit 之外的还没看过,一并标掉等于让它们凭空消失。
|
||||
const ids = idsToMarkRead('unread', [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
|
||||
assert.deepEqual(ids, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('不变量:status=all 时不标记', () => {
|
||||
// 那是「回顾历史」的读法。把历史邮件标成已读会让下一轮真正的新邮件
|
||||
// 混在里面认不出来。
|
||||
assert.deepEqual(idsToMarkRead('all', [mail({ mail_id: 'a' })]), []);
|
||||
});
|
||||
|
||||
test('status 省略时按默认(unread)标记', () => {
|
||||
assert.deepEqual(idsToMarkRead(undefined, [mail({ mail_id: 'a' })]), ['a']);
|
||||
});
|
||||
|
||||
test('idsToMarkRead 过滤掉无 id 的条目', () => {
|
||||
const ids = idsToMarkRead('unread', [
|
||||
mail({ mail_id: 'a' }),
|
||||
mail({ mail_id: '' }),
|
||||
mail({ mail_id: undefined }),
|
||||
{ },
|
||||
]);
|
||||
assert.deepEqual(ids, ['a']);
|
||||
});
|
||||
|
||||
test('idsToMarkRead 容错非数组', () => {
|
||||
assert.deepEqual(idsToMarkRead('unread', undefined), []);
|
||||
assert.deepEqual(idsToMarkRead('unread', 'oops'), []);
|
||||
});
|
||||
|
||||
// ─── 默认值 ───
|
||||
|
||||
test('默认只看未读', () => {
|
||||
// 默认 all 会让模型每轮重读旧邮件,把处理过的和新来的混在一起。
|
||||
assert.equal(DEFAULT_INBOX_STATUS, 'unread');
|
||||
});
|
||||
|
||||
test('默认条数是个小数字', () => {
|
||||
// 收件箱一次给几十封会把上下文塞满,而模型一轮通常只处理一两封。
|
||||
assert.ok(DEFAULT_INBOX_LIMIT > 0 && DEFAULT_INBOX_LIMIT <= 10);
|
||||
});
|
||||
248
plugins/pi-mail-bridge/test/model-scope.test.mjs
Normal file
248
plugins/pi-mail-bridge/test/model-scope.test.mjs
Normal file
@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 模型范围与降级尝试的测试。
|
||||
*
|
||||
* 最要紧的一条:范围为空时必须返回 `[undefined]`(试一次平台默认)而不是 `[]`。
|
||||
* 返回空数组会让调用方一次都不试,等于「管理员没配」就把 Agent 彻底哑掉。
|
||||
*
|
||||
* node --test 'test/*.test.mjs'
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeModels,
|
||||
snapshotDshModels,
|
||||
snapshotPiModels,
|
||||
modelAttemptOrder,
|
||||
renderFailureReport,
|
||||
MAX_CATALOG,
|
||||
} from '../lib/model-scope.js';
|
||||
|
||||
// ─── opencode 目录 ───
|
||||
|
||||
const ocConfig = {
|
||||
providers: [
|
||||
{
|
||||
id: 'llmsproxy',
|
||||
models: {
|
||||
AUTO: { name: 'AUTO (smart routing)' },
|
||||
'claude-sonnet-4-6': { name: 'claude-sonnet-4-6' },
|
||||
},
|
||||
},
|
||||
{ id: 'huawei', models: { 'deepseek-v4-flash': { name: 'dpkv4' } } },
|
||||
],
|
||||
};
|
||||
|
||||
test('opencode 目录拍平 provider × model', () => {
|
||||
const got = snapshotOpencodeModels(ocConfig);
|
||||
assert.equal(got.length, 3);
|
||||
assert.deepEqual(got[0], {
|
||||
provider: 'llmsproxy',
|
||||
model: 'AUTO',
|
||||
display_name: 'AUTO (smart routing)',
|
||||
});
|
||||
});
|
||||
|
||||
test('models 是对象而非数组(键是 model id)', () => {
|
||||
// 实测 opencode 的 /config/providers 返回 { models: { "AUTO": {...} } }。
|
||||
// 当成数组处理会得到零条目而不是报错。
|
||||
const got = snapshotOpencodeModels(ocConfig);
|
||||
assert.ok(got.some(m => m.model === 'claude-sonnet-4-6'));
|
||||
});
|
||||
|
||||
test('无 id 的 provider 被跳过', () => {
|
||||
const got = snapshotOpencodeModels({
|
||||
providers: [{ models: { a: {} } }, { id: 'ok', models: { b: {} } }],
|
||||
});
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].provider, 'ok');
|
||||
});
|
||||
|
||||
test('缺 name 时 display_name 为空串而不是 undefined', () => {
|
||||
const got = snapshotOpencodeModels({ providers: [{ id: 'p', models: { m: {} } }] });
|
||||
assert.equal(got[0].display_name, '');
|
||||
});
|
||||
|
||||
test('opencode 目录容错:结构缺失不崩', () => {
|
||||
assert.deepEqual(snapshotOpencodeModels(undefined), []);
|
||||
assert.deepEqual(snapshotOpencodeModels({}), []);
|
||||
assert.deepEqual(snapshotOpencodeModels({ providers: 'oops' }), []);
|
||||
assert.deepEqual(snapshotOpencodeModels({ providers: [{ id: 'p', models: null }] }), []);
|
||||
});
|
||||
|
||||
// ─── DSH 目录 ───
|
||||
|
||||
test('DSH 目录用 provider + id', () => {
|
||||
const got = snapshotDshModels([
|
||||
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
|
||||
{ provider: 'deepseek', id: 'chat', name: 'DeepSeek Chat' },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.deepEqual(got[1], { provider: 'deepseek', model: 'chat', display_name: 'DeepSeek Chat' });
|
||||
});
|
||||
|
||||
test('DSH 目录跳过缺 provider 或 id 的条目', () => {
|
||||
const got = snapshotDshModels([
|
||||
{ provider: '', id: 'x' },
|
||||
{ provider: 'p', id: '' },
|
||||
{ provider: 'p', id: 'ok' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].model, 'ok');
|
||||
});
|
||||
|
||||
test('重复的 provider/model 组合去重', () => {
|
||||
const got = snapshotDshModels([
|
||||
{ provider: 'p', id: 'm', name: '第一次' },
|
||||
{ provider: 'p', id: 'm', name: '第二次' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].display_name, '第一次');
|
||||
});
|
||||
|
||||
test('目录截断到 MAX_CATALOG', () => {
|
||||
const many = Array.from({ length: MAX_CATALOG + 20 }, (_, i) => ({
|
||||
provider: 'p', id: `m${i}`, name: `M${i}`,
|
||||
}));
|
||||
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── pi 目录 ───
|
||||
|
||||
test('pi 目录用 provider + id', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
|
||||
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.deepEqual(got[1], {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
display_name: 'Claude Sonnet 4.6',
|
||||
});
|
||||
});
|
||||
|
||||
test('pi 目录跳过缺 provider 或 id 的条目', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: '', id: 'x' },
|
||||
{ provider: 'p' },
|
||||
{ provider: 'p', id: 'ok' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].model, 'ok');
|
||||
});
|
||||
|
||||
test('pi 目录容错:非数组不崩', () => {
|
||||
assert.deepEqual(snapshotPiModels(undefined), []);
|
||||
assert.deepEqual(snapshotPiModels(null), []);
|
||||
assert.deepEqual(snapshotPiModels('oops'), []);
|
||||
});
|
||||
|
||||
test('pi 目录同样受 MAX_CATALOG 截断', () => {
|
||||
// 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。
|
||||
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
|
||||
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
|
||||
provider: 'p', id: `m${i}`, name: `M${i}`,
|
||||
}));
|
||||
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── modelAttemptOrder ───
|
||||
|
||||
test('管理员划定范围时按 rank 顺序尝试', () => {
|
||||
const got = modelAttemptOrder(
|
||||
[{ provider: 'a', model: '1' }, { provider: 'b', model: '2' }],
|
||||
{ provider: 'env', model: 'x' }
|
||||
);
|
||||
assert.deepEqual(got, [
|
||||
{ provider: 'a', model: '1' },
|
||||
{ provider: 'b', model: '2' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('不变量:范围为空时返回 [undefined] 而不是 []', () => {
|
||||
// 返回空数组会让调用方一次都不试 —— 「管理员没配」的正确含义是不限定,
|
||||
// 不是「一个都不许用」。后者等于让 Agent 彻底哑掉。
|
||||
const got = modelAttemptOrder([], undefined);
|
||||
assert.equal(got.length, 1, `应有一次尝试,实际 ${got.length}`);
|
||||
assert.equal(got[0], undefined, 'undefined 表示交给平台自己选');
|
||||
});
|
||||
|
||||
test('范围为空但有环境变量时用环境变量', () => {
|
||||
const got = modelAttemptOrder([], { provider: 'llmsproxy', model: 'AUTO' });
|
||||
assert.deepEqual(got, [{ provider: 'llmsproxy', model: 'AUTO' }]);
|
||||
});
|
||||
|
||||
test('不变量:范围优先于环境变量', () => {
|
||||
// 范围是运行时可改的策略,环境变量是部署时的兜底。
|
||||
// 反过来的话管理员在配置页改了范围却不生效,得去改 service 文件重启。
|
||||
const got = modelAttemptOrder(
|
||||
[{ provider: 'chosen', model: 'm' }],
|
||||
{ provider: 'env', model: 'x' }
|
||||
);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].provider, 'chosen');
|
||||
});
|
||||
|
||||
test('过滤掉范围里字段不全的项', () => {
|
||||
const got = modelAttemptOrder(
|
||||
[{ provider: 'a', model: '' }, { provider: '', model: '1' }, { provider: 'ok', model: 'm' }],
|
||||
undefined
|
||||
);
|
||||
assert.deepEqual(got, [{ provider: 'ok', model: 'm' }]);
|
||||
});
|
||||
|
||||
test('环境变量只给一半时不采用', () => {
|
||||
assert.deepEqual(modelAttemptOrder([], { provider: 'p' }), [undefined]);
|
||||
assert.deepEqual(modelAttemptOrder([], { model: 'm' }), [undefined]);
|
||||
});
|
||||
|
||||
test('modelAttemptOrder 容错非数组', () => {
|
||||
assert.deepEqual(modelAttemptOrder(undefined, undefined), [undefined]);
|
||||
assert.deepEqual(modelAttemptOrder('oops', undefined), [undefined]);
|
||||
});
|
||||
|
||||
// ─── renderFailureReport ───
|
||||
|
||||
test('失败报告列出每次尝试的路由与原因', () => {
|
||||
const got = renderFailureReport(
|
||||
[
|
||||
{ provider: 'llmsproxy', model: 'AUTO', error: '429 Too Many Requests' },
|
||||
{ provider: 'huawei', model: 'dpk', error: 'connect ECONNREFUSED' },
|
||||
],
|
||||
'缓存选型'
|
||||
);
|
||||
assert.match(got, /缓存选型/);
|
||||
assert.match(got, /已尝试 2 个/);
|
||||
assert.match(got, /llmsproxy\/AUTO/);
|
||||
assert.match(got, /429 Too Many Requests/);
|
||||
assert.match(got, /huawei\/dpk/);
|
||||
assert.match(got, /ECONNREFUSED/);
|
||||
});
|
||||
|
||||
test('没有路由信息时标为平台默认模型', () => {
|
||||
const got = renderFailureReport([{ error: 'boom' }], '主题');
|
||||
assert.match(got, /平台默认模型/);
|
||||
});
|
||||
|
||||
test('失败报告给出可操作的下一步', () => {
|
||||
// 只报错误不说怎么办,收信的人只能来问。
|
||||
const got = renderFailureReport([{ error: 'x' }], '主题');
|
||||
assert.match(got, /配置页/);
|
||||
});
|
||||
|
||||
test('多行报错缩进后不破坏 Markdown 排版', () => {
|
||||
const got = renderFailureReport([{ error: 'line1\nline2' }], '主题');
|
||||
// 第二行也要带缩进,否则会脱离代码块、其中的字符被当作 Markdown 解析
|
||||
assert.match(got, / line1\n line2/);
|
||||
});
|
||||
|
||||
test('空主题有兜底', () => {
|
||||
assert.match(renderFailureReport([{ error: 'x' }], ''), /\(无主题\)/);
|
||||
assert.match(renderFailureReport([{ error: 'x' }], undefined), /\(无主题\)/);
|
||||
});
|
||||
|
||||
test('renderFailureReport 容错非数组', () => {
|
||||
const got = renderFailureReport(undefined, '主题');
|
||||
assert.match(got, /已尝试 0 个/);
|
||||
});
|
||||
185
plugins/pi-mail-bridge/test/naming.test.mjs
Normal file
185
plugins/pi-mail-bridge/test/naming.test.mjs
Normal file
@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 会话命名一致性的测试。
|
||||
*
|
||||
* 这是 pi 接入里最容易做错的一块,因为「两边各自命名然后指望撞上」看起来能用:
|
||||
* 单条会话、不撞名、没人手工改过名时,两边确实一致。上面任何一条不成立就分叉。
|
||||
*
|
||||
* 因此这里的每个 test 都对应一条**分叉场景**。
|
||||
*
|
||||
* node --test 'test/*.test.mjs'
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planNamingSync, planWriteBack } from '../src/naming.mjs';
|
||||
|
||||
// ─── 向 Gateway 提交(W-7)───
|
||||
|
||||
test('平台名字正常时派生别名并原样带标题', () => {
|
||||
const got = planNamingSync({ platformName: '排查连接泄漏', mailSubject: '别用我', lastSynced: '' });
|
||||
assert.equal(got.skip, false);
|
||||
assert.equal(got.alias, '排查连接泄漏');
|
||||
assert.equal(got.title, '排查连接泄漏');
|
||||
assert.equal(got.source, 'platform');
|
||||
});
|
||||
|
||||
test('不变量:标题原样提交,不清洗不派生', () => {
|
||||
// I-4:插件只搬运。标题那一列不负责寻址,没有字符限制,
|
||||
// 改写它等于让邮箱里显示的和 pi 里显示的是两个东西。
|
||||
const name = 'Fix: cache TTL (v2) — 缓存/过期';
|
||||
const got = planNamingSync({ platformName: name, mailSubject: '', lastSynced: '' });
|
||||
assert.equal(got.title, name);
|
||||
// 别名要按寻址规则剥掉分隔符
|
||||
assert.doesNotMatch(got.alias, /[.@/]/);
|
||||
});
|
||||
|
||||
test('不变量:内容没变就不重复提交', () => {
|
||||
// setSessionName 会触发 session_info_changed,钩子又去 sync,
|
||||
// 不判「与上次相同」就是自激循环 —— 每 30 秒刷一次 Gateway。
|
||||
const first = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: '' });
|
||||
assert.equal(first.skip, false);
|
||||
const again = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: first.signature });
|
||||
assert.equal(again.skip, true);
|
||||
assert.equal(again.reason, 'unchanged');
|
||||
});
|
||||
|
||||
test('不变量:pi 没有名字时退到邮件主题(SDK 路径的常态)', () => {
|
||||
// 桥用 SDK 起的会话不经过 pi-web 的标题生成器,sessionName 一直是 undefined。
|
||||
// 只等平台命名的话别名永远是空的,`name@path.<别名>` 续谈无从下手 ——
|
||||
// 第一次端到端跑通时就是这个结果(sessions.session_alias 是空串)。
|
||||
const got = planNamingSync({ platformName: undefined, mailSubject: '主链路验证', lastSynced: '' });
|
||||
assert.equal(got.skip, false);
|
||||
assert.equal(got.alias, '主链路验证');
|
||||
assert.equal(got.source, 'mail-subject');
|
||||
});
|
||||
|
||||
test('不变量:无名字 + 主题未变时也要判 unchanged', () => {
|
||||
// 用平台名字本身充当「上次提交了什么」的记录时,名字为空就无法区分
|
||||
// 「还没提交过」与「提交过、内容没变」,于是每轮心跳都白打一次 sync。
|
||||
const first = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: '' });
|
||||
const again = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: first.signature });
|
||||
assert.equal(again.skip, true, '空名字场景同样要能判出「没变化」');
|
||||
});
|
||||
|
||||
test('思维链泄漏的名字退到邮件主题派生别名', () => {
|
||||
// pi-web 的标题生成器不防这个(cleanSessionName 只取首行 + 截 60 字符)。
|
||||
// 本机 81 条会话里实测捞到过这条。
|
||||
const got = planNamingSync({
|
||||
platformName: 'The user is asking me to generate a title for a coding-agent',
|
||||
mailSubject: '排查连接泄漏',
|
||||
lastSynced: '',
|
||||
});
|
||||
assert.equal(got.skip, false);
|
||||
assert.equal(got.alias, '排查连接泄漏');
|
||||
assert.equal(got.source, 'mail-subject');
|
||||
});
|
||||
|
||||
test('不变量:退到邮件主题时不写标题', () => {
|
||||
// 标题那一列的语义是「平台生成的会话标题」。把邮件主题填进去会让
|
||||
// 邮箱里看起来像是 pi 生成了这个标题,而 pi 侧其实是另一个名字(或没有)。
|
||||
const got = planNamingSync({
|
||||
platformName: '我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试。简短:测试。或者更简',
|
||||
mailSubject: '压测报告',
|
||||
lastSynced: '',
|
||||
});
|
||||
assert.equal(got.title, '');
|
||||
assert.equal(got.alias, '压测报告');
|
||||
});
|
||||
|
||||
test('不变量:无可用名字时什么都不写(W-7.2)', () => {
|
||||
// 宁可让会话保持无别名(session_alias 允许 NULL),也不要写 "session-123"
|
||||
// 这种占位值 —— 它对人毫无指代作用,而且一旦落库就把 alias 位占住了,
|
||||
// 真正的名字来了只能追 -2 后缀。
|
||||
const got = planNamingSync({ platformName: '', mailSubject: '', lastSynced: '' });
|
||||
assert.equal(got.skip, true);
|
||||
assert.equal(got.reason, 'no-usable-name');
|
||||
});
|
||||
|
||||
test('纯符号名字:写标题但不写别名', () => {
|
||||
// slugFromTitle('...') 是空串,作为别名非法(Gateway 会 400),
|
||||
// 但这个名字本身是平台产出,标题列该照实反映。
|
||||
const got = planNamingSync({ platformName: '...', mailSubject: '', lastSynced: '' });
|
||||
assert.equal(got.skip, false);
|
||||
assert.equal(got.alias, '');
|
||||
assert.equal(got.title, '...');
|
||||
assert.equal(got.source, 'platform-title-only');
|
||||
});
|
||||
|
||||
test('主题也派生不出别名时不写', () => {
|
||||
const got = planNamingSync({ platformName: 'The user is asking me to', mailSubject: '@@@', lastSynced: '' });
|
||||
assert.equal(got.skip, true);
|
||||
});
|
||||
|
||||
// ─── 回写进 pi(D-5 / 一致性的关键)───
|
||||
|
||||
test('不变量:撞名后缀必须回写进 pi', () => {
|
||||
// Gateway 侧别名负有寻址唯一性义务(partial unique index),撞名自动追 -2。
|
||||
// pi 侧没有这个约束。不回写的话:邮箱里是 fix-leak-2、pi-web 里是 fix-leak,
|
||||
// 用户按界面上看到的名字发信会 404。
|
||||
const got = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
|
||||
assert.equal(got.write, true);
|
||||
assert.equal(got.name, 'fix-leak-2');
|
||||
assert.equal(got.reason, 'diverged');
|
||||
});
|
||||
|
||||
test('不变量:manual 别名(人手工改过)优先,回写进 pi', () => {
|
||||
// SyncSessionAlias 遇到 alias_source='manual' 时不覆盖,**原样返回当前别名**。
|
||||
// 于是「人在 AgentMail 界面上定的名字」赢,pi 侧要跟着改 —— 这是有意的:
|
||||
// 人的意图优先于模型生成的标题。
|
||||
const got = planWriteBack({ finalAlias: '紧急排查', currentPiName: 'connection-leak' });
|
||||
assert.equal(got.write, true);
|
||||
assert.equal(got.name, '紧急排查');
|
||||
});
|
||||
|
||||
test('规范化改写过的别名也要回写', () => {
|
||||
// normalizeAlias 把 . / @ 空白换成 -。提议 "a.b c" 会变成 "a-b-c"。
|
||||
const got = planWriteBack({ finalAlias: 'a-b-c', currentPiName: 'a.b c' });
|
||||
assert.equal(got.write, true);
|
||||
assert.equal(got.name, 'a-b-c');
|
||||
});
|
||||
|
||||
test('两边已经一致就不回写', () => {
|
||||
// 回写会 append 一条 session_info 并触发 session_info_changed。
|
||||
// 无条件回写 = 每轮多一条无意义的历史条目 + 一次多余的 sync。
|
||||
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: 'fix-leak' });
|
||||
assert.equal(got.write, false);
|
||||
assert.equal(got.reason, 'already-equal');
|
||||
});
|
||||
|
||||
test('pi 侧还没有名字时也要回写', () => {
|
||||
// 桥用 SDK 起的会话没有名字(pi-web 的生成器不在这条链路上),
|
||||
// 此时 Gateway 定稿的别名就是这条会话的第一个名字。
|
||||
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: undefined });
|
||||
assert.equal(got.write, true);
|
||||
assert.equal(got.reason, 'pi-unnamed');
|
||||
});
|
||||
|
||||
test('不变量:响应没带别名时不回写', () => {
|
||||
// 本次只同步了标题(planNamingSync 的 platform-title-only 分支)→ 没有定稿值。
|
||||
// 拿空串去 setSessionName 是**清除**语义(实测 appendSessionInfo(" ")
|
||||
// 之后 getSessionName() 变 undefined),会把 pi 侧原有的名字抹掉。
|
||||
assert.equal(planWriteBack({ finalAlias: '', currentPiName: 'keep-me' }).write, false);
|
||||
assert.equal(planWriteBack({ finalAlias: undefined, currentPiName: 'keep-me' }).write, false);
|
||||
assert.equal(planWriteBack({ finalAlias: ' ', currentPiName: 'keep-me' }).write, false);
|
||||
});
|
||||
|
||||
// ─── 端到端的一致性推理 ───
|
||||
|
||||
test('完整链路:提议 → 撞名定稿 → 回写 → 再观测不再动', () => {
|
||||
// 这个 test 钉住「不会自激循环」这条性质,它是分四步的:
|
||||
// 1. pi 有了名字 fix-leak,提交
|
||||
// 2. Gateway 撞名,定稿 fix-leak-2
|
||||
// 3. 回写进 pi,pi 的名字变成 fix-leak-2
|
||||
// 4. session_info_changed 再次触发 → 必须 skip,否则无限循环
|
||||
const step1 = planNamingSync({ platformName: 'fix-leak', mailSubject: '', lastSynced: '' });
|
||||
assert.equal(step1.alias, 'fix-leak');
|
||||
|
||||
const step3 = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
|
||||
assert.equal(step3.write, true);
|
||||
|
||||
// 桥在回写后把指纹更新成「定稿别名当作平台名字」会算出的那个值,
|
||||
// 因此第 4 步(回写触发的事件)看到的指纹与它相同。
|
||||
const afterWriteBack = `platform:${step3.name}|${step3.name}`;
|
||||
const step4 = planNamingSync({ platformName: 'fix-leak-2', mailSubject: '', lastSynced: afterWriteBack });
|
||||
assert.equal(step4.skip, true, '回写触发的事件必须被指纹挡住,否则无限循环');
|
||||
});
|
||||
326
plugins/pi-mail-bridge/test/session-snapshot.test.mjs
Normal file
326
plugins/pi-mail-bridge/test/session-snapshot.test.mjs
Normal file
@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 平台会话快照的纯函数测试。
|
||||
*
|
||||
* 这些函数的产出直接决定「写信时能不能选到某条会话」:slug 错了就填出一个
|
||||
* 送不到的 session 位(三态语义下会 404),workspace 错了就归到别的工作区去。
|
||||
*
|
||||
* node --test test/
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeSessions,
|
||||
snapshotDshSessions,
|
||||
snapshotPiSessions,
|
||||
isUnusableName,
|
||||
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);
|
||||
});
|
||||
|
||||
// ─── pi:名字来自会话文件的 session_info ───
|
||||
|
||||
const piSession = (over = {}) => ({
|
||||
id: '01a064cc-df57-7b2d-bebb-736776105485',
|
||||
cwd: '/home/program/agentmail',
|
||||
name: '重构导入路径',
|
||||
messageCount: 6,
|
||||
created: new Date(1788300000000),
|
||||
modified: new Date(1788344476744),
|
||||
...over,
|
||||
});
|
||||
|
||||
test('pi 快照取 cwd 与 session_info 名字', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.workspace, '/home/program/agentmail');
|
||||
assert.equal(got.title, '重构导入路径');
|
||||
assert.equal(got.slug, '重构导入路径');
|
||||
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
|
||||
});
|
||||
|
||||
test('不变量:pi 无名会话不上报', () => {
|
||||
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
|
||||
// (「你收到一封新邮件(AgentMail)…」)—— 拿它当别名毫无区分度,且条条撞名。
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'named', name: '有名字' }),
|
||||
piSession({ id: 'anon', name: undefined }),
|
||||
piSession({ id: 'blank', name: '' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['named']);
|
||||
});
|
||||
|
||||
test('不变量:pi 老会话的空 cwd 照实上报', () => {
|
||||
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
|
||||
// 那条会话在补全里挂到一个它其实不属于的工作区下。
|
||||
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
|
||||
assert.equal(got.workspace, '');
|
||||
});
|
||||
|
||||
test('不变量:pi 的 updated_at 取 modified(文件 mtime)', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
|
||||
});
|
||||
|
||||
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
|
||||
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'new');
|
||||
});
|
||||
|
||||
test('pi 快照标记邮件驱动的会话', () => {
|
||||
const got = snapshotPiSessions(
|
||||
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
|
||||
(id) => id === 'mail-one'
|
||||
);
|
||||
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
|
||||
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
|
||||
});
|
||||
|
||||
// ─── isUnusableName:pi-web 标题生成器的思维链泄漏 ───
|
||||
|
||||
test('不变量:思维链泄漏的标题判废', () => {
|
||||
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
|
||||
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
|
||||
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
|
||||
assert.equal(
|
||||
isUnusableName('我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:Opencode源测试。或者更简'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isUnusableName 放过正常标题', () => {
|
||||
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
|
||||
assert.equal(isUnusableName('查看Agent接入群聊'), false);
|
||||
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
|
||||
assert.equal(isUnusableName('homeagent-gateway'), false);
|
||||
assert.equal(isUnusableName('重构导入路径'), false);
|
||||
assert.equal(isUnusableName('Fix flaky auth test'), false);
|
||||
});
|
||||
|
||||
test('isUnusableName 判废空名字', () => {
|
||||
assert.equal(isUnusableName(''), true);
|
||||
assert.equal(isUnusableName(' '), true);
|
||||
assert.equal(isUnusableName(undefined), true);
|
||||
});
|
||||
|
||||
test('判废的名字不进快照', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
|
||||
piSession({ id: 'clean', name: '正常标题' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
|
||||
});
|
||||
254
plugins/pi-mail-bridge/test/turn.test.mjs
Normal file
254
plugins/pi-mail-bridge/test/turn.test.mjs
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* pi 专属纯逻辑的测试:轮次结论判定、消息文本提取、提示词。
|
||||
*
|
||||
* 这些不在 lib/ 下(那里的六个文件三平台逐字节相同),因为它们依赖 pi 的
|
||||
* 消息形状与 stopReason 语义。但同样是纯函数,因此可以不起模型就钉住。
|
||||
*
|
||||
* node --test 'test/*.test.mjs'
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
stripRe,
|
||||
replySubject,
|
||||
lastAssistantText,
|
||||
classifyTurnOutcome,
|
||||
describeError,
|
||||
buildMailPrompt,
|
||||
relayKeyFor,
|
||||
} from '../src/turn.mjs';
|
||||
|
||||
// ─── 主题 ───
|
||||
|
||||
test('stripRe 去掉叠加的 Re: 前缀', () => {
|
||||
assert.equal(stripRe('Re: Re: Re: 缓存选型'), '缓存选型');
|
||||
assert.equal(stripRe('缓存选型'), '缓存选型');
|
||||
assert.equal(stripRe('RE: RE: x'), 'x');
|
||||
});
|
||||
|
||||
test('replySubject 只加一层 Re:', () => {
|
||||
assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型');
|
||||
assert.equal(replySubject('缓存选型'), 'Re: 缓存选型');
|
||||
});
|
||||
|
||||
test('无主题时回信有兜底主题', () => {
|
||||
// 空主题会被 Gateway 拒(400 Missing subject),不兜底就发不出去
|
||||
assert.equal(replySubject(''), '本轮工作总结');
|
||||
assert.equal(replySubject(undefined), '本轮工作总结');
|
||||
});
|
||||
|
||||
// ─── 消息文本提取 ───
|
||||
|
||||
const asst = (blocks, over = {}) => ({ role: 'assistant', content: blocks, stopReason: 'stop', ...over });
|
||||
|
||||
test('只取 text 块,丢掉 thinking', () => {
|
||||
// 思考过程不该出现在邮件里(B-5.1 / N-6):它对收件人没有意义,
|
||||
// 而且经常包含「我先假设…」这类会被误读为结论的话。
|
||||
const got = lastAssistantText([
|
||||
asst([
|
||||
{ type: 'thinking', thinking: '先看看有没有缓存层' },
|
||||
{ type: 'text', text: '已定位到问题:连接池没有复用。' },
|
||||
]),
|
||||
]);
|
||||
assert.equal(got, '已定位到问题:连接池没有复用。');
|
||||
});
|
||||
|
||||
test('不变量:跳过纯工具调用的收尾消息,往前找有文本的那条', () => {
|
||||
// 一轮的最后一条 assistant 消息常常只有 toolCall。取到它会得到空串,
|
||||
// 于是 B-5.4 判成「无话可说」而漏掉真正的结论 —— 发件人再无音讯。
|
||||
const got = lastAssistantText([
|
||||
asst([{ type: 'text', text: '结论在这里。' }]),
|
||||
asst([{ type: 'toolCall', toolName: 'bash', input: {} }]),
|
||||
]);
|
||||
assert.equal(got, '结论在这里。');
|
||||
});
|
||||
|
||||
test('多个 text 块按顺序拼接', () => {
|
||||
const got = lastAssistantText([asst([
|
||||
{ type: 'text', text: '第一段' },
|
||||
{ type: 'text', text: '第二段' },
|
||||
])]);
|
||||
assert.equal(got, '第一段\n第二段');
|
||||
});
|
||||
|
||||
test('忽略 user 消息里的文本', () => {
|
||||
const got = lastAssistantText([
|
||||
asst([{ type: 'text', text: 'assistant 说的' }]),
|
||||
{ role: 'user', content: [{ type: 'text', text: 'user 说的' }] },
|
||||
]);
|
||||
assert.equal(got, 'assistant 说的');
|
||||
});
|
||||
|
||||
test('没有 assistant 消息时返回空串', () => {
|
||||
assert.equal(lastAssistantText([{ role: 'user', content: [{ type: 'text', text: 'x' }] }]), '');
|
||||
assert.equal(lastAssistantText([]), '');
|
||||
assert.equal(lastAssistantText(undefined), '');
|
||||
});
|
||||
|
||||
// ─── 轮次结论(D-3,两次适配都踩过)───
|
||||
|
||||
test('不变量:prompt 抛错判为失败', () => {
|
||||
// 实测:无凭证的 provider 让 prompt() reject(No API key found for
|
||||
// amazon-bedrock.),**一个事件都不发**。只看事件的话这轮会被当成没跑完。
|
||||
const got = classifyTurnOutcome({ error: new Error('No API key found for amazon-bedrock.') });
|
||||
assert.equal(got.ok, false);
|
||||
assert.match(got.error, /No API key/);
|
||||
});
|
||||
|
||||
test('不变量:一条 assistant 消息都没有判为失败', () => {
|
||||
// 判成功会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件,
|
||||
// 而不是错误说明。这是契约里 C-4「必须能区分成功与出错」的核心。
|
||||
const got = classifyTurnOutcome({ messages: [{ role: 'user', content: [] }] });
|
||||
assert.equal(got.ok, false);
|
||||
assert.match(got.error, /没有产出/);
|
||||
});
|
||||
|
||||
test('不变量:stopReason=error 判为失败并带出 errorMessage', () => {
|
||||
const got = classifyTurnOutcome({
|
||||
messages: [asst([{ type: 'text', text: '半句' }], {
|
||||
stopReason: 'error',
|
||||
errorMessage: 'upstream 503 rate limited',
|
||||
})],
|
||||
});
|
||||
assert.equal(got.ok, false);
|
||||
assert.equal(got.error, 'upstream 503 rate limited');
|
||||
});
|
||||
|
||||
test('stopReason=error 但没给原因也要有话可说', () => {
|
||||
const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'error' })] });
|
||||
assert.equal(got.ok, false);
|
||||
assert.ok(got.error, '失败原因不能是空串:renderFailureReport 会把它填进邮件');
|
||||
});
|
||||
|
||||
test('正常收尾判为成功', () => {
|
||||
const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '好了' }])] });
|
||||
assert.deepEqual(got, { ok: true, error: '', aborted: false });
|
||||
});
|
||||
|
||||
test('不变量:length(被 max tokens 截断)判为成功', () => {
|
||||
// 内容不完整,但**是模型的产出**。判失败会让一封「说了一半」的回信
|
||||
// 变成「换个模型重试」,那更糟 —— 用户什么都收不到。
|
||||
const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '说了一半' }], { stopReason: 'length' })] });
|
||||
assert.equal(got.ok, true);
|
||||
});
|
||||
|
||||
test('aborted 判为失败但标记 aborted', () => {
|
||||
// 有人主动打断(Esc / dispose),不是模型故障 —— 不该触发换模型重试
|
||||
const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'aborted' })] });
|
||||
assert.equal(got.ok, false);
|
||||
assert.equal(got.aborted, true);
|
||||
});
|
||||
|
||||
test('取最后一条 assistant 消息判定,不是第一条', () => {
|
||||
const got = classifyTurnOutcome({
|
||||
messages: [
|
||||
asst([{ type: 'text', text: '第一轮好的' }], { stopReason: 'stop' }),
|
||||
asst([], { stopReason: 'error', errorMessage: '第二轮炸了' }),
|
||||
],
|
||||
});
|
||||
assert.equal(got.ok, false);
|
||||
assert.equal(got.error, '第二轮炸了');
|
||||
});
|
||||
|
||||
// ─── describeError ───
|
||||
|
||||
test('describeError 只取首行', () => {
|
||||
// 报错原文会被填进故障邮件的正文,多行堆栈会把那封信淹掉
|
||||
assert.equal(describeError(new Error('炸了\n at foo (bar.js:1)')), '炸了');
|
||||
assert.equal(describeError('单行错误'), '单行错误');
|
||||
});
|
||||
|
||||
test('describeError 带上 code', () => {
|
||||
const e = new Error('connect failed');
|
||||
e.code = 'ECONNREFUSED';
|
||||
assert.equal(describeError(e), 'ECONNREFUSED: connect failed');
|
||||
});
|
||||
|
||||
test('describeError 容错', () => {
|
||||
assert.equal(describeError(null), '');
|
||||
assert.equal(describeError(undefined), '');
|
||||
});
|
||||
|
||||
// ─── 提示词(B-3.4 / B-3.5)───
|
||||
|
||||
const mailData = {
|
||||
mail_id: 'm-1',
|
||||
from_name: 'admin',
|
||||
subject: '排查连接泄漏',
|
||||
to_workspace: '/home/program/agentmail',
|
||||
};
|
||||
|
||||
test('不变量:提示词写明回信由桥自动发', () => {
|
||||
// 不说的话模型会自己调 send_mail,而桥在轮次结束时也会转发一次 ——
|
||||
// 同一件事两封邮件(生产里真实发生过)。
|
||||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||||
assert.match(p, /回信不用你自己发/);
|
||||
});
|
||||
|
||||
test('不变量:提示词带 mail_id 与 read_inbox 指引', () => {
|
||||
// 事件里只有主题,正文和附件清单都在收件箱里;不给 mail_id 模型无法定位这一封
|
||||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||||
assert.match(p, /m-1/);
|
||||
assert.match(p, /read_inbox/);
|
||||
});
|
||||
|
||||
test('首封带身份,续谈不重复带', () => {
|
||||
const first = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||||
const again = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: true });
|
||||
assert.match(first, /你是 pi/);
|
||||
assert.doesNotMatch(again, /你是 pi/);
|
||||
assert.match(again, /续谈/);
|
||||
});
|
||||
|
||||
test('补投的邮件在提示词里说明来源', () => {
|
||||
// 不说明的话模型会以为这是刚到的、按「立即响应」的语气回
|
||||
const p = buildMailPrompt({
|
||||
agentName: 'pi',
|
||||
data: { ...mailData, catchup: true },
|
||||
kind: 'mail',
|
||||
reused: false,
|
||||
});
|
||||
assert.match(p, /积压/);
|
||||
});
|
||||
|
||||
test('不变量:带上服务端算好的 reply_address', () => {
|
||||
// 模型确实会自己发信(要抄送第三方、或分多封交代不同的事)。
|
||||
// 让它自己拼三维地址的话,`.new` 会被拼进去 —— 回信静默开出一条新会话,
|
||||
// 原来的线索里再无下文。服务端在 new_mail 里已经算好了这个地址。
|
||||
const p = buildMailPrompt({
|
||||
agentName: 'pi',
|
||||
data: { ...mailData, reply_address: 'admin@.排查连接泄漏' },
|
||||
kind: 'mail',
|
||||
reused: false,
|
||||
});
|
||||
assert.match(p, /admin@\.排查连接泄漏/);
|
||||
});
|
||||
|
||||
test('没有 reply_address 时不留空行占位', () => {
|
||||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||||
assert.doesNotMatch(p, /回信地址/);
|
||||
});
|
||||
|
||||
test('权限决策的提示词带决策与决策人', () => {
|
||||
const p = buildMailPrompt({
|
||||
agentName: 'pi',
|
||||
data: { decision: '同意', decided_by: 'zhang' },
|
||||
kind: 'permission',
|
||||
reused: true,
|
||||
});
|
||||
assert.match(p, /同意/);
|
||||
assert.match(p, /zhang/);
|
||||
});
|
||||
|
||||
// ─── 幂等键 ───
|
||||
|
||||
test('relayKey 由会话 id 与叶子 id 组成', () => {
|
||||
assert.equal(relayKeyFor('sess-1', 'leaf-9'), 'sess-1:leaf-9');
|
||||
});
|
||||
|
||||
test('不变量:叶子 id 缺失时仍产出稳定键', () => {
|
||||
// 返回空串会让服务端把 relay_key 当作「没给」,于是幂等失效、同一轮转两次
|
||||
assert.equal(relayKeyFor('sess-1', null), 'sess-1:noleaf');
|
||||
assert.equal(relayKeyFor('sess-1', undefined), 'sess-1:noleaf');
|
||||
});
|
||||
128
plugins/pi-mail-bridge/test/workspace.test.mjs
Normal file
128
plugins/pi-mail-bridge/test/workspace.test.mjs
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 工作目录解析的回归测试。
|
||||
*
|
||||
* 这是「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, mailSessionFallback } from '../lib/workspace.js';
|
||||
|
||||
// 兜底目录现在由调用方给(各平台不同)。DSH 用 mailSessionFallback,
|
||||
// opencode 用插件启动时的 directory。
|
||||
const fallbackOf = key => mailSessionFallback(key);
|
||||
|
||||
test('存在的绝对路径直接用作 cwd', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
try {
|
||||
const got = resolveWorkspaceCwd(dir, fallbackOf('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, fallbackOf('mail-aaa'));
|
||||
const b = resolveWorkspaceCwd(dir, fallbackOf('mail-bbb'));
|
||||
assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('path 为空时回退到兜底目录', () => {
|
||||
const got = resolveWorkspaceCwd('', fallbackOf('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, fallbackOf('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', fallbackOf('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, fallbackOf('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, fallbackOf('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} `, fallbackOf('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, '');
|
||||
assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录');
|
||||
|
||||
// grouped=true 时不该创建(那种目录本来就存在)
|
||||
const never = join(base, 'should-not-exist');
|
||||
ensureCwd(never, true);
|
||||
assert.equal(resolveWorkspaceCwd(never, '').grouped, false);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('兜底为空串时返回空 cwd(交给平台自己决定)', () => {
|
||||
// opencode 没配 directory 时就是这种情况:session.create 不带 query.directory,
|
||||
// 由平台按自己的默认规则选目录。比硬塞一个我们猜的路径好。
|
||||
const got = resolveWorkspaceCwd('', '');
|
||||
assert.equal(got.cwd, '');
|
||||
assert.equal(got.grouped, false);
|
||||
});
|
||||
|
||||
test('mailSessionFallback 同一 key 稳定、不同 key 不同', () => {
|
||||
assert.equal(mailSessionFallback('a'), mailSessionFallback('a'));
|
||||
assert.notEqual(mailSessionFallback('a'), mailSessionFallback('b'));
|
||||
assert.match(mailSessionFallback('a'), /mail-sessions/);
|
||||
});
|
||||
Reference in New Issue
Block a user