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:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View 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[];

View 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;
}

View 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;

View 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');
}

View File

@ -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[];

View File

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

View File

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

View File

@ -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);
}
/**
* 决定这一轮按什么顺序尝试模型。
*

View File

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

View File

@ -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 把子会话写在自定义 sessionDirrun 根目录)下,默认会话目录
* 列不到它们,因此这里不需要 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-1pi 的列表在无名时显示首条消息,
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
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();

View File

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

View 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, '');
});

View 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({}), /没有可见的邮件/);
});

View File

@ -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 空收件箱给明确文案', () => {

View File

@ -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 顺序尝试', () => {

View File

@ -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);
});
// ─── isUnusableNamepi-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']);
});

View File

@ -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("会话 IDread_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,
},
};
}

View 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;
}

View 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');
}

View File

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

View File

@ -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);
}
/**
* 决定这一轮按什么顺序尝试模型。
*

View File

@ -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 把子会话写在自定义 sessionDirrun 根目录)下,默认会话目录
* 列不到它们,因此这里不需要 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-1pi 的列表在无名时显示首条消息,
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
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();

View 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, '');
});

View 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({}), /没有可见的邮件/);
});

View File

@ -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 空收件箱给明确文案', () => {

View File

@ -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 顺序尝试', () => {

View File

@ -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);
});
// ─── isUnusableNamepi-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']);
});

View 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;
}

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

View 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');
}

View 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;

View 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;
}

View 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;
}

View File

@ -0,0 +1,234 @@
/**
* 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。
*
* 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。
* Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话
* 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。
*
* 为什么是插件上报而不是 Gateway 拉取当前架构是单向的Agent 持密钥主动连
* GatewayGateway 从不外呼)。反向拉取需要 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 把子会话写在自定义 sessionDirrun 根目录)下,默认会话目录
* 列不到它们,因此这里不需要 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-1pi 的列表在无名时显示首条消息,
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
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);
}

View 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 自己报错,这里不该吞掉真实原因
}
}

View 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'"
}
}

View 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 长连并自动重连。
*
* 手写解析而不用 EventSourceNode 内建的那个不支持自定义请求头,
* 而认证头是必须的。协议这一小块(`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;
}
}

View 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_idSSE 与补拉共用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 自己的本地 UIB-8.2)。
// 占着钩子不放会让人在 TUI 里干活时每一步都卡住等邮件。
if (!mailSessionId) return;
// relay_key 用 pi 给的 toolCallIdB-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 本地 UIB-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 closedB-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-8404 后自动改用 .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-6thinking 是思考过程,不是结论
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)}`);
}
}
// 发完仍要 throwB-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);
});

View 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/-3pi 侧没有这个约束。
* 而 `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' };
}

View 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 而不是 steersteer 会把当前这一轮打断,
// 而当前这一轮正在处理**上一封邮件** —— 那封邮件的发件人也在等回信。
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 里塞消息,
* 队列**只在运行中的轮次末尾**被 drainpi-agent-core/agent.js 的 run 循环,
* 以及 `continue()`)。会话空闲时(上一轮早已结束)塞进去的消息永远没人取,
* 于是这封邮件既没有回信也没有报错 —— 实测踩过:日志打了「续谈」,
* 收件箱里只有来信没有回复。
*
* `runTurn` 按 `isStreaming` 分流,两种状态都正确:
* - 空闲 → `prompt()` 直接起一轮
* - 正在跑 → `prompt(text, {streamingBehavior:'followUp'})` 排到当轮之后
*/
export { runTurn as followUpTurn };

View 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_idPOST /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: '附件 IDread_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_permissionN-1 / T-7
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
// 而真正被 pi 拦下的那一次才是事实。
return [
sendMail, readInbox, readMail, forwardMail,
uploadAttachment, downloadAttachment,
// 寻址发现:让模型选地址而不是拼地址
suggestAddress, listContacts, sessionParticipants, readThread,
];
}

View 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.1thinking 块是思考过程,转进邮件对收件人
* 没有意义,而且经常包含「我先假设…」这类会被误读为结论的话。
*
* 从后往前找第一条**有文本**的 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, '-')}--`;
}

View 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, '');
});

View 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']);
});

View 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({}), /没有可见的邮件/);
});

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

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

View 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);
});
// ─── 回写进 piD-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. 回写进 pipi 的名字变成 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, '回写触发的事件必须被指纹挡住,否则无限循环');
});

View File

@ -0,0 +1,326 @@
/**
* 平台会话快照的纯函数测试。
*
* 这些函数的产出直接决定「写信时能不能选到某条会话」slug 错了就填出一个
* 送不到的 session 位(三态语义下会 404workspace 错了就归到别的工作区去。
*
* 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');
});
// ─── DSHsubagent 过滤与 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);
});
// ─── isUnusableNamepi-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']);
});

View 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() rejectNo 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');
});

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