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:
@ -20,6 +20,14 @@ import {
|
||||
DEFAULT_INBOX_STATUS,
|
||||
DEFAULT_INBOX_LIMIT,
|
||||
} from "./lib/inbox-format.js";
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from "./lib/discovery.js";
|
||||
import {
|
||||
explicitSends,
|
||||
noteExplicitSend,
|
||||
@ -242,7 +250,11 @@ const readInboxTool = {
|
||||
|
||||
// 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关,
|
||||
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
|
||||
const listed = renderInbox(data.mails);
|
||||
//
|
||||
// 传 AGENT_NAME 是为了让渲染能判定「我是收件人还是抄送方」并给出
|
||||
// 可投递地址 —— 不传的话模型只能从抄送行里抄一个 `.new`,而那是
|
||||
// 一次性的,回过去只会再建一条平行会话。
|
||||
const listed = renderInbox(data.mails, 200, AGENT_NAME);
|
||||
|
||||
const ids = idsToMarkRead(args.filter, data.mails);
|
||||
if (ids.length) {
|
||||
@ -317,6 +329,130 @@ const downloadAttachmentTool = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 寻址发现工具(读 Agent 侧只读端点)───
|
||||
//
|
||||
// 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段。人类侧
|
||||
// 从来不是这样:三段式输入框逐段查候选。Agent 只能猜,而猜错不报错 ——
|
||||
// 生产上 dsh 猜了 `opencode@/home`,投递成功,但那不是 opencode 的工作目录,
|
||||
// 那个错误路径静默变成了新会话的 workspace。
|
||||
//
|
||||
// 渲染逻辑在 lib/discovery.js(与平台 SDK 无关,三平台共用)。
|
||||
|
||||
const suggestAddressTool = {
|
||||
description:
|
||||
"查询可用的收件人地址,用于精准发信。分三段逐步查:不带参数给候选收件人名;" +
|
||||
"带 name 给它可用的工作目录;name+path 都带则给该目录下可续谈的会话别名与现成地址。" +
|
||||
"**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。",
|
||||
args: {
|
||||
name: z.string().optional().describe("收件人名;留空则列出所有候选收件人"),
|
||||
path: z.string().optional().describe("工作目录;与 name 同时给出才列会话"),
|
||||
},
|
||||
async execute(args) {
|
||||
const name = (args.name || "").trim();
|
||||
const path = (args.path || "").trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (name) qs.set("name", name);
|
||||
if (path) qs.set("path", path);
|
||||
const data = await apiGet(`/agent/contacts/suggest?${qs.toString()}`);
|
||||
|
||||
// 按服务端回的 kind 分派,而不是按本地参数判断:省略 path 与传空串在
|
||||
// 服务端是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
|
||||
switch (data?.kind) {
|
||||
case "name":
|
||||
return renderNameSuggestions(data.suggestions);
|
||||
case "path":
|
||||
return renderPathSuggestions(data.suggestions, name);
|
||||
default:
|
||||
return renderSessionSuggestions(data, name, path);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const listContactsTool = {
|
||||
description:
|
||||
"列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。" +
|
||||
"用于回答「我还有什么没处理」以及「上次跟某人聊的那条线索地址是什么」。",
|
||||
args: {
|
||||
limit: z.number().optional().describe("最多列出多少条,默认 20"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet("/agent/contacts");
|
||||
return renderContacts(data, args.limit || 20);
|
||||
},
|
||||
};
|
||||
|
||||
const sessionParticipantsTool = {
|
||||
description:
|
||||
"列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址," +
|
||||
"并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
|
||||
args: {
|
||||
session_id: z.string().describe("会话 ID(read_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet(`/agent/sessions/${args.session_id}/participants`);
|
||||
return renderParticipants(data);
|
||||
},
|
||||
};
|
||||
|
||||
const readThreadTool = {
|
||||
description:
|
||||
"查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时" +
|
||||
"用它确认别人已经说了什么,避免重复提问或重复汇报。",
|
||||
args: {
|
||||
mail_id: z.string().describe("线索中任一封邮件的 ID"),
|
||||
offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"),
|
||||
},
|
||||
async execute(args) {
|
||||
const qs = args.offset ? `?offset=${args.offset}` : "";
|
||||
const data = await apiGet(`/agent/mail/${args.mail_id}/thread${qs}`);
|
||||
return renderThread(data, AGENT_NAME);
|
||||
},
|
||||
};
|
||||
|
||||
const readMailTool = {
|
||||
description:
|
||||
"读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。" +
|
||||
"收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。",
|
||||
args: {
|
||||
mail_id: z.string().describe("邮件 ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet(`/agent/mail/${args.mail_id}`);
|
||||
const m = data?.mail || {};
|
||||
const lines = [
|
||||
`发件人: ${m.from_name || "?"}`,
|
||||
`收件人: ${m.to_name || "?"}${m.to_workspace ? "@" + m.to_workspace : ""}`,
|
||||
`主题: ${m.subject || "(无主题)"}`,
|
||||
`会话: #${data.session_alias || "未命名"}(session_id: ${m.session_id || "?"})`,
|
||||
];
|
||||
if (Array.isArray(m.cc_list) && m.cc_list.length) {
|
||||
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join("、")}`);
|
||||
}
|
||||
if (Array.isArray(m.attachments) && m.attachments.length) {
|
||||
lines.push(
|
||||
`附件: ${m.attachments
|
||||
.map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
|
||||
.join("、")}`
|
||||
);
|
||||
}
|
||||
lines.push("", m.body || "(空正文)", "");
|
||||
// 参与方地址由服务端拼好(session 位已是真实别名,不是 .new)
|
||||
if (Array.isArray(data.participants) && data.participants.length) {
|
||||
lines.push(
|
||||
"可投递地址: " +
|
||||
data.participants
|
||||
.filter(p => p.address && p.name !== AGENT_NAME)
|
||||
.map(p => `${p.address}(${p.role})`)
|
||||
.join("、")
|
||||
);
|
||||
}
|
||||
if (data.reply_address) {
|
||||
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
},
|
||||
};
|
||||
|
||||
// 平台原生权限询问 → 邮件。
|
||||
//
|
||||
// **不作为工具暴露给模型**:opencode 自己就有权限机制(permission.ask 钩子 /
|
||||
@ -1052,10 +1188,16 @@ export default async function mailBridge(input) {
|
||||
tool: {
|
||||
send_mail: sendMailTool,
|
||||
read_inbox: readInboxTool,
|
||||
read_mail: readMailTool,
|
||||
forward_mail: forwardMailTool,
|
||||
upload_attachment: uploadAttachmentTool,
|
||||
download_attachment: downloadAttachmentTool,
|
||||
connect_to_server: connectToServerTool,
|
||||
// 寻址发现:让模型选地址而不是拼地址
|
||||
suggest_address: suggestAddressTool,
|
||||
list_contacts: listContactsTool,
|
||||
session_participants: sessionParticipantsTool,
|
||||
read_thread: readThreadTool,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
141
plugins/opencode-mail-bridge/lib/addressing.js
Normal file
141
plugins/opencode-mail-bridge/lib/addressing.js
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 三维寻址的构造与判读 —— 所有平台插件共用。
|
||||
*
|
||||
* 为什么这些函数必须共用、且必须是纯函数:
|
||||
*
|
||||
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
|
||||
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
|
||||
* 生产上真实发生过两次:
|
||||
*
|
||||
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
|
||||
* 再建一条平行会话,双方从此各说各话。
|
||||
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`,
|
||||
* 整串被当成名字,session 位静默丢失。
|
||||
*
|
||||
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 拼一个可寻址的 `name@path.session`。
|
||||
*
|
||||
* **空 path 也必须留下 `@` 与 `.`**:`admin@.silent-harbor` 才解析成
|
||||
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
|
||||
* `admin.silent-harbor` 会被整串当作名字。
|
||||
*
|
||||
* session 省略时不写那一位(默认会话语义)。
|
||||
*
|
||||
* @param {string} name 收件方名(Agent 名或人类用户名)
|
||||
* @param {string} [path] 工作目录,可为空
|
||||
* @param {string} [session] 会话别名;空则省略该位
|
||||
* @returns {string} 地址,name 为空时返回空串
|
||||
*/
|
||||
export function formatAddress(name, path, session) {
|
||||
const n = String(name ?? '').trim();
|
||||
const p = String(path ?? '').trim();
|
||||
const s = String(session ?? '').trim();
|
||||
if (!n) return '';
|
||||
if (!s) return p ? `${n}@${p}` : n;
|
||||
return `${n}@${p}.${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断自己在这封邮件里是收件人还是抄送方。
|
||||
*
|
||||
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
|
||||
* admin 主发 dsh、抄送 opencode,分工是「dsh 提供源码解读、opencode 提供部署
|
||||
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
|
||||
* 或者都以为自己只是旁观者。
|
||||
*
|
||||
* @param {any} mail `/mail/inbox` 返回的一封邮件
|
||||
* @param {string} selfName 自己的 Agent 名
|
||||
* @returns {'to'|'cc'|'unknown'}
|
||||
*/
|
||||
export function roleOf(mail, selfName) {
|
||||
const self = String(selfName ?? '').trim();
|
||||
if (!self) return 'unknown';
|
||||
if (mail?.to_name === self) return 'to';
|
||||
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
|
||||
return 'cc';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 给出「把回信发回这条会话」的地址。
|
||||
*
|
||||
* 发件人一侧**不带 path**:Agent 回信时 `from_workspace` 存的是 Agent 名而不是
|
||||
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
|
||||
* 人类发件人本来就没有工作目录。
|
||||
*
|
||||
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
|
||||
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {string}
|
||||
*/
|
||||
export function replyAddressFor(mail, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
return formatAddress(mail?.from_name, '', a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
|
||||
*
|
||||
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
|
||||
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} selfName 自己的 Agent 名
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {string}
|
||||
*/
|
||||
export function selfAddressFor(mail, selfName, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
|
||||
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
|
||||
let path = mail?.to_workspace ?? '';
|
||||
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
|
||||
const mine = mail.cc_list.find(c => c?.name === selfName);
|
||||
if (mine) path = mine.path ?? '';
|
||||
}
|
||||
return formatAddress(selfName, path, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出这封邮件的全部参与方及各自可投递的地址。
|
||||
*
|
||||
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
|
||||
* 抄送方的 path 取它自己那个地址的 path 位。
|
||||
*
|
||||
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
|
||||
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
|
||||
*
|
||||
* @param {any} mail 一封邮件
|
||||
* @param {string} [selfName] 自己的名字,用于标记 is_self
|
||||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||||
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
|
||||
*/
|
||||
export function participantsOfMail(mail, selfName, alias) {
|
||||
const a = alias ?? mail?.session_alias ?? '';
|
||||
const self = String(selfName ?? '').trim();
|
||||
const out = [];
|
||||
const add = (role, name, path) => {
|
||||
const n = String(name ?? '').trim();
|
||||
if (!n) return;
|
||||
out.push({
|
||||
role,
|
||||
name: n,
|
||||
path: String(path ?? ''),
|
||||
address: formatAddress(n, path, a),
|
||||
is_self: !!self && n === self,
|
||||
});
|
||||
};
|
||||
// 发件人一侧 path 留空,理由同 replyAddressFor
|
||||
add('from', mail?.from_name, '');
|
||||
add('to', mail?.to_name, mail?.to_workspace);
|
||||
if (Array.isArray(mail?.cc_list)) {
|
||||
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
237
plugins/opencode-mail-bridge/lib/discovery.js
Normal file
237
plugins/opencode-mail-bridge/lib/discovery.js
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
|
||||
*
|
||||
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
|
||||
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
|
||||
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
|
||||
*
|
||||
* # 这一组端点解决的问题
|
||||
*
|
||||
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
|
||||
* 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从
|
||||
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
|
||||
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
|
||||
* 那个错误路径静默变成了新会话的 workspace。
|
||||
*
|
||||
* # 渲染的取舍
|
||||
*
|
||||
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
|
||||
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
|
||||
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 渲染候选收件人清单(`kind: "name"`)。
|
||||
*
|
||||
* 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的
|
||||
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
|
||||
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
|
||||
*
|
||||
* @param {string[]} names
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderNameSuggestions(names) {
|
||||
const list = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||
if (list.length === 0) return '当前没有可投递的收件人。';
|
||||
return [
|
||||
`可投递的收件人(${list.length} 个):`,
|
||||
list.map(n => `- ${n}`).join('\n'),
|
||||
'',
|
||||
'下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染工作目录候选(`kind: "path"`)。
|
||||
*
|
||||
* 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录),
|
||||
* 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
*
|
||||
* @param {string[]} paths
|
||||
* @param {string} name 正在查的收件人名,用于拼下一步的提示
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderPathSuggestions(paths, name) {
|
||||
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
return [
|
||||
`${name} 没有记录在案的工作目录。`,
|
||||
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
|
||||
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`,
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`${name} 用过的工作目录(按最近使用排序):`,
|
||||
list.map(p => `- ${p}`).join('\n'),
|
||||
'',
|
||||
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染会话候选(`kind: "session"`)。
|
||||
*
|
||||
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`:
|
||||
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
|
||||
*
|
||||
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
|
||||
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
*
|
||||
* @param {object} data `/agent/contacts/suggest` 的返回体
|
||||
* @param {string} name
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderSessionSuggestions(data, name, path) {
|
||||
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
|
||||
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
|
||||
|
||||
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
|
||||
const existing = aliases.filter(a => a !== 'new');
|
||||
if (existing.length === 0) {
|
||||
return [
|
||||
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
|
||||
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`,
|
||||
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
|
||||
for (let i = 0; i < aliases.length; i++) {
|
||||
const alias = aliases[i];
|
||||
const addr = addresses[i] || '';
|
||||
const c = candidates[i] || {};
|
||||
if (alias === 'new') continue; // new 单独放最后
|
||||
const bits = [];
|
||||
if (c.title) bits.push(c.title);
|
||||
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
|
||||
if (c.source === 'platform') bits.push('平台侧会话');
|
||||
lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
|
||||
const newAddr = addressAt(addresses, aliases, 'new');
|
||||
if (newAddr) {
|
||||
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 按别名在同序的 addresses 里取地址。 */
|
||||
function addressAt(addresses, aliases, alias) {
|
||||
const i = aliases.indexOf(alias);
|
||||
return i >= 0 ? addresses[i] || '' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染会话参与方清单。
|
||||
*
|
||||
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
|
||||
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
|
||||
* 「作为发件人」的邮件,正是为了让这个判断成立。
|
||||
*
|
||||
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderParticipants(data) {
|
||||
const parts = Array.isArray(data?.participants) ? data.participants : [];
|
||||
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
|
||||
|
||||
const alias = data?.session_alias || '';
|
||||
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
|
||||
for (const p of parts) {
|
||||
const tags = [];
|
||||
if (p.is_self) tags.push('就是你');
|
||||
if (Array.isArray(p.roles) && p.roles.length) {
|
||||
tags.push(p.roles.map(roleLabel).join('/'));
|
||||
}
|
||||
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
|
||||
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
|
||||
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染联系人清单(本 Agent 参与过的全部会话)。
|
||||
*
|
||||
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
|
||||
* 有未读的那些才是答案。
|
||||
*
|
||||
* @param {object} data `/agent/contacts` 的返回体
|
||||
* @param {number} limit 最多列出多少条
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderContacts(data, limit = 20) {
|
||||
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
|
||||
if (list.length === 0) return '还没有任何往来会话。';
|
||||
|
||||
list.sort((a, b) => {
|
||||
const ua = a?.unread_count || 0;
|
||||
const ub = b?.unread_count || 0;
|
||||
if (ua !== ub) return ub - ua;
|
||||
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
|
||||
});
|
||||
|
||||
const shown = list.slice(0, limit);
|
||||
const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`];
|
||||
for (const c of shown) {
|
||||
const bits = [];
|
||||
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
|
||||
if (c.subject) bits.push(c.subject);
|
||||
if (c.max_rounds > 0) {
|
||||
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
|
||||
bits.push(`剩 ${left}/${c.max_rounds} 个来回`);
|
||||
}
|
||||
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
|
||||
lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||||
function roleLabel(role) {
|
||||
switch (role) {
|
||||
case 'from': return '发件人';
|
||||
case 'to': return '收件人';
|
||||
case 'cc': return '抄送方';
|
||||
default: return String(role);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染对话树,回答「谁已经回了、谁还没回」。
|
||||
*
|
||||
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
|
||||
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
|
||||
*
|
||||
* @param {object} data `/agent/mail/{id}/thread` 的返回体
|
||||
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderThread(data, selfName = '') {
|
||||
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||||
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
|
||||
|
||||
const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`];
|
||||
for (const n of nodes) {
|
||||
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
|
||||
const indent = ' '.repeat(Math.min(depth, 8));
|
||||
const marks = [];
|
||||
if (selfName && n?.from_name === selfName) marks.push('你发的');
|
||||
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
|
||||
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
|
||||
lines.push(
|
||||
`${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
|
||||
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}`
|
||||
);
|
||||
}
|
||||
if (data?.has_more) {
|
||||
lines.push('');
|
||||
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@ -6,6 +6,8 @@
|
||||
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
|
||||
*/
|
||||
|
||||
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
|
||||
|
||||
/** 人类可读的字节数,用于附件清单展示。 */
|
||||
export function formatSize(n) {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
|
||||
@ -19,19 +21,41 @@ export function formatSize(n) {
|
||||
*
|
||||
* @param {any} m `/mail/inbox` 返回的一封邮件
|
||||
* @param {number} bodyLimit 正文截断长度
|
||||
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
|
||||
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderMail(m, bodyLimit = 200) {
|
||||
export function renderMail(m, bodyLimit = 200, selfName = '') {
|
||||
const alias = m?.session_alias || '';
|
||||
const lines = [
|
||||
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
|
||||
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
|
||||
`会话: #${m?.session_alias || '未命名'}`,
|
||||
`会话: #${alias || '未命名'}`,
|
||||
];
|
||||
|
||||
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
|
||||
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
|
||||
// 抄送方却看不到收件人叫什么。
|
||||
if (m?.to_name) {
|
||||
let toLine = `收件人: ${m.to_name}`;
|
||||
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
|
||||
lines.push(toLine);
|
||||
}
|
||||
|
||||
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
|
||||
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
|
||||
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
|
||||
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
|
||||
}
|
||||
|
||||
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
|
||||
// 以为自己是负责人,或者都以为自己只是旁观者。
|
||||
if (selfName) {
|
||||
const role = roleOf(m, selfName);
|
||||
if (role === 'to') lines.push('你的身份: 收件人(主办)');
|
||||
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
|
||||
}
|
||||
|
||||
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
|
||||
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
|
||||
lines.push(
|
||||
@ -42,22 +66,52 @@ export function renderMail(m, bodyLimit = 200) {
|
||||
);
|
||||
lines.push('下载附件请用 download_attachment 工具。');
|
||||
}
|
||||
|
||||
// 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。
|
||||
const body = m?.body_preview || m?.body || '';
|
||||
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
|
||||
|
||||
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
|
||||
//
|
||||
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
|
||||
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
|
||||
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
|
||||
if (selfName && alias) {
|
||||
const parts = participantsOfMail(m, selfName, alias);
|
||||
const others = parts.filter(p => !p.is_self && p.address);
|
||||
if (others.length > 0) {
|
||||
lines.push(
|
||||
'可投递地址: ' +
|
||||
others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、')
|
||||
);
|
||||
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||||
function roleLabel(role) {
|
||||
switch (role) {
|
||||
case 'from': return '发件人';
|
||||
case 'to': return '收件人';
|
||||
case 'cc': return '抄送方';
|
||||
default: return role;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染整个收件箱。
|
||||
* @param {any[]} mails
|
||||
* @param {number} bodyLimit
|
||||
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderInbox(mails, bodyLimit = 200) {
|
||||
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
|
||||
const list = Array.isArray(mails) ? mails : [];
|
||||
if (list.length === 0) return '收件箱为空。';
|
||||
return list.map(m => renderMail(m, bodyLimit)).join('\n\n');
|
||||
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -65,6 +65,37 @@ export function snapshotDshModels(entries) {
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pi 的模型列表整理成上报格式。
|
||||
*
|
||||
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
|
||||
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
|
||||
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
|
||||
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
|
||||
*
|
||||
* pi 的 Model 对象上,provider 在 `provider` 字段、模型 id 在 `id` 字段,
|
||||
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
|
||||
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
|
||||
*
|
||||
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotPiModels(models) {
|
||||
const list = Array.isArray(models) ? models : [];
|
||||
const out = [];
|
||||
for (const m of list) {
|
||||
const provider = typeof m?.provider === 'string' ? m.provider : '';
|
||||
const model = typeof m?.id === 'string' ? m.id : '';
|
||||
if (!provider || !model) continue;
|
||||
out.push({
|
||||
provider,
|
||||
model,
|
||||
display_name: typeof m?.name === 'string' ? m.name : '',
|
||||
});
|
||||
}
|
||||
return dedupeAndCap(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定这一轮按什么顺序尝试模型。
|
||||
*
|
||||
|
||||
@ -83,6 +83,83 @@ export function snapshotDshSessions(entries, isMailDriven = () => false) {
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
|
||||
*
|
||||
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
|
||||
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
|
||||
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
|
||||
* - **pi 内核(SDK)自己不生成**:桥用 createAgentSession 起的会话没有名字,
|
||||
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming)
|
||||
*
|
||||
* 与另两个平台的差异:pi 的 SessionInfo 里**没有 subagent 标记**。
|
||||
* pi-subagents 把子会话写在自定义 sessionDir(run 根目录)下,默认会话目录
|
||||
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
|
||||
*
|
||||
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
|
||||
* @param {(id: string) => boolean} isMailDriven
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function snapshotPiSessions(entries, isMailDriven = () => false) {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
const out = [];
|
||||
for (const e of list) {
|
||||
const id = typeof e?.id === 'string' ? e.id : '';
|
||||
if (!id) continue;
|
||||
const name = typeof e?.name === 'string' ? e.name : '';
|
||||
// 没有名字的会话不报(S-1):pi 的列表在无名时显示首条消息,
|
||||
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
|
||||
if (!name) continue;
|
||||
// 模型把思维链当标题写进来的那些不报(见 isUnusableName)
|
||||
if (isUnusableName(name)) continue;
|
||||
const slug = slugFromTitle(name);
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
platform_id: id,
|
||||
// 老会话的 cwd 是空串(pi 的 SessionInfo 注释里写明了),照实上报,
|
||||
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
|
||||
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
|
||||
slug,
|
||||
title: name,
|
||||
mail_driven: Boolean(isMailDriven(id)),
|
||||
updated_at: toISO(e?.modified ?? e?.created),
|
||||
});
|
||||
}
|
||||
return dedupeBySlug(sortAndCap(out));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个平台侧名字是否不适合当别名。
|
||||
*
|
||||
* 这条判废是 pi 特有的:pi-web 的标题生成器(`sessionNameGenerator`)只做了
|
||||
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
|
||||
*
|
||||
* "The user is asking me to generate a title for a coding-agent"
|
||||
* "我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:…"
|
||||
*
|
||||
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
|
||||
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
|
||||
*
|
||||
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
|
||||
* 而漏掉一个坏名字只是别名难看。
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUnusableName(name) {
|
||||
const s = String(name ?? '').trim();
|
||||
if (!s) return true;
|
||||
// 自指标题生成任务 = 模型把系统提示词复述了出来
|
||||
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
|
||||
return true;
|
||||
}
|
||||
// 以第三人称叙述用户意图开头 = 思维链的典型开场
|
||||
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(,|,))/i.test(s)) return true;
|
||||
// 又长又分句 = 一段话而不是一个标题(pi-web 截断上限是 60)
|
||||
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
|
||||
function isSubagent(e) {
|
||||
if (e?.origin === 'subagent') return true;
|
||||
@ -131,11 +208,17 @@ export function slugFromTitle(title) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** 毫秒时间戳或 ISO 串 → ISO 串;无法解析时返回 undefined。 */
|
||||
/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */
|
||||
function toISO(v) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
// pi 的 SessionInfo 给的是 Date 实例(created/modified),不是时间戳。
|
||||
// 少了这一支会让整份快照的 updated_at 全是 undefined,于是服务端只能按
|
||||
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
|
||||
if (v instanceof Date) {
|
||||
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
|
||||
}
|
||||
if (typeof v === 'string' && v) {
|
||||
const d = new Date(v);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
|
||||
145
plugins/opencode-mail-bridge/test/addressing.test.mjs
Normal file
145
plugins/opencode-mail-bridge/test/addressing.test.mjs
Normal file
@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatAddress,
|
||||
roleOf,
|
||||
replyAddressFor,
|
||||
selfAddressFor,
|
||||
participantsOfMail,
|
||||
} from '../lib/addressing.js';
|
||||
|
||||
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
|
||||
// 「拼出来的东西还能不能被正确解析回三段」上。
|
||||
|
||||
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
|
||||
// 生产事故:朴素拼接得到 admin.silent-harbor,没有 @,
|
||||
// 整串被 ParseAddress 当成名字,session 位静默丢失。
|
||||
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('formatAddress: 省略 session 位', () => {
|
||||
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
|
||||
// 名字与 path 都有但都不带会话 → 默认会话语义
|
||||
assert.equal(formatAddress('dsh', '', ''), 'dsh');
|
||||
});
|
||||
|
||||
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
|
||||
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
|
||||
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
|
||||
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
|
||||
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
|
||||
});
|
||||
|
||||
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
|
||||
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
|
||||
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
|
||||
assert.equal(formatAddress('', '/p', 'a'), '');
|
||||
assert.equal(formatAddress(null, '/p', 'a'), '');
|
||||
});
|
||||
|
||||
test('formatAddress: 去掉首尾空白', () => {
|
||||
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
|
||||
});
|
||||
|
||||
const ccMail = {
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
};
|
||||
|
||||
test('roleOf: 区分主收件人与抄送方', () => {
|
||||
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
|
||||
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
|
||||
assert.equal(roleOf(ccMail, 'dsh'), 'to');
|
||||
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
|
||||
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
|
||||
});
|
||||
|
||||
test('roleOf: 名字为空时不猜', () => {
|
||||
assert.equal(roleOf(ccMail, ''), 'unknown');
|
||||
assert.equal(roleOf(ccMail, undefined), 'unknown');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
|
||||
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
|
||||
const addr = replyAddressFor(ccMail);
|
||||
assert.equal(addr, 'admin@.silent-harbor');
|
||||
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 发件人一侧不带 path', () => {
|
||||
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
|
||||
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
|
||||
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
|
||||
assert.equal(replyAddressFor(mail), 'dsh@.x');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 无别名时退回默认会话形式', () => {
|
||||
const mail = { from_name: 'admin', session_alias: '' };
|
||||
const addr = replyAddressFor(mail);
|
||||
assert.equal(addr, 'admin');
|
||||
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
|
||||
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
|
||||
});
|
||||
|
||||
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
|
||||
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path,
|
||||
// 「我是谁」这句话就指向了别人的目录。
|
||||
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
|
||||
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
|
||||
|
||||
assert.equal(byName.opencode.path, '/home');
|
||||
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
|
||||
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
|
||||
// 发件人 path 留空,理由同 replyAddressFor
|
||||
assert.equal(byName.admin.address, 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
|
||||
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名,
|
||||
// 否则「回给抄收方」这个动作每次都会新开会话。
|
||||
for (const p of participantsOfMail(ccMail, 'dsh')) {
|
||||
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('participantsOfMail: 自己被标记而不是被剔除', () => {
|
||||
// 剔掉的话模型无法确认这封信是不是也发给了自己,
|
||||
// 也就无法判断自己该不该回。
|
||||
const parts = participantsOfMail(ccMail, 'opencode');
|
||||
const me = parts.find(p => p.name === 'opencode');
|
||||
assert.ok(me, '自己应出现在参与方列表里');
|
||||
assert.equal(me.is_self, true);
|
||||
assert.equal(parts.filter(p => p.is_self).length, 1);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
|
||||
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 无抄送时只有两方', () => {
|
||||
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 跳过空名字条目', () => {
|
||||
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
|
||||
const mail = {
|
||||
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
|
||||
cc_list: [{ name: '', path: '/x' }, {}],
|
||||
session_alias: 'a',
|
||||
};
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
for (const p of parts) assert.notEqual(p.address, '');
|
||||
});
|
||||
218
plugins/opencode-mail-bridge/test/discovery.test.mjs
Normal file
218
plugins/opencode-mail-bridge/test/discovery.test.mjs
Normal file
@ -0,0 +1,218 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
|
||||
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
|
||||
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
|
||||
|
||||
test('renderNameSuggestions 只给名字并指向下一步', () => {
|
||||
// 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」——
|
||||
// 那不一定是调用方想要的那条。
|
||||
const got = renderNameSuggestions(['opencode', 'admin']);
|
||||
assert.match(got, /opencode/);
|
||||
assert.match(got, /admin/);
|
||||
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
|
||||
});
|
||||
|
||||
test('renderNameSuggestions 空列表给明确文案', () => {
|
||||
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
|
||||
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
|
||||
// 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
const got = renderPathSuggestions([], 'admin');
|
||||
assert.match(got, /可以留空/);
|
||||
assert.match(got, /admin/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 列出目录并指向下一步', () => {
|
||||
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
|
||||
assert.match(got, /\/home\/program\/agentmail/);
|
||||
assert.match(got, /最近使用/);
|
||||
assert.match(got, /suggest_address\(name="opencode", path="/);
|
||||
});
|
||||
|
||||
const sessionData = {
|
||||
kind: 'session',
|
||||
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
|
||||
addresses: [
|
||||
'opencode@/home.silent-harbor',
|
||||
'opencode@/home.happy-tiger',
|
||||
'opencode@/home.new',
|
||||
],
|
||||
candidates: [
|
||||
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
|
||||
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
|
||||
{ alias: 'new', title: '新建会话', source: 'new' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
|
||||
// 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions
|
||||
// 同序由服务端保证,直接用。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /opencode@\/home\.happy-tiger/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 带出标题与未读数', () => {
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /联调 llmsproxy/);
|
||||
assert.match(got, /2 封未读/);
|
||||
});
|
||||
|
||||
test('不变量:new 不与已存在会话混列,且带警告', () => {
|
||||
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
const lines = got.split('\n');
|
||||
const newLineIdx = lines.findIndex(l => l.includes('.new'));
|
||||
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
|
||||
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
|
||||
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
|
||||
// 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。
|
||||
// 不传的话服务端会自动命名,但模型不知道那个名字。
|
||||
const got = renderSessionSuggestions(
|
||||
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
|
||||
'dsh', '/tmp',
|
||||
);
|
||||
assert.match(got, /还没有可续谈的会话/);
|
||||
assert.match(got, /session_alias/);
|
||||
});
|
||||
|
||||
const participantData = {
|
||||
session_id: 'f3d824ce',
|
||||
session_alias: 'silent-harbor',
|
||||
participants: [
|
||||
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
|
||||
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
|
||||
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderParticipants 给出每个参与方的地址', () => {
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /admin@\.silent-harbor/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('不变量:标出「尚未回应」的人', () => {
|
||||
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
|
||||
// 正是为了让这个判断成立。
|
||||
const got = renderParticipants(participantData);
|
||||
const line = got.split('\n').find(l => l.includes('opencode'));
|
||||
assert.match(line, /尚未回应/);
|
||||
// 自己不该被标「尚未回应」—— 自己正在处理这封
|
||||
const selfLine = got.split('\n').find(l => l.includes('dsh'));
|
||||
assert.ok(!selfLine.includes('尚未回应'));
|
||||
assert.match(selfLine, /就是你/);
|
||||
});
|
||||
|
||||
test('renderParticipants 用中文角色标签', () => {
|
||||
// 模型读到「抄送方」比读到 cc 更容易判对分工。
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /抄送方/);
|
||||
assert.match(got, /发件人/);
|
||||
});
|
||||
|
||||
test('renderParticipants 无地址时说明原因', () => {
|
||||
const got = renderParticipants({
|
||||
session_alias: '',
|
||||
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
|
||||
});
|
||||
assert.match(got, /尚未命名/);
|
||||
});
|
||||
|
||||
test('renderParticipants 空会话不崩', () => {
|
||||
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
|
||||
assert.match(renderParticipants({}), /还没有参与方/);
|
||||
});
|
||||
|
||||
test('renderContacts 未读优先排序', () => {
|
||||
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
|
||||
const got = renderContacts({
|
||||
contacts: [
|
||||
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
|
||||
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
|
||||
],
|
||||
});
|
||||
const lines = got.split('\n').filter(l => l.startsWith('- '));
|
||||
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
|
||||
assert.match(lines[0], /3 封未读/);
|
||||
});
|
||||
|
||||
test('renderContacts 带出剩余预算', () => {
|
||||
const got = renderContacts({
|
||||
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
|
||||
});
|
||||
assert.match(got, /剩 3\/20 个来回/);
|
||||
});
|
||||
|
||||
test('renderContacts 未命名会话说明只能 reply_to', () => {
|
||||
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
|
||||
assert.match(got, /reply_to/);
|
||||
});
|
||||
|
||||
test('renderContacts 空列表', () => {
|
||||
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
|
||||
});
|
||||
|
||||
const threadData = {
|
||||
anchor_mail_id: 'm-2',
|
||||
total: 3,
|
||||
hidden: 1,
|
||||
nodes: [
|
||||
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
|
||||
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
|
||||
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderThread 用缩进表示层级', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const lines = got.split('\n');
|
||||
const l1 = lines.find(l => l.includes('m-1'));
|
||||
const l2 = lines.find(l => l.includes('m-2'));
|
||||
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
|
||||
});
|
||||
|
||||
test('不变量:detached 必须标出来', () => {
|
||||
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const line = got.split('\n').find(l => l.includes('m-3'));
|
||||
assert.match(line, /父邮件无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 标出自己发的与当前这封', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
|
||||
});
|
||||
|
||||
test('renderThread 报告不可见数量', () => {
|
||||
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
|
||||
// 不说的话模型会以为自己看到了全貌。
|
||||
assert.match(renderThread(threadData), /另有 1 封无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 有更多时给出 offset', () => {
|
||||
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
|
||||
assert.match(got, /offset=60/);
|
||||
});
|
||||
|
||||
test('renderThread 空线索不崩', () => {
|
||||
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
|
||||
assert.match(renderThread({}), /没有可见的邮件/);
|
||||
});
|
||||
@ -117,6 +117,96 @@ test('附件字段不是数组时忽略', () => {
|
||||
assert.ok(!got.includes('抄送'));
|
||||
});
|
||||
|
||||
// ─── 收件人与身份(只有知道自己是谁才能判定)───
|
||||
|
||||
test('不变量:收件人要显示出来', () => {
|
||||
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
|
||||
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
|
||||
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
|
||||
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
|
||||
});
|
||||
|
||||
test('收件人无工作目录时只显名字', () => {
|
||||
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
|
||||
assert.match(got, /收件人: admin$/m);
|
||||
});
|
||||
|
||||
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
|
||||
const got = renderMail(mail({ to_name: 'dsh' }));
|
||||
assert.ok(!got.includes('你的身份'));
|
||||
});
|
||||
|
||||
test('不变量:区分收件人与抄送方身份', () => {
|
||||
// 两者职责不同。不区分的话两方都会以为自己是负责人,
|
||||
// 或者都以为自己只是旁观者。
|
||||
const m = mail({
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
|
||||
});
|
||||
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
|
||||
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
|
||||
// 不相关的名字不编造身份
|
||||
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
|
||||
});
|
||||
|
||||
// ─── 可投递地址(「精准发信」的关键)───
|
||||
|
||||
const joint = () => mail({
|
||||
mail_id: 'm-7',
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
});
|
||||
|
||||
test('不变量:给出每个参与方的可投递地址', () => {
|
||||
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`,
|
||||
// 而那个地址回过去只会再建一条平行会话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /可投递地址/);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor(抄送方)/);
|
||||
assert.match(got, /admin@\.silent-harbor(发件人)/);
|
||||
});
|
||||
|
||||
test('不变量:可投递地址里绝不出现 .new', () => {
|
||||
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
|
||||
// 把它当回信地址会让双方各说各话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(line, '应有可投递地址行');
|
||||
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
|
||||
});
|
||||
|
||||
test('可投递地址不列自己', () => {
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
|
||||
});
|
||||
|
||||
test('同时给出 reply_to 这条更稳的路', () => {
|
||||
// 地址可能拼错,reply_to 不会 —— 两条路都告诉模型。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /reply_to=m-7/);
|
||||
});
|
||||
|
||||
test('无会话别名时不给地址(宁可不给不可给错)', () => {
|
||||
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
|
||||
// 实际指向默认会话的地址,比不给危险。
|
||||
const got = renderMail(mail({
|
||||
to_name: 'dsh', session_alias: '',
|
||||
cc_list: [{ name: 'opencode', path: '/home' }],
|
||||
}), 200, 'dsh');
|
||||
assert.ok(!got.includes('可投递地址'));
|
||||
});
|
||||
|
||||
test('renderInbox 透传 selfName', () => {
|
||||
const got = renderInbox([joint()], 200, 'opencode');
|
||||
assert.match(got, /你的身份: 抄送方/);
|
||||
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor(收件人)/);
|
||||
});
|
||||
|
||||
// ─── renderInbox ───
|
||||
|
||||
test('renderInbox 空收件箱给明确文案', () => {
|
||||
|
||||
@ -12,6 +12,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeModels,
|
||||
snapshotDshModels,
|
||||
snapshotPiModels,
|
||||
modelAttemptOrder,
|
||||
renderFailureReport,
|
||||
MAX_CATALOG,
|
||||
@ -106,6 +107,46 @@ test('目录截断到 MAX_CATALOG', () => {
|
||||
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── pi 目录 ───
|
||||
|
||||
test('pi 目录用 provider + id', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
|
||||
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.deepEqual(got[1], {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
display_name: 'Claude Sonnet 4.6',
|
||||
});
|
||||
});
|
||||
|
||||
test('pi 目录跳过缺 provider 或 id 的条目', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: '', id: 'x' },
|
||||
{ provider: 'p' },
|
||||
{ provider: 'p', id: 'ok' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].model, 'ok');
|
||||
});
|
||||
|
||||
test('pi 目录容错:非数组不崩', () => {
|
||||
assert.deepEqual(snapshotPiModels(undefined), []);
|
||||
assert.deepEqual(snapshotPiModels(null), []);
|
||||
assert.deepEqual(snapshotPiModels('oops'), []);
|
||||
});
|
||||
|
||||
test('pi 目录同样受 MAX_CATALOG 截断', () => {
|
||||
// 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。
|
||||
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
|
||||
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
|
||||
provider: 'p', id: `m${i}`, name: `M${i}`,
|
||||
}));
|
||||
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── modelAttemptOrder ───
|
||||
|
||||
test('管理员划定范围时按 rank 顺序尝试', () => {
|
||||
|
||||
@ -12,6 +12,8 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeSessions,
|
||||
snapshotDshSessions,
|
||||
snapshotPiSessions,
|
||||
isUnusableName,
|
||||
slugFromTitle,
|
||||
MAX_REPORTED,
|
||||
} from '../lib/session-snapshot.js';
|
||||
@ -226,3 +228,99 @@ test('不同标题不受去重影响', () => {
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
});
|
||||
|
||||
// ─── pi:名字来自会话文件的 session_info ───
|
||||
|
||||
const piSession = (over = {}) => ({
|
||||
id: '01a064cc-df57-7b2d-bebb-736776105485',
|
||||
cwd: '/home/program/agentmail',
|
||||
name: '重构导入路径',
|
||||
messageCount: 6,
|
||||
created: new Date(1788300000000),
|
||||
modified: new Date(1788344476744),
|
||||
...over,
|
||||
});
|
||||
|
||||
test('pi 快照取 cwd 与 session_info 名字', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.workspace, '/home/program/agentmail');
|
||||
assert.equal(got.title, '重构导入路径');
|
||||
assert.equal(got.slug, '重构导入路径');
|
||||
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
|
||||
});
|
||||
|
||||
test('不变量:pi 无名会话不上报', () => {
|
||||
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
|
||||
// (「你收到一封新邮件(AgentMail)…」)—— 拿它当别名毫无区分度,且条条撞名。
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'named', name: '有名字' }),
|
||||
piSession({ id: 'anon', name: undefined }),
|
||||
piSession({ id: 'blank', name: '' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['named']);
|
||||
});
|
||||
|
||||
test('不变量:pi 老会话的空 cwd 照实上报', () => {
|
||||
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
|
||||
// 那条会话在补全里挂到一个它其实不属于的工作区下。
|
||||
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
|
||||
assert.equal(got.workspace, '');
|
||||
});
|
||||
|
||||
test('不变量:pi 的 updated_at 取 modified(文件 mtime)', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
|
||||
});
|
||||
|
||||
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
|
||||
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'new');
|
||||
});
|
||||
|
||||
test('pi 快照标记邮件驱动的会话', () => {
|
||||
const got = snapshotPiSessions(
|
||||
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
|
||||
(id) => id === 'mail-one'
|
||||
);
|
||||
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
|
||||
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
|
||||
});
|
||||
|
||||
// ─── isUnusableName:pi-web 标题生成器的思维链泄漏 ───
|
||||
|
||||
test('不变量:思维链泄漏的标题判废', () => {
|
||||
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
|
||||
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
|
||||
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
|
||||
assert.equal(
|
||||
isUnusableName('我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:Opencode源测试。或者更简'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isUnusableName 放过正常标题', () => {
|
||||
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
|
||||
assert.equal(isUnusableName('查看Agent接入群聊'), false);
|
||||
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
|
||||
assert.equal(isUnusableName('homeagent-gateway'), false);
|
||||
assert.equal(isUnusableName('重构导入路径'), false);
|
||||
assert.equal(isUnusableName('Fix flaky auth test'), false);
|
||||
});
|
||||
|
||||
test('isUnusableName 判废空名字', () => {
|
||||
assert.equal(isUnusableName(''), true);
|
||||
assert.equal(isUnusableName(' '), true);
|
||||
assert.equal(isUnusableName(undefined), true);
|
||||
});
|
||||
|
||||
test('判废的名字不进快照', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
|
||||
piSession({ id: 'clean', name: '正常标题' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user