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:
145
plugins/pi-mail-bridge/test/addressing.test.mjs
Normal file
145
plugins/pi-mail-bridge/test/addressing.test.mjs
Normal file
@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatAddress,
|
||||
roleOf,
|
||||
replyAddressFor,
|
||||
selfAddressFor,
|
||||
participantsOfMail,
|
||||
} from '../lib/addressing.js';
|
||||
|
||||
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
|
||||
// 「拼出来的东西还能不能被正确解析回三段」上。
|
||||
|
||||
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
|
||||
// 生产事故:朴素拼接得到 admin.silent-harbor,没有 @,
|
||||
// 整串被 ParseAddress 当成名字,session 位静默丢失。
|
||||
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('formatAddress: 省略 session 位', () => {
|
||||
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
|
||||
// 名字与 path 都有但都不带会话 → 默认会话语义
|
||||
assert.equal(formatAddress('dsh', '', ''), 'dsh');
|
||||
});
|
||||
|
||||
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
|
||||
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
|
||||
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
|
||||
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
|
||||
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
|
||||
});
|
||||
|
||||
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
|
||||
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
|
||||
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
|
||||
assert.equal(formatAddress('', '/p', 'a'), '');
|
||||
assert.equal(formatAddress(null, '/p', 'a'), '');
|
||||
});
|
||||
|
||||
test('formatAddress: 去掉首尾空白', () => {
|
||||
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
|
||||
});
|
||||
|
||||
const ccMail = {
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
};
|
||||
|
||||
test('roleOf: 区分主收件人与抄送方', () => {
|
||||
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
|
||||
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
|
||||
assert.equal(roleOf(ccMail, 'dsh'), 'to');
|
||||
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
|
||||
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
|
||||
});
|
||||
|
||||
test('roleOf: 名字为空时不猜', () => {
|
||||
assert.equal(roleOf(ccMail, ''), 'unknown');
|
||||
assert.equal(roleOf(ccMail, undefined), 'unknown');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
|
||||
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
|
||||
const addr = replyAddressFor(ccMail);
|
||||
assert.equal(addr, 'admin@.silent-harbor');
|
||||
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 发件人一侧不带 path', () => {
|
||||
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
|
||||
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
|
||||
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
|
||||
assert.equal(replyAddressFor(mail), 'dsh@.x');
|
||||
});
|
||||
|
||||
test('replyAddressFor: 无别名时退回默认会话形式', () => {
|
||||
const mail = { from_name: 'admin', session_alias: '' };
|
||||
const addr = replyAddressFor(mail);
|
||||
assert.equal(addr, 'admin');
|
||||
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
|
||||
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
|
||||
});
|
||||
|
||||
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
|
||||
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path,
|
||||
// 「我是谁」这句话就指向了别人的目录。
|
||||
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
|
||||
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
|
||||
|
||||
assert.equal(byName.opencode.path, '/home');
|
||||
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
|
||||
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
|
||||
// 发件人 path 留空,理由同 replyAddressFor
|
||||
assert.equal(byName.admin.address, 'admin@.silent-harbor');
|
||||
});
|
||||
|
||||
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
|
||||
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名,
|
||||
// 否则「回给抄收方」这个动作每次都会新开会话。
|
||||
for (const p of participantsOfMail(ccMail, 'dsh')) {
|
||||
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('participantsOfMail: 自己被标记而不是被剔除', () => {
|
||||
// 剔掉的话模型无法确认这封信是不是也发给了自己,
|
||||
// 也就无法判断自己该不该回。
|
||||
const parts = participantsOfMail(ccMail, 'opencode');
|
||||
const me = parts.find(p => p.name === 'opencode');
|
||||
assert.ok(me, '自己应出现在参与方列表里');
|
||||
assert.equal(me.is_self, true);
|
||||
assert.equal(parts.filter(p => p.is_self).length, 1);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
|
||||
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
|
||||
const parts = participantsOfMail(ccMail, 'dsh');
|
||||
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 无抄送时只有两方', () => {
|
||||
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
});
|
||||
|
||||
test('participantsOfMail: 跳过空名字条目', () => {
|
||||
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
|
||||
const mail = {
|
||||
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
|
||||
cc_list: [{ name: '', path: '/x' }, {}],
|
||||
session_alias: 'a',
|
||||
};
|
||||
const parts = participantsOfMail(mail, 'dsh');
|
||||
assert.equal(parts.length, 2);
|
||||
for (const p of parts) assert.notEqual(p.address, '');
|
||||
});
|
||||
81
plugins/pi-mail-bridge/test/catchup.test.mjs
Normal file
81
plugins/pi-mail-bridge/test/catchup.test.mjs
Normal file
@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js';
|
||||
|
||||
const mail = (over = {}) => ({
|
||||
mail_id: 'm1',
|
||||
session_id: 's1',
|
||||
from_name: 'admin',
|
||||
subject: '主题',
|
||||
mail_type: 'normal',
|
||||
to_workspace: '/tmp/ws',
|
||||
...over,
|
||||
});
|
||||
|
||||
test('mailToEvent 产出与 SSE new_mail 同形的对象', () => {
|
||||
const ev = mailToEvent(mail());
|
||||
// 投递侧读的就是这几个键,形状不一致会让补拉那条路径静默地少带信息
|
||||
for (const k of ['mail_id', 'session_id', 'from_name', 'subject', 'mail_type', 'to_workspace']) {
|
||||
assert.ok(k in ev, `缺少 ${k}`);
|
||||
}
|
||||
assert.equal(ev.role, 'to');
|
||||
assert.equal(ev.catchup, true);
|
||||
});
|
||||
|
||||
test('mailToEvent 对缺字段的行给出空串而非 undefined', () => {
|
||||
const ev = mailToEvent({});
|
||||
assert.equal(ev.mail_id, '');
|
||||
assert.equal(ev.to_workspace, '');
|
||||
assert.equal(ev.mail_type, 'normal');
|
||||
});
|
||||
|
||||
test('已经通过 SSE 投过的不再补投', () => {
|
||||
const mails = [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })];
|
||||
const got = selectCatchup(mails, new Set(['a']));
|
||||
assert.deepEqual(got.map(e => e.mail_id), ['b']);
|
||||
});
|
||||
|
||||
test('按时间正序补投(收件箱是倒序返回的)', () => {
|
||||
// 收件箱:新的在前
|
||||
const mails = [mail({ mail_id: 'new' }), mail({ mail_id: 'mid' }), mail({ mail_id: 'old' })];
|
||||
const got = selectCatchup(mails, new Set());
|
||||
assert.deepEqual(
|
||||
got.map(e => e.mail_id),
|
||||
['old', 'mid', 'new'],
|
||||
'先来的邮件必须先处理,否则同一会话里的上下文顺序是乱的',
|
||||
);
|
||||
});
|
||||
|
||||
test('permission 类邮件不补投', () => {
|
||||
const mails = [mail({ mail_id: 'p', mail_type: 'permission' }), mail({ mail_id: 'n' })];
|
||||
const got = selectCatchup(mails, new Set());
|
||||
assert.deepEqual(got.map(e => e.mail_id), ['n']);
|
||||
});
|
||||
|
||||
test('超过上限的部分留在收件箱里', () => {
|
||||
const mails = Array.from({ length: MAX_CATCHUP + 4 }, (_, i) => mail({ mail_id: 'm' + i }));
|
||||
const got = selectCatchup(mails, new Set());
|
||||
assert.equal(got.length, MAX_CATCHUP, '一次补拉不该把几十封邮件同时放出去');
|
||||
});
|
||||
|
||||
test('上限可显式压到 0(用于禁用补拉)', () => {
|
||||
const got = selectCatchup([mail()], new Set(), 0);
|
||||
assert.deepEqual(got, []);
|
||||
});
|
||||
|
||||
test('空输入与非数组不炸', () => {
|
||||
assert.deepEqual(selectCatchup([], new Set()), []);
|
||||
assert.deepEqual(selectCatchup(undefined, new Set()), []);
|
||||
assert.deepEqual(selectCatchup(null, new Set()), []);
|
||||
});
|
||||
|
||||
test('没有 mail_id 的行跳过', () => {
|
||||
const got = selectCatchup([mail({ mail_id: '' }), mail({ mail_id: 'ok' })], new Set());
|
||||
assert.deepEqual(got.map(e => e.mail_id), ['ok']);
|
||||
});
|
||||
|
||||
test('seen 传 undefined 时不去重也不报错', () => {
|
||||
const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
|
||||
assert.deepEqual(got.map(e => e.mail_id), ['x']);
|
||||
});
|
||||
218
plugins/pi-mail-bridge/test/discovery.test.mjs
Normal file
218
plugins/pi-mail-bridge/test/discovery.test.mjs
Normal file
@ -0,0 +1,218 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
|
||||
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
|
||||
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
|
||||
|
||||
test('renderNameSuggestions 只给名字并指向下一步', () => {
|
||||
// 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」——
|
||||
// 那不一定是调用方想要的那条。
|
||||
const got = renderNameSuggestions(['opencode', 'admin']);
|
||||
assert.match(got, /opencode/);
|
||||
assert.match(got, /admin/);
|
||||
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
|
||||
});
|
||||
|
||||
test('renderNameSuggestions 空列表给明确文案', () => {
|
||||
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
|
||||
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
|
||||
// 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||||
const got = renderPathSuggestions([], 'admin');
|
||||
assert.match(got, /可以留空/);
|
||||
assert.match(got, /admin/);
|
||||
});
|
||||
|
||||
test('renderPathSuggestions 列出目录并指向下一步', () => {
|
||||
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
|
||||
assert.match(got, /\/home\/program\/agentmail/);
|
||||
assert.match(got, /最近使用/);
|
||||
assert.match(got, /suggest_address\(name="opencode", path="/);
|
||||
});
|
||||
|
||||
const sessionData = {
|
||||
kind: 'session',
|
||||
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
|
||||
addresses: [
|
||||
'opencode@/home.silent-harbor',
|
||||
'opencode@/home.happy-tiger',
|
||||
'opencode@/home.new',
|
||||
],
|
||||
candidates: [
|
||||
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
|
||||
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
|
||||
{ alias: 'new', title: '新建会话', source: 'new' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
|
||||
// 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions
|
||||
// 同序由服务端保证,直接用。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /opencode@\/home\.happy-tiger/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 带出标题与未读数', () => {
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
assert.match(got, /联调 llmsproxy/);
|
||||
assert.match(got, /2 封未读/);
|
||||
});
|
||||
|
||||
test('不变量:new 不与已存在会话混列,且带警告', () => {
|
||||
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||||
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
|
||||
const lines = got.split('\n');
|
||||
const newLineIdx = lines.findIndex(l => l.includes('.new'));
|
||||
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
|
||||
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
|
||||
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
|
||||
});
|
||||
|
||||
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
|
||||
// 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。
|
||||
// 不传的话服务端会自动命名,但模型不知道那个名字。
|
||||
const got = renderSessionSuggestions(
|
||||
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
|
||||
'dsh', '/tmp',
|
||||
);
|
||||
assert.match(got, /还没有可续谈的会话/);
|
||||
assert.match(got, /session_alias/);
|
||||
});
|
||||
|
||||
const participantData = {
|
||||
session_id: 'f3d824ce',
|
||||
session_alias: 'silent-harbor',
|
||||
participants: [
|
||||
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
|
||||
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
|
||||
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderParticipants 给出每个参与方的地址', () => {
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor/);
|
||||
assert.match(got, /admin@\.silent-harbor/);
|
||||
assert.match(got, /原样填进 send_mail 的 to/);
|
||||
});
|
||||
|
||||
test('不变量:标出「尚未回应」的人', () => {
|
||||
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
|
||||
// 正是为了让这个判断成立。
|
||||
const got = renderParticipants(participantData);
|
||||
const line = got.split('\n').find(l => l.includes('opencode'));
|
||||
assert.match(line, /尚未回应/);
|
||||
// 自己不该被标「尚未回应」—— 自己正在处理这封
|
||||
const selfLine = got.split('\n').find(l => l.includes('dsh'));
|
||||
assert.ok(!selfLine.includes('尚未回应'));
|
||||
assert.match(selfLine, /就是你/);
|
||||
});
|
||||
|
||||
test('renderParticipants 用中文角色标签', () => {
|
||||
// 模型读到「抄送方」比读到 cc 更容易判对分工。
|
||||
const got = renderParticipants(participantData);
|
||||
assert.match(got, /抄送方/);
|
||||
assert.match(got, /发件人/);
|
||||
});
|
||||
|
||||
test('renderParticipants 无地址时说明原因', () => {
|
||||
const got = renderParticipants({
|
||||
session_alias: '',
|
||||
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
|
||||
});
|
||||
assert.match(got, /尚未命名/);
|
||||
});
|
||||
|
||||
test('renderParticipants 空会话不崩', () => {
|
||||
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
|
||||
assert.match(renderParticipants({}), /还没有参与方/);
|
||||
});
|
||||
|
||||
test('renderContacts 未读优先排序', () => {
|
||||
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
|
||||
const got = renderContacts({
|
||||
contacts: [
|
||||
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
|
||||
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
|
||||
],
|
||||
});
|
||||
const lines = got.split('\n').filter(l => l.startsWith('- '));
|
||||
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
|
||||
assert.match(lines[0], /3 封未读/);
|
||||
});
|
||||
|
||||
test('renderContacts 带出剩余预算', () => {
|
||||
const got = renderContacts({
|
||||
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
|
||||
});
|
||||
assert.match(got, /剩 3\/20 个来回/);
|
||||
});
|
||||
|
||||
test('renderContacts 未命名会话说明只能 reply_to', () => {
|
||||
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
|
||||
assert.match(got, /reply_to/);
|
||||
});
|
||||
|
||||
test('renderContacts 空列表', () => {
|
||||
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
|
||||
});
|
||||
|
||||
const threadData = {
|
||||
anchor_mail_id: 'm-2',
|
||||
total: 3,
|
||||
hidden: 1,
|
||||
nodes: [
|
||||
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
|
||||
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
|
||||
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
|
||||
],
|
||||
};
|
||||
|
||||
test('renderThread 用缩进表示层级', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const lines = got.split('\n');
|
||||
const l1 = lines.find(l => l.includes('m-1'));
|
||||
const l2 = lines.find(l => l.includes('m-2'));
|
||||
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
|
||||
});
|
||||
|
||||
test('不变量:detached 必须标出来', () => {
|
||||
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
const line = got.split('\n').find(l => l.includes('m-3'));
|
||||
assert.match(line, /父邮件无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 标出自己发的与当前这封', () => {
|
||||
const got = renderThread(threadData, 'dsh');
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
|
||||
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
|
||||
});
|
||||
|
||||
test('renderThread 报告不可见数量', () => {
|
||||
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
|
||||
// 不说的话模型会以为自己看到了全貌。
|
||||
assert.match(renderThread(threadData), /另有 1 封无权查看/);
|
||||
});
|
||||
|
||||
test('renderThread 有更多时给出 offset', () => {
|
||||
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
|
||||
assert.match(got, /offset=60/);
|
||||
});
|
||||
|
||||
test('renderThread 空线索不崩', () => {
|
||||
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
|
||||
assert.match(renderThread({}), /没有可见的邮件/);
|
||||
});
|
||||
266
plugins/pi-mail-bridge/test/inbox-format.test.mjs
Normal file
266
plugins/pi-mail-bridge/test/inbox-format.test.mjs
Normal file
@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 收件箱渲染与已读策略的测试。
|
||||
*
|
||||
* 每条断言都对应一次真实的错误行为(见 lib/inbox-format.js 里的注释):
|
||||
* 漏掉 attachment_id 模型就无从下载附件;漏掉抄送它会以为这是私信;
|
||||
* status=all 时标记已读会让下一轮的新邮件混在历史里认不出来。
|
||||
*
|
||||
* node --test 'test/*.test.mjs'
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatSize,
|
||||
renderMail,
|
||||
renderInbox,
|
||||
idsToMarkRead,
|
||||
DEFAULT_INBOX_STATUS,
|
||||
DEFAULT_INBOX_LIMIT,
|
||||
} from '../lib/inbox-format.js';
|
||||
|
||||
const mail = (over = {}) => ({
|
||||
mail_id: 'm-1',
|
||||
from_name: 'admin',
|
||||
subject: '缓存选型',
|
||||
status: 'unread',
|
||||
session_alias: 'brisk-harbor',
|
||||
body_preview: '我们需要评估一下缓存层',
|
||||
...over,
|
||||
});
|
||||
|
||||
// ─── formatSize ───
|
||||
|
||||
test('formatSize 分档', () => {
|
||||
assert.equal(formatSize(512), '512 B');
|
||||
assert.equal(formatSize(2048), '2.0 KB');
|
||||
assert.equal(formatSize(3 * 1024 * 1024), '3.0 MB');
|
||||
});
|
||||
|
||||
test('formatSize 容错', () => {
|
||||
assert.equal(formatSize(undefined), '?');
|
||||
assert.equal(formatSize(NaN), '?');
|
||||
assert.equal(formatSize('x'), '?');
|
||||
});
|
||||
|
||||
// ─── renderMail ───
|
||||
|
||||
test('renderMail 带出 mail_id 与会话别名', () => {
|
||||
const got = renderMail(mail());
|
||||
assert.match(got, /邮件 ID: m-1/);
|
||||
assert.match(got, /#brisk-harbor/);
|
||||
assert.match(got, /admin: 缓存选型/);
|
||||
});
|
||||
|
||||
test('无别名时显示「未命名」而不是空', () => {
|
||||
const got = renderMail(mail({ session_alias: '' }));
|
||||
assert.match(got, /#未命名/);
|
||||
});
|
||||
|
||||
test('不变量:附件必须带 attachment_id', () => {
|
||||
// 只说「有附件」模型就无从下载 —— download_attachment 要的正是这个 id。
|
||||
const got = renderMail(mail({
|
||||
attachments: [{ filename: 'report.md', size_bytes: 2048, attachment_id: 'att-9' }],
|
||||
}));
|
||||
assert.match(got, /id=att-9/, `附件行缺 id:${got}`);
|
||||
assert.match(got, /report\.md/);
|
||||
assert.match(got, /2\.0 KB/);
|
||||
assert.match(got, /download_attachment/, '要提示模型用哪个工具下载');
|
||||
});
|
||||
|
||||
test('多个附件都列出来', () => {
|
||||
const got = renderMail(mail({
|
||||
attachments: [
|
||||
{ filename: 'a.md', size_bytes: 10, attachment_id: 'att-1' },
|
||||
{ filename: 'b.md', size_bytes: 20, attachment_id: 'att-2' },
|
||||
],
|
||||
}));
|
||||
assert.match(got, /att-1/);
|
||||
assert.match(got, /att-2/);
|
||||
});
|
||||
|
||||
test('不变量:抄送人要显示出来', () => {
|
||||
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
|
||||
const got = renderMail(mail({
|
||||
cc_list: [{ name: 'opencode', raw: 'opencode@/home.new' }],
|
||||
}));
|
||||
assert.match(got, /抄送/);
|
||||
assert.match(got, /opencode@\/home\.new/, '应优先用 raw(带路径与会话段)');
|
||||
});
|
||||
|
||||
test('无抄送时不出现抄送行', () => {
|
||||
assert.ok(!renderMail(mail()).includes('抄送'));
|
||||
assert.ok(!renderMail(mail({ cc_list: [] })).includes('抄送'));
|
||||
});
|
||||
|
||||
test('正文优先取 body_preview,缺失时退回 body', () => {
|
||||
assert.match(renderMail(mail({ body_preview: '预览', body: '全文' })), /内容: 预览/);
|
||||
assert.match(renderMail(mail({ body_preview: '', body: '全文' })), /内容: 全文/);
|
||||
});
|
||||
|
||||
test('正文按 bodyLimit 截断', () => {
|
||||
const got = renderMail(mail({ body_preview: 'x'.repeat(500) }), 50);
|
||||
const line = got.split('\n').find(l => l.startsWith('内容: '));
|
||||
assert.equal(line.length, '内容: '.length + 50);
|
||||
});
|
||||
|
||||
test('renderMail 容错:字段全缺不崩', () => {
|
||||
const got = renderMail({});
|
||||
assert.match(got, /unknown/);
|
||||
const got2 = renderMail(undefined);
|
||||
assert.equal(typeof got2, 'string');
|
||||
});
|
||||
|
||||
test('附件字段不是数组时忽略', () => {
|
||||
const got = renderMail(mail({ attachments: 'oops', cc_list: 'oops' }));
|
||||
assert.ok(!got.includes('附件:'));
|
||||
assert.ok(!got.includes('抄送'));
|
||||
});
|
||||
|
||||
// ─── 收件人与身份(只有知道自己是谁才能判定)───
|
||||
|
||||
test('不变量:收件人要显示出来', () => {
|
||||
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
|
||||
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
|
||||
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
|
||||
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
|
||||
});
|
||||
|
||||
test('收件人无工作目录时只显名字', () => {
|
||||
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
|
||||
assert.match(got, /收件人: admin$/m);
|
||||
});
|
||||
|
||||
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
|
||||
const got = renderMail(mail({ to_name: 'dsh' }));
|
||||
assert.ok(!got.includes('你的身份'));
|
||||
});
|
||||
|
||||
test('不变量:区分收件人与抄送方身份', () => {
|
||||
// 两者职责不同。不区分的话两方都会以为自己是负责人,
|
||||
// 或者都以为自己只是旁观者。
|
||||
const m = mail({
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
|
||||
});
|
||||
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
|
||||
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
|
||||
// 不相关的名字不编造身份
|
||||
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
|
||||
});
|
||||
|
||||
// ─── 可投递地址(「精准发信」的关键)───
|
||||
|
||||
const joint = () => mail({
|
||||
mail_id: 'm-7',
|
||||
from_name: 'admin',
|
||||
to_name: 'dsh',
|
||||
to_workspace: '/home/program/llmsproxy',
|
||||
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
|
||||
session_alias: 'silent-harbor',
|
||||
});
|
||||
|
||||
test('不变量:给出每个参与方的可投递地址', () => {
|
||||
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`,
|
||||
// 而那个地址回过去只会再建一条平行会话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /可投递地址/);
|
||||
assert.match(got, /opencode@\/home\.silent-harbor(抄送方)/);
|
||||
assert.match(got, /admin@\.silent-harbor(发件人)/);
|
||||
});
|
||||
|
||||
test('不变量:可投递地址里绝不出现 .new', () => {
|
||||
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
|
||||
// 把它当回信地址会让双方各说各话。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(line, '应有可投递地址行');
|
||||
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
|
||||
});
|
||||
|
||||
test('可投递地址不列自己', () => {
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
|
||||
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
|
||||
});
|
||||
|
||||
test('同时给出 reply_to 这条更稳的路', () => {
|
||||
// 地址可能拼错,reply_to 不会 —— 两条路都告诉模型。
|
||||
const got = renderMail(joint(), 200, 'dsh');
|
||||
assert.match(got, /reply_to=m-7/);
|
||||
});
|
||||
|
||||
test('无会话别名时不给地址(宁可不给不可给错)', () => {
|
||||
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
|
||||
// 实际指向默认会话的地址,比不给危险。
|
||||
const got = renderMail(mail({
|
||||
to_name: 'dsh', session_alias: '',
|
||||
cc_list: [{ name: 'opencode', path: '/home' }],
|
||||
}), 200, 'dsh');
|
||||
assert.ok(!got.includes('可投递地址'));
|
||||
});
|
||||
|
||||
test('renderInbox 透传 selfName', () => {
|
||||
const got = renderInbox([joint()], 200, 'opencode');
|
||||
assert.match(got, /你的身份: 抄送方/);
|
||||
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor(收件人)/);
|
||||
});
|
||||
|
||||
// ─── renderInbox ───
|
||||
|
||||
test('renderInbox 空收件箱给明确文案', () => {
|
||||
assert.equal(renderInbox([]), '收件箱为空。');
|
||||
assert.equal(renderInbox(undefined), '收件箱为空。');
|
||||
assert.equal(renderInbox(null), '收件箱为空。');
|
||||
});
|
||||
|
||||
test('renderInbox 用空行分隔多封', () => {
|
||||
const got = renderInbox([mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
|
||||
assert.match(got, /邮件 ID: a[\s\S]*\n\n[\s\S]*邮件 ID: b/);
|
||||
});
|
||||
|
||||
// ─── idsToMarkRead ───
|
||||
|
||||
test('不变量:只标本次列出的那些', () => {
|
||||
// limit 之外的还没看过,一并标掉等于让它们凭空消失。
|
||||
const ids = idsToMarkRead('unread', [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
|
||||
assert.deepEqual(ids, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('不变量:status=all 时不标记', () => {
|
||||
// 那是「回顾历史」的读法。把历史邮件标成已读会让下一轮真正的新邮件
|
||||
// 混在里面认不出来。
|
||||
assert.deepEqual(idsToMarkRead('all', [mail({ mail_id: 'a' })]), []);
|
||||
});
|
||||
|
||||
test('status 省略时按默认(unread)标记', () => {
|
||||
assert.deepEqual(idsToMarkRead(undefined, [mail({ mail_id: 'a' })]), ['a']);
|
||||
});
|
||||
|
||||
test('idsToMarkRead 过滤掉无 id 的条目', () => {
|
||||
const ids = idsToMarkRead('unread', [
|
||||
mail({ mail_id: 'a' }),
|
||||
mail({ mail_id: '' }),
|
||||
mail({ mail_id: undefined }),
|
||||
{ },
|
||||
]);
|
||||
assert.deepEqual(ids, ['a']);
|
||||
});
|
||||
|
||||
test('idsToMarkRead 容错非数组', () => {
|
||||
assert.deepEqual(idsToMarkRead('unread', undefined), []);
|
||||
assert.deepEqual(idsToMarkRead('unread', 'oops'), []);
|
||||
});
|
||||
|
||||
// ─── 默认值 ───
|
||||
|
||||
test('默认只看未读', () => {
|
||||
// 默认 all 会让模型每轮重读旧邮件,把处理过的和新来的混在一起。
|
||||
assert.equal(DEFAULT_INBOX_STATUS, 'unread');
|
||||
});
|
||||
|
||||
test('默认条数是个小数字', () => {
|
||||
// 收件箱一次给几十封会把上下文塞满,而模型一轮通常只处理一两封。
|
||||
assert.ok(DEFAULT_INBOX_LIMIT > 0 && DEFAULT_INBOX_LIMIT <= 10);
|
||||
});
|
||||
248
plugins/pi-mail-bridge/test/model-scope.test.mjs
Normal file
248
plugins/pi-mail-bridge/test/model-scope.test.mjs
Normal file
@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 模型范围与降级尝试的测试。
|
||||
*
|
||||
* 最要紧的一条:范围为空时必须返回 `[undefined]`(试一次平台默认)而不是 `[]`。
|
||||
* 返回空数组会让调用方一次都不试,等于「管理员没配」就把 Agent 彻底哑掉。
|
||||
*
|
||||
* node --test 'test/*.test.mjs'
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeModels,
|
||||
snapshotDshModels,
|
||||
snapshotPiModels,
|
||||
modelAttemptOrder,
|
||||
renderFailureReport,
|
||||
MAX_CATALOG,
|
||||
} from '../lib/model-scope.js';
|
||||
|
||||
// ─── opencode 目录 ───
|
||||
|
||||
const ocConfig = {
|
||||
providers: [
|
||||
{
|
||||
id: 'llmsproxy',
|
||||
models: {
|
||||
AUTO: { name: 'AUTO (smart routing)' },
|
||||
'claude-sonnet-4-6': { name: 'claude-sonnet-4-6' },
|
||||
},
|
||||
},
|
||||
{ id: 'huawei', models: { 'deepseek-v4-flash': { name: 'dpkv4' } } },
|
||||
],
|
||||
};
|
||||
|
||||
test('opencode 目录拍平 provider × model', () => {
|
||||
const got = snapshotOpencodeModels(ocConfig);
|
||||
assert.equal(got.length, 3);
|
||||
assert.deepEqual(got[0], {
|
||||
provider: 'llmsproxy',
|
||||
model: 'AUTO',
|
||||
display_name: 'AUTO (smart routing)',
|
||||
});
|
||||
});
|
||||
|
||||
test('models 是对象而非数组(键是 model id)', () => {
|
||||
// 实测 opencode 的 /config/providers 返回 { models: { "AUTO": {...} } }。
|
||||
// 当成数组处理会得到零条目而不是报错。
|
||||
const got = snapshotOpencodeModels(ocConfig);
|
||||
assert.ok(got.some(m => m.model === 'claude-sonnet-4-6'));
|
||||
});
|
||||
|
||||
test('无 id 的 provider 被跳过', () => {
|
||||
const got = snapshotOpencodeModels({
|
||||
providers: [{ models: { a: {} } }, { id: 'ok', models: { b: {} } }],
|
||||
});
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].provider, 'ok');
|
||||
});
|
||||
|
||||
test('缺 name 时 display_name 为空串而不是 undefined', () => {
|
||||
const got = snapshotOpencodeModels({ providers: [{ id: 'p', models: { m: {} } }] });
|
||||
assert.equal(got[0].display_name, '');
|
||||
});
|
||||
|
||||
test('opencode 目录容错:结构缺失不崩', () => {
|
||||
assert.deepEqual(snapshotOpencodeModels(undefined), []);
|
||||
assert.deepEqual(snapshotOpencodeModels({}), []);
|
||||
assert.deepEqual(snapshotOpencodeModels({ providers: 'oops' }), []);
|
||||
assert.deepEqual(snapshotOpencodeModels({ providers: [{ id: 'p', models: null }] }), []);
|
||||
});
|
||||
|
||||
// ─── DSH 目录 ───
|
||||
|
||||
test('DSH 目录用 provider + id', () => {
|
||||
const got = snapshotDshModels([
|
||||
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
|
||||
{ provider: 'deepseek', id: 'chat', name: 'DeepSeek Chat' },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.deepEqual(got[1], { provider: 'deepseek', model: 'chat', display_name: 'DeepSeek Chat' });
|
||||
});
|
||||
|
||||
test('DSH 目录跳过缺 provider 或 id 的条目', () => {
|
||||
const got = snapshotDshModels([
|
||||
{ provider: '', id: 'x' },
|
||||
{ provider: 'p', id: '' },
|
||||
{ provider: 'p', id: 'ok' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].model, 'ok');
|
||||
});
|
||||
|
||||
test('重复的 provider/model 组合去重', () => {
|
||||
const got = snapshotDshModels([
|
||||
{ provider: 'p', id: 'm', name: '第一次' },
|
||||
{ provider: 'p', id: 'm', name: '第二次' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].display_name, '第一次');
|
||||
});
|
||||
|
||||
test('目录截断到 MAX_CATALOG', () => {
|
||||
const many = Array.from({ length: MAX_CATALOG + 20 }, (_, i) => ({
|
||||
provider: 'p', id: `m${i}`, name: `M${i}`,
|
||||
}));
|
||||
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── pi 目录 ───
|
||||
|
||||
test('pi 目录用 provider + id', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
|
||||
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.deepEqual(got[1], {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
display_name: 'Claude Sonnet 4.6',
|
||||
});
|
||||
});
|
||||
|
||||
test('pi 目录跳过缺 provider 或 id 的条目', () => {
|
||||
const got = snapshotPiModels([
|
||||
{ provider: '', id: 'x' },
|
||||
{ provider: 'p' },
|
||||
{ provider: 'p', id: 'ok' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].model, 'ok');
|
||||
});
|
||||
|
||||
test('pi 目录容错:非数组不崩', () => {
|
||||
assert.deepEqual(snapshotPiModels(undefined), []);
|
||||
assert.deepEqual(snapshotPiModels(null), []);
|
||||
assert.deepEqual(snapshotPiModels('oops'), []);
|
||||
});
|
||||
|
||||
test('pi 目录同样受 MAX_CATALOG 截断', () => {
|
||||
// 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。
|
||||
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
|
||||
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
|
||||
provider: 'p', id: `m${i}`, name: `M${i}`,
|
||||
}));
|
||||
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
|
||||
});
|
||||
|
||||
// ─── modelAttemptOrder ───
|
||||
|
||||
test('管理员划定范围时按 rank 顺序尝试', () => {
|
||||
const got = modelAttemptOrder(
|
||||
[{ provider: 'a', model: '1' }, { provider: 'b', model: '2' }],
|
||||
{ provider: 'env', model: 'x' }
|
||||
);
|
||||
assert.deepEqual(got, [
|
||||
{ provider: 'a', model: '1' },
|
||||
{ provider: 'b', model: '2' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('不变量:范围为空时返回 [undefined] 而不是 []', () => {
|
||||
// 返回空数组会让调用方一次都不试 —— 「管理员没配」的正确含义是不限定,
|
||||
// 不是「一个都不许用」。后者等于让 Agent 彻底哑掉。
|
||||
const got = modelAttemptOrder([], undefined);
|
||||
assert.equal(got.length, 1, `应有一次尝试,实际 ${got.length}`);
|
||||
assert.equal(got[0], undefined, 'undefined 表示交给平台自己选');
|
||||
});
|
||||
|
||||
test('范围为空但有环境变量时用环境变量', () => {
|
||||
const got = modelAttemptOrder([], { provider: 'llmsproxy', model: 'AUTO' });
|
||||
assert.deepEqual(got, [{ provider: 'llmsproxy', model: 'AUTO' }]);
|
||||
});
|
||||
|
||||
test('不变量:范围优先于环境变量', () => {
|
||||
// 范围是运行时可改的策略,环境变量是部署时的兜底。
|
||||
// 反过来的话管理员在配置页改了范围却不生效,得去改 service 文件重启。
|
||||
const got = modelAttemptOrder(
|
||||
[{ provider: 'chosen', model: 'm' }],
|
||||
{ provider: 'env', model: 'x' }
|
||||
);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].provider, 'chosen');
|
||||
});
|
||||
|
||||
test('过滤掉范围里字段不全的项', () => {
|
||||
const got = modelAttemptOrder(
|
||||
[{ provider: 'a', model: '' }, { provider: '', model: '1' }, { provider: 'ok', model: 'm' }],
|
||||
undefined
|
||||
);
|
||||
assert.deepEqual(got, [{ provider: 'ok', model: 'm' }]);
|
||||
});
|
||||
|
||||
test('环境变量只给一半时不采用', () => {
|
||||
assert.deepEqual(modelAttemptOrder([], { provider: 'p' }), [undefined]);
|
||||
assert.deepEqual(modelAttemptOrder([], { model: 'm' }), [undefined]);
|
||||
});
|
||||
|
||||
test('modelAttemptOrder 容错非数组', () => {
|
||||
assert.deepEqual(modelAttemptOrder(undefined, undefined), [undefined]);
|
||||
assert.deepEqual(modelAttemptOrder('oops', undefined), [undefined]);
|
||||
});
|
||||
|
||||
// ─── renderFailureReport ───
|
||||
|
||||
test('失败报告列出每次尝试的路由与原因', () => {
|
||||
const got = renderFailureReport(
|
||||
[
|
||||
{ provider: 'llmsproxy', model: 'AUTO', error: '429 Too Many Requests' },
|
||||
{ provider: 'huawei', model: 'dpk', error: 'connect ECONNREFUSED' },
|
||||
],
|
||||
'缓存选型'
|
||||
);
|
||||
assert.match(got, /缓存选型/);
|
||||
assert.match(got, /已尝试 2 个/);
|
||||
assert.match(got, /llmsproxy\/AUTO/);
|
||||
assert.match(got, /429 Too Many Requests/);
|
||||
assert.match(got, /huawei\/dpk/);
|
||||
assert.match(got, /ECONNREFUSED/);
|
||||
});
|
||||
|
||||
test('没有路由信息时标为平台默认模型', () => {
|
||||
const got = renderFailureReport([{ error: 'boom' }], '主题');
|
||||
assert.match(got, /平台默认模型/);
|
||||
});
|
||||
|
||||
test('失败报告给出可操作的下一步', () => {
|
||||
// 只报错误不说怎么办,收信的人只能来问。
|
||||
const got = renderFailureReport([{ error: 'x' }], '主题');
|
||||
assert.match(got, /配置页/);
|
||||
});
|
||||
|
||||
test('多行报错缩进后不破坏 Markdown 排版', () => {
|
||||
const got = renderFailureReport([{ error: 'line1\nline2' }], '主题');
|
||||
// 第二行也要带缩进,否则会脱离代码块、其中的字符被当作 Markdown 解析
|
||||
assert.match(got, / line1\n line2/);
|
||||
});
|
||||
|
||||
test('空主题有兜底', () => {
|
||||
assert.match(renderFailureReport([{ error: 'x' }], ''), /\(无主题\)/);
|
||||
assert.match(renderFailureReport([{ error: 'x' }], undefined), /\(无主题\)/);
|
||||
});
|
||||
|
||||
test('renderFailureReport 容错非数组', () => {
|
||||
const got = renderFailureReport(undefined, '主题');
|
||||
assert.match(got, /已尝试 0 个/);
|
||||
});
|
||||
185
plugins/pi-mail-bridge/test/naming.test.mjs
Normal file
185
plugins/pi-mail-bridge/test/naming.test.mjs
Normal file
@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 会话命名一致性的测试。
|
||||
*
|
||||
* 这是 pi 接入里最容易做错的一块,因为「两边各自命名然后指望撞上」看起来能用:
|
||||
* 单条会话、不撞名、没人手工改过名时,两边确实一致。上面任何一条不成立就分叉。
|
||||
*
|
||||
* 因此这里的每个 test 都对应一条**分叉场景**。
|
||||
*
|
||||
* node --test 'test/*.test.mjs'
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planNamingSync, planWriteBack } from '../src/naming.mjs';
|
||||
|
||||
// ─── 向 Gateway 提交(W-7)───
|
||||
|
||||
test('平台名字正常时派生别名并原样带标题', () => {
|
||||
const got = planNamingSync({ platformName: '排查连接泄漏', mailSubject: '别用我', lastSynced: '' });
|
||||
assert.equal(got.skip, false);
|
||||
assert.equal(got.alias, '排查连接泄漏');
|
||||
assert.equal(got.title, '排查连接泄漏');
|
||||
assert.equal(got.source, 'platform');
|
||||
});
|
||||
|
||||
test('不变量:标题原样提交,不清洗不派生', () => {
|
||||
// I-4:插件只搬运。标题那一列不负责寻址,没有字符限制,
|
||||
// 改写它等于让邮箱里显示的和 pi 里显示的是两个东西。
|
||||
const name = 'Fix: cache TTL (v2) — 缓存/过期';
|
||||
const got = planNamingSync({ platformName: name, mailSubject: '', lastSynced: '' });
|
||||
assert.equal(got.title, name);
|
||||
// 别名要按寻址规则剥掉分隔符
|
||||
assert.doesNotMatch(got.alias, /[.@/]/);
|
||||
});
|
||||
|
||||
test('不变量:内容没变就不重复提交', () => {
|
||||
// setSessionName 会触发 session_info_changed,钩子又去 sync,
|
||||
// 不判「与上次相同」就是自激循环 —— 每 30 秒刷一次 Gateway。
|
||||
const first = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: '' });
|
||||
assert.equal(first.skip, false);
|
||||
const again = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: first.signature });
|
||||
assert.equal(again.skip, true);
|
||||
assert.equal(again.reason, 'unchanged');
|
||||
});
|
||||
|
||||
test('不变量:pi 没有名字时退到邮件主题(SDK 路径的常态)', () => {
|
||||
// 桥用 SDK 起的会话不经过 pi-web 的标题生成器,sessionName 一直是 undefined。
|
||||
// 只等平台命名的话别名永远是空的,`name@path.<别名>` 续谈无从下手 ——
|
||||
// 第一次端到端跑通时就是这个结果(sessions.session_alias 是空串)。
|
||||
const got = planNamingSync({ platformName: undefined, mailSubject: '主链路验证', lastSynced: '' });
|
||||
assert.equal(got.skip, false);
|
||||
assert.equal(got.alias, '主链路验证');
|
||||
assert.equal(got.source, 'mail-subject');
|
||||
});
|
||||
|
||||
test('不变量:无名字 + 主题未变时也要判 unchanged', () => {
|
||||
// 用平台名字本身充当「上次提交了什么」的记录时,名字为空就无法区分
|
||||
// 「还没提交过」与「提交过、内容没变」,于是每轮心跳都白打一次 sync。
|
||||
const first = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: '' });
|
||||
const again = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: first.signature });
|
||||
assert.equal(again.skip, true, '空名字场景同样要能判出「没变化」');
|
||||
});
|
||||
|
||||
test('思维链泄漏的名字退到邮件主题派生别名', () => {
|
||||
// pi-web 的标题生成器不防这个(cleanSessionName 只取首行 + 截 60 字符)。
|
||||
// 本机 81 条会话里实测捞到过这条。
|
||||
const got = planNamingSync({
|
||||
platformName: 'The user is asking me to generate a title for a coding-agent',
|
||||
mailSubject: '排查连接泄漏',
|
||||
lastSynced: '',
|
||||
});
|
||||
assert.equal(got.skip, false);
|
||||
assert.equal(got.alias, '排查连接泄漏');
|
||||
assert.equal(got.source, 'mail-subject');
|
||||
});
|
||||
|
||||
test('不变量:退到邮件主题时不写标题', () => {
|
||||
// 标题那一列的语义是「平台生成的会话标题」。把邮件主题填进去会让
|
||||
// 邮箱里看起来像是 pi 生成了这个标题,而 pi 侧其实是另一个名字(或没有)。
|
||||
const got = planNamingSync({
|
||||
platformName: '我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试。简短:测试。或者更简',
|
||||
mailSubject: '压测报告',
|
||||
lastSynced: '',
|
||||
});
|
||||
assert.equal(got.title, '');
|
||||
assert.equal(got.alias, '压测报告');
|
||||
});
|
||||
|
||||
test('不变量:无可用名字时什么都不写(W-7.2)', () => {
|
||||
// 宁可让会话保持无别名(session_alias 允许 NULL),也不要写 "session-123"
|
||||
// 这种占位值 —— 它对人毫无指代作用,而且一旦落库就把 alias 位占住了,
|
||||
// 真正的名字来了只能追 -2 后缀。
|
||||
const got = planNamingSync({ platformName: '', mailSubject: '', lastSynced: '' });
|
||||
assert.equal(got.skip, true);
|
||||
assert.equal(got.reason, 'no-usable-name');
|
||||
});
|
||||
|
||||
test('纯符号名字:写标题但不写别名', () => {
|
||||
// slugFromTitle('...') 是空串,作为别名非法(Gateway 会 400),
|
||||
// 但这个名字本身是平台产出,标题列该照实反映。
|
||||
const got = planNamingSync({ platformName: '...', mailSubject: '', lastSynced: '' });
|
||||
assert.equal(got.skip, false);
|
||||
assert.equal(got.alias, '');
|
||||
assert.equal(got.title, '...');
|
||||
assert.equal(got.source, 'platform-title-only');
|
||||
});
|
||||
|
||||
test('主题也派生不出别名时不写', () => {
|
||||
const got = planNamingSync({ platformName: 'The user is asking me to', mailSubject: '@@@', lastSynced: '' });
|
||||
assert.equal(got.skip, true);
|
||||
});
|
||||
|
||||
// ─── 回写进 pi(D-5 / 一致性的关键)───
|
||||
|
||||
test('不变量:撞名后缀必须回写进 pi', () => {
|
||||
// Gateway 侧别名负有寻址唯一性义务(partial unique index),撞名自动追 -2。
|
||||
// pi 侧没有这个约束。不回写的话:邮箱里是 fix-leak-2、pi-web 里是 fix-leak,
|
||||
// 用户按界面上看到的名字发信会 404。
|
||||
const got = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
|
||||
assert.equal(got.write, true);
|
||||
assert.equal(got.name, 'fix-leak-2');
|
||||
assert.equal(got.reason, 'diverged');
|
||||
});
|
||||
|
||||
test('不变量:manual 别名(人手工改过)优先,回写进 pi', () => {
|
||||
// SyncSessionAlias 遇到 alias_source='manual' 时不覆盖,**原样返回当前别名**。
|
||||
// 于是「人在 AgentMail 界面上定的名字」赢,pi 侧要跟着改 —— 这是有意的:
|
||||
// 人的意图优先于模型生成的标题。
|
||||
const got = planWriteBack({ finalAlias: '紧急排查', currentPiName: 'connection-leak' });
|
||||
assert.equal(got.write, true);
|
||||
assert.equal(got.name, '紧急排查');
|
||||
});
|
||||
|
||||
test('规范化改写过的别名也要回写', () => {
|
||||
// normalizeAlias 把 . / @ 空白换成 -。提议 "a.b c" 会变成 "a-b-c"。
|
||||
const got = planWriteBack({ finalAlias: 'a-b-c', currentPiName: 'a.b c' });
|
||||
assert.equal(got.write, true);
|
||||
assert.equal(got.name, 'a-b-c');
|
||||
});
|
||||
|
||||
test('两边已经一致就不回写', () => {
|
||||
// 回写会 append 一条 session_info 并触发 session_info_changed。
|
||||
// 无条件回写 = 每轮多一条无意义的历史条目 + 一次多余的 sync。
|
||||
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: 'fix-leak' });
|
||||
assert.equal(got.write, false);
|
||||
assert.equal(got.reason, 'already-equal');
|
||||
});
|
||||
|
||||
test('pi 侧还没有名字时也要回写', () => {
|
||||
// 桥用 SDK 起的会话没有名字(pi-web 的生成器不在这条链路上),
|
||||
// 此时 Gateway 定稿的别名就是这条会话的第一个名字。
|
||||
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: undefined });
|
||||
assert.equal(got.write, true);
|
||||
assert.equal(got.reason, 'pi-unnamed');
|
||||
});
|
||||
|
||||
test('不变量:响应没带别名时不回写', () => {
|
||||
// 本次只同步了标题(planNamingSync 的 platform-title-only 分支)→ 没有定稿值。
|
||||
// 拿空串去 setSessionName 是**清除**语义(实测 appendSessionInfo(" ")
|
||||
// 之后 getSessionName() 变 undefined),会把 pi 侧原有的名字抹掉。
|
||||
assert.equal(planWriteBack({ finalAlias: '', currentPiName: 'keep-me' }).write, false);
|
||||
assert.equal(planWriteBack({ finalAlias: undefined, currentPiName: 'keep-me' }).write, false);
|
||||
assert.equal(planWriteBack({ finalAlias: ' ', currentPiName: 'keep-me' }).write, false);
|
||||
});
|
||||
|
||||
// ─── 端到端的一致性推理 ───
|
||||
|
||||
test('完整链路:提议 → 撞名定稿 → 回写 → 再观测不再动', () => {
|
||||
// 这个 test 钉住「不会自激循环」这条性质,它是分四步的:
|
||||
// 1. pi 有了名字 fix-leak,提交
|
||||
// 2. Gateway 撞名,定稿 fix-leak-2
|
||||
// 3. 回写进 pi,pi 的名字变成 fix-leak-2
|
||||
// 4. session_info_changed 再次触发 → 必须 skip,否则无限循环
|
||||
const step1 = planNamingSync({ platformName: 'fix-leak', mailSubject: '', lastSynced: '' });
|
||||
assert.equal(step1.alias, 'fix-leak');
|
||||
|
||||
const step3 = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
|
||||
assert.equal(step3.write, true);
|
||||
|
||||
// 桥在回写后把指纹更新成「定稿别名当作平台名字」会算出的那个值,
|
||||
// 因此第 4 步(回写触发的事件)看到的指纹与它相同。
|
||||
const afterWriteBack = `platform:${step3.name}|${step3.name}`;
|
||||
const step4 = planNamingSync({ platformName: 'fix-leak-2', mailSubject: '', lastSynced: afterWriteBack });
|
||||
assert.equal(step4.skip, true, '回写触发的事件必须被指纹挡住,否则无限循环');
|
||||
});
|
||||
326
plugins/pi-mail-bridge/test/session-snapshot.test.mjs
Normal file
326
plugins/pi-mail-bridge/test/session-snapshot.test.mjs
Normal file
@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 平台会话快照的纯函数测试。
|
||||
*
|
||||
* 这些函数的产出直接决定「写信时能不能选到某条会话」:slug 错了就填出一个
|
||||
* 送不到的 session 位(三态语义下会 404),workspace 错了就归到别的工作区去。
|
||||
*
|
||||
* node --test test/
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
snapshotOpencodeSessions,
|
||||
snapshotDshSessions,
|
||||
snapshotPiSessions,
|
||||
isUnusableName,
|
||||
slugFromTitle,
|
||||
MAX_REPORTED,
|
||||
} from '../lib/session-snapshot.js';
|
||||
|
||||
// ─── opencode ───
|
||||
|
||||
const ocSession = (over = {}) => ({
|
||||
id: 'ses_abc',
|
||||
slug: 'witty-planet',
|
||||
title: '重构导入路径',
|
||||
directory: '/home/program/agentmail',
|
||||
path: '',
|
||||
time: { created: 1788300000000, updated: 1788344476744 },
|
||||
...over,
|
||||
});
|
||||
|
||||
test('opencode 快照取 directory 作为 workspace', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.workspace, '/home/program/agentmail');
|
||||
});
|
||||
|
||||
test('不变量:workspace 取 directory 而不是 path', () => {
|
||||
// opencode 的 path 是项目内的子路径(通常是空串),cwd 在 directory 上。
|
||||
// 取错的后果是所有会话的 workspace 都变成空串,一条都匹配不上。
|
||||
const [got] = snapshotOpencodeSessions([
|
||||
ocSession({ directory: '/home/real/cwd', path: 'src/sub' }),
|
||||
]);
|
||||
assert.equal(got.workspace, '/home/real/cwd');
|
||||
});
|
||||
|
||||
test('opencode 快照带出 slug 与标题', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.slug, 'witty-planet');
|
||||
assert.equal(got.title, '重构导入路径');
|
||||
assert.equal(got.platform_id, 'ses_abc');
|
||||
});
|
||||
|
||||
test('不变量:无 slug 的会话不上报', () => {
|
||||
// slug 是填进 session 位的值。没有它,这一项在补全里点下去
|
||||
// 只能得到 `name@path.` —— 一个空的 session 段。
|
||||
const got = snapshotOpencodeSessions([
|
||||
ocSession({ id: 'a', slug: '' }),
|
||||
ocSession({ id: 'b', slug: undefined }),
|
||||
ocSession({ id: 'c', slug: 'good-name' }),
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].slug, 'good-name');
|
||||
});
|
||||
|
||||
test('无 id 的条目被跳过', () => {
|
||||
const got = snapshotOpencodeSessions([ocSession({ id: '' }), ocSession({ id: undefined })]);
|
||||
assert.equal(got.length, 0);
|
||||
});
|
||||
|
||||
test('mail_driven 由回调判定', () => {
|
||||
const got = snapshotOpencodeSessions(
|
||||
[ocSession({ id: 'driven' }), ocSession({ id: 'manual' })],
|
||||
id => id === 'driven'
|
||||
);
|
||||
assert.equal(got.find(s => s.platform_id === 'driven').mail_driven, true);
|
||||
assert.equal(got.find(s => s.platform_id === 'manual').mail_driven, false);
|
||||
});
|
||||
|
||||
test('updated_at 由毫秒时间戳转 ISO', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession()]);
|
||||
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
|
||||
});
|
||||
|
||||
test('没有 updated 时退回 created', () => {
|
||||
const [got] = snapshotOpencodeSessions([
|
||||
ocSession({ time: { created: 1788300000000 } }),
|
||||
]);
|
||||
assert.equal(got.updated_at, new Date(1788300000000).toISOString());
|
||||
});
|
||||
|
||||
test('时间完全缺失时 updated_at 为 undefined 而不是崩', () => {
|
||||
const [got] = snapshotOpencodeSessions([ocSession({ time: undefined })]);
|
||||
assert.equal(got.updated_at, undefined);
|
||||
});
|
||||
|
||||
test('按最近活跃降序排列', () => {
|
||||
const got = snapshotOpencodeSessions([
|
||||
ocSession({ id: 'old', slug: 'old', time: { updated: 1000 } }),
|
||||
ocSession({ id: 'new', slug: 'new', time: { updated: 9000 } }),
|
||||
ocSession({ id: 'mid', slug: 'mid', time: { updated: 5000 } }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
test('截断到 MAX_REPORTED', () => {
|
||||
const many = Array.from({ length: MAX_REPORTED + 50 }, (_, i) =>
|
||||
ocSession({ id: `s${i}`, slug: `slug-${i}`, time: { updated: i } })
|
||||
);
|
||||
assert.equal(snapshotOpencodeSessions(many).length, MAX_REPORTED);
|
||||
});
|
||||
|
||||
test('非数组输入不崩', () => {
|
||||
assert.deepEqual(snapshotOpencodeSessions(undefined), []);
|
||||
assert.deepEqual(snapshotOpencodeSessions(null), []);
|
||||
assert.deepEqual(snapshotOpencodeSessions({}), []);
|
||||
});
|
||||
|
||||
// ─── DSH ───
|
||||
|
||||
test('DSH 快照从标题派生 slug', () => {
|
||||
const [got] = snapshotDshSessions([
|
||||
{ id: 'mail-1', cwd: '/home/x', title: '缓存层选型评估', updatedAt: 1788344476744 },
|
||||
]);
|
||||
assert.equal(got.slug, '缓存层选型评估');
|
||||
assert.equal(got.title, '缓存层选型评估');
|
||||
assert.equal(got.workspace, '/home/x');
|
||||
});
|
||||
|
||||
test('DSH 无标题时不上报(派生不出别名)', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'a', cwd: '/home/x', title: '' },
|
||||
{ id: 'b', cwd: '/home/x' },
|
||||
]);
|
||||
assert.equal(got.length, 0);
|
||||
});
|
||||
|
||||
// ─── slugFromTitle ───
|
||||
|
||||
test('slugFromTitle 空白转连字符', () => {
|
||||
assert.equal(slugFromTitle('处理新邮件 任务'), '处理新邮件-任务');
|
||||
assert.equal(slugFromTitle('a b c'), 'a-b-c');
|
||||
});
|
||||
|
||||
test('不变量:slug 不含寻址分隔符', () => {
|
||||
// `.` 是 session 位的分隔符、`@` 是 path 位的分隔符。留在 slug 里
|
||||
// 会让别名自己被解析器切开 —— 填进去的地址会指向一个完全不同的目标。
|
||||
const slug = slugFromTitle('修 a.b@c/d 的问题');
|
||||
for (const ch of ['.', '@', '/', '\\', ':']) {
|
||||
assert.ok(!slug.includes(ch), `slug 里不该有 ${ch}:${slug}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('slugFromTitle 压缩连续连字符', () => {
|
||||
assert.equal(slugFromTitle('a...b'), 'ab');
|
||||
assert.equal(slugFromTitle('a - b'), 'a-b');
|
||||
});
|
||||
|
||||
test('slugFromTitle 去掉首尾连字符', () => {
|
||||
assert.equal(slugFromTitle(' 中间 '), '中间');
|
||||
assert.equal(slugFromTitle('--x--'), 'x');
|
||||
});
|
||||
|
||||
test('slugFromTitle 截断到 48 字符且不留尾部连字符', () => {
|
||||
const long = 'a'.repeat(60);
|
||||
assert.equal(slugFromTitle(long).length, 48);
|
||||
// 第 48 个字符正好落在空格上时,截断后不该留下尾部 -
|
||||
const tricky = `${'b'.repeat(47)} tail`;
|
||||
const slug = slugFromTitle(tricky);
|
||||
assert.ok(!slug.endsWith('-'), `尾部残留连字符:${slug}`);
|
||||
});
|
||||
|
||||
test('slugFromTitle 保留中文', () => {
|
||||
// 不转拼音:huancunceng-xuanxing 既不好读也不好打,
|
||||
// 而三维地址按最后一个 . 切分,中文不影响解析。
|
||||
assert.equal(slugFromTitle('缓存选型'), '缓存选型');
|
||||
});
|
||||
|
||||
test('slugFromTitle 纯符号标题返回空串', () => {
|
||||
assert.equal(slugFromTitle('...'), '');
|
||||
assert.equal(slugFromTitle('@@@'), '');
|
||||
assert.equal(slugFromTitle(' '), '');
|
||||
});
|
||||
|
||||
test('slugFromTitle 容错非字符串', () => {
|
||||
assert.equal(slugFromTitle(undefined), '');
|
||||
assert.equal(slugFromTitle(null), '');
|
||||
assert.equal(slugFromTitle(42), '42');
|
||||
});
|
||||
|
||||
// ─── DSH:subagent 过滤与 slug 去重 ───
|
||||
|
||||
test('不变量:subagent 子会话不上报(origin 判据)', () => {
|
||||
// 它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'child', cwd: '/w', title: 'You are auditing ONE file', origin: 'subagent' },
|
||||
{ id: 'top', cwd: '/w', title: '正常会话' },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'top');
|
||||
});
|
||||
|
||||
test('不变量:subagent 子会话不上报(delegationDepth 判据)', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'child', cwd: '/w', title: '子任务', delegationDepth: 1 },
|
||||
{ id: 'top', cwd: '/w', title: '顶层', delegationDepth: 0 },
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'top');
|
||||
});
|
||||
|
||||
test('不变量:slug 撞名只留最近那条', () => {
|
||||
// 别名是寻址用的:同一个 slug 对应多条会话时服务端只能取其中一条,
|
||||
// 上报一堆同名项只会让补全列表里出现几个点哪个都不确定的候选。
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'old', cwd: '/w', title: '同一个标题', updatedAt: 1000 },
|
||||
{ id: 'new', cwd: '/w', title: '同一个标题', updatedAt: 9000 },
|
||||
{ id: 'mid', cwd: '/w', title: '同一个标题', updatedAt: 5000 },
|
||||
]);
|
||||
assert.equal(got.length, 1, `应去重到 1 条,实际 ${got.length}`);
|
||||
assert.equal(got[0].platform_id, 'new', '应保留最近活跃的那条');
|
||||
});
|
||||
|
||||
test('不同标题不受去重影响', () => {
|
||||
const got = snapshotDshSessions([
|
||||
{ id: 'a', cwd: '/w', title: '标题一', updatedAt: 2000 },
|
||||
{ id: 'b', cwd: '/w', title: '标题二', updatedAt: 1000 },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
});
|
||||
|
||||
// ─── pi:名字来自会话文件的 session_info ───
|
||||
|
||||
const piSession = (over = {}) => ({
|
||||
id: '01a064cc-df57-7b2d-bebb-736776105485',
|
||||
cwd: '/home/program/agentmail',
|
||||
name: '重构导入路径',
|
||||
messageCount: 6,
|
||||
created: new Date(1788300000000),
|
||||
modified: new Date(1788344476744),
|
||||
...over,
|
||||
});
|
||||
|
||||
test('pi 快照取 cwd 与 session_info 名字', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.workspace, '/home/program/agentmail');
|
||||
assert.equal(got.title, '重构导入路径');
|
||||
assert.equal(got.slug, '重构导入路径');
|
||||
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
|
||||
});
|
||||
|
||||
test('不变量:pi 无名会话不上报', () => {
|
||||
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
|
||||
// (「你收到一封新邮件(AgentMail)…」)—— 拿它当别名毫无区分度,且条条撞名。
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'named', name: '有名字' }),
|
||||
piSession({ id: 'anon', name: undefined }),
|
||||
piSession({ id: 'blank', name: '' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['named']);
|
||||
});
|
||||
|
||||
test('不变量:pi 老会话的空 cwd 照实上报', () => {
|
||||
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
|
||||
// 那条会话在补全里挂到一个它其实不属于的工作区下。
|
||||
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
|
||||
assert.equal(got.workspace, '');
|
||||
});
|
||||
|
||||
test('不变量:pi 的 updated_at 取 modified(文件 mtime)', () => {
|
||||
const [got] = snapshotPiSessions([piSession()]);
|
||||
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
|
||||
});
|
||||
|
||||
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
|
||||
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
|
||||
]);
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(got[0].platform_id, 'new');
|
||||
});
|
||||
|
||||
test('pi 快照标记邮件驱动的会话', () => {
|
||||
const got = snapshotPiSessions(
|
||||
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
|
||||
(id) => id === 'mail-one'
|
||||
);
|
||||
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
|
||||
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
|
||||
});
|
||||
|
||||
// ─── isUnusableName:pi-web 标题生成器的思维链泄漏 ───
|
||||
|
||||
test('不变量:思维链泄漏的标题判废', () => {
|
||||
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
|
||||
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
|
||||
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
|
||||
assert.equal(
|
||||
isUnusableName('我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:Opencode源测试。或者更简'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isUnusableName 放过正常标题', () => {
|
||||
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
|
||||
assert.equal(isUnusableName('查看Agent接入群聊'), false);
|
||||
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
|
||||
assert.equal(isUnusableName('homeagent-gateway'), false);
|
||||
assert.equal(isUnusableName('重构导入路径'), false);
|
||||
assert.equal(isUnusableName('Fix flaky auth test'), false);
|
||||
});
|
||||
|
||||
test('isUnusableName 判废空名字', () => {
|
||||
assert.equal(isUnusableName(''), true);
|
||||
assert.equal(isUnusableName(' '), true);
|
||||
assert.equal(isUnusableName(undefined), true);
|
||||
});
|
||||
|
||||
test('判废的名字不进快照', () => {
|
||||
const got = snapshotPiSessions([
|
||||
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
|
||||
piSession({ id: 'clean', name: '正常标题' }),
|
||||
]);
|
||||
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
|
||||
});
|
||||
254
plugins/pi-mail-bridge/test/turn.test.mjs
Normal file
254
plugins/pi-mail-bridge/test/turn.test.mjs
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* pi 专属纯逻辑的测试:轮次结论判定、消息文本提取、提示词。
|
||||
*
|
||||
* 这些不在 lib/ 下(那里的六个文件三平台逐字节相同),因为它们依赖 pi 的
|
||||
* 消息形状与 stopReason 语义。但同样是纯函数,因此可以不起模型就钉住。
|
||||
*
|
||||
* node --test 'test/*.test.mjs'
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
stripRe,
|
||||
replySubject,
|
||||
lastAssistantText,
|
||||
classifyTurnOutcome,
|
||||
describeError,
|
||||
buildMailPrompt,
|
||||
relayKeyFor,
|
||||
} from '../src/turn.mjs';
|
||||
|
||||
// ─── 主题 ───
|
||||
|
||||
test('stripRe 去掉叠加的 Re: 前缀', () => {
|
||||
assert.equal(stripRe('Re: Re: Re: 缓存选型'), '缓存选型');
|
||||
assert.equal(stripRe('缓存选型'), '缓存选型');
|
||||
assert.equal(stripRe('RE: RE: x'), 'x');
|
||||
});
|
||||
|
||||
test('replySubject 只加一层 Re:', () => {
|
||||
assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型');
|
||||
assert.equal(replySubject('缓存选型'), 'Re: 缓存选型');
|
||||
});
|
||||
|
||||
test('无主题时回信有兜底主题', () => {
|
||||
// 空主题会被 Gateway 拒(400 Missing subject),不兜底就发不出去
|
||||
assert.equal(replySubject(''), '本轮工作总结');
|
||||
assert.equal(replySubject(undefined), '本轮工作总结');
|
||||
});
|
||||
|
||||
// ─── 消息文本提取 ───
|
||||
|
||||
const asst = (blocks, over = {}) => ({ role: 'assistant', content: blocks, stopReason: 'stop', ...over });
|
||||
|
||||
test('只取 text 块,丢掉 thinking', () => {
|
||||
// 思考过程不该出现在邮件里(B-5.1 / N-6):它对收件人没有意义,
|
||||
// 而且经常包含「我先假设…」这类会被误读为结论的话。
|
||||
const got = lastAssistantText([
|
||||
asst([
|
||||
{ type: 'thinking', thinking: '先看看有没有缓存层' },
|
||||
{ type: 'text', text: '已定位到问题:连接池没有复用。' },
|
||||
]),
|
||||
]);
|
||||
assert.equal(got, '已定位到问题:连接池没有复用。');
|
||||
});
|
||||
|
||||
test('不变量:跳过纯工具调用的收尾消息,往前找有文本的那条', () => {
|
||||
// 一轮的最后一条 assistant 消息常常只有 toolCall。取到它会得到空串,
|
||||
// 于是 B-5.4 判成「无话可说」而漏掉真正的结论 —— 发件人再无音讯。
|
||||
const got = lastAssistantText([
|
||||
asst([{ type: 'text', text: '结论在这里。' }]),
|
||||
asst([{ type: 'toolCall', toolName: 'bash', input: {} }]),
|
||||
]);
|
||||
assert.equal(got, '结论在这里。');
|
||||
});
|
||||
|
||||
test('多个 text 块按顺序拼接', () => {
|
||||
const got = lastAssistantText([asst([
|
||||
{ type: 'text', text: '第一段' },
|
||||
{ type: 'text', text: '第二段' },
|
||||
])]);
|
||||
assert.equal(got, '第一段\n第二段');
|
||||
});
|
||||
|
||||
test('忽略 user 消息里的文本', () => {
|
||||
const got = lastAssistantText([
|
||||
asst([{ type: 'text', text: 'assistant 说的' }]),
|
||||
{ role: 'user', content: [{ type: 'text', text: 'user 说的' }] },
|
||||
]);
|
||||
assert.equal(got, 'assistant 说的');
|
||||
});
|
||||
|
||||
test('没有 assistant 消息时返回空串', () => {
|
||||
assert.equal(lastAssistantText([{ role: 'user', content: [{ type: 'text', text: 'x' }] }]), '');
|
||||
assert.equal(lastAssistantText([]), '');
|
||||
assert.equal(lastAssistantText(undefined), '');
|
||||
});
|
||||
|
||||
// ─── 轮次结论(D-3,两次适配都踩过)───
|
||||
|
||||
test('不变量:prompt 抛错判为失败', () => {
|
||||
// 实测:无凭证的 provider 让 prompt() reject(No API key found for
|
||||
// amazon-bedrock.),**一个事件都不发**。只看事件的话这轮会被当成没跑完。
|
||||
const got = classifyTurnOutcome({ error: new Error('No API key found for amazon-bedrock.') });
|
||||
assert.equal(got.ok, false);
|
||||
assert.match(got.error, /No API key/);
|
||||
});
|
||||
|
||||
test('不变量:一条 assistant 消息都没有判为失败', () => {
|
||||
// 判成功会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件,
|
||||
// 而不是错误说明。这是契约里 C-4「必须能区分成功与出错」的核心。
|
||||
const got = classifyTurnOutcome({ messages: [{ role: 'user', content: [] }] });
|
||||
assert.equal(got.ok, false);
|
||||
assert.match(got.error, /没有产出/);
|
||||
});
|
||||
|
||||
test('不变量:stopReason=error 判为失败并带出 errorMessage', () => {
|
||||
const got = classifyTurnOutcome({
|
||||
messages: [asst([{ type: 'text', text: '半句' }], {
|
||||
stopReason: 'error',
|
||||
errorMessage: 'upstream 503 rate limited',
|
||||
})],
|
||||
});
|
||||
assert.equal(got.ok, false);
|
||||
assert.equal(got.error, 'upstream 503 rate limited');
|
||||
});
|
||||
|
||||
test('stopReason=error 但没给原因也要有话可说', () => {
|
||||
const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'error' })] });
|
||||
assert.equal(got.ok, false);
|
||||
assert.ok(got.error, '失败原因不能是空串:renderFailureReport 会把它填进邮件');
|
||||
});
|
||||
|
||||
test('正常收尾判为成功', () => {
|
||||
const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '好了' }])] });
|
||||
assert.deepEqual(got, { ok: true, error: '', aborted: false });
|
||||
});
|
||||
|
||||
test('不变量:length(被 max tokens 截断)判为成功', () => {
|
||||
// 内容不完整,但**是模型的产出**。判失败会让一封「说了一半」的回信
|
||||
// 变成「换个模型重试」,那更糟 —— 用户什么都收不到。
|
||||
const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '说了一半' }], { stopReason: 'length' })] });
|
||||
assert.equal(got.ok, true);
|
||||
});
|
||||
|
||||
test('aborted 判为失败但标记 aborted', () => {
|
||||
// 有人主动打断(Esc / dispose),不是模型故障 —— 不该触发换模型重试
|
||||
const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'aborted' })] });
|
||||
assert.equal(got.ok, false);
|
||||
assert.equal(got.aborted, true);
|
||||
});
|
||||
|
||||
test('取最后一条 assistant 消息判定,不是第一条', () => {
|
||||
const got = classifyTurnOutcome({
|
||||
messages: [
|
||||
asst([{ type: 'text', text: '第一轮好的' }], { stopReason: 'stop' }),
|
||||
asst([], { stopReason: 'error', errorMessage: '第二轮炸了' }),
|
||||
],
|
||||
});
|
||||
assert.equal(got.ok, false);
|
||||
assert.equal(got.error, '第二轮炸了');
|
||||
});
|
||||
|
||||
// ─── describeError ───
|
||||
|
||||
test('describeError 只取首行', () => {
|
||||
// 报错原文会被填进故障邮件的正文,多行堆栈会把那封信淹掉
|
||||
assert.equal(describeError(new Error('炸了\n at foo (bar.js:1)')), '炸了');
|
||||
assert.equal(describeError('单行错误'), '单行错误');
|
||||
});
|
||||
|
||||
test('describeError 带上 code', () => {
|
||||
const e = new Error('connect failed');
|
||||
e.code = 'ECONNREFUSED';
|
||||
assert.equal(describeError(e), 'ECONNREFUSED: connect failed');
|
||||
});
|
||||
|
||||
test('describeError 容错', () => {
|
||||
assert.equal(describeError(null), '');
|
||||
assert.equal(describeError(undefined), '');
|
||||
});
|
||||
|
||||
// ─── 提示词(B-3.4 / B-3.5)───
|
||||
|
||||
const mailData = {
|
||||
mail_id: 'm-1',
|
||||
from_name: 'admin',
|
||||
subject: '排查连接泄漏',
|
||||
to_workspace: '/home/program/agentmail',
|
||||
};
|
||||
|
||||
test('不变量:提示词写明回信由桥自动发', () => {
|
||||
// 不说的话模型会自己调 send_mail,而桥在轮次结束时也会转发一次 ——
|
||||
// 同一件事两封邮件(生产里真实发生过)。
|
||||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||||
assert.match(p, /回信不用你自己发/);
|
||||
});
|
||||
|
||||
test('不变量:提示词带 mail_id 与 read_inbox 指引', () => {
|
||||
// 事件里只有主题,正文和附件清单都在收件箱里;不给 mail_id 模型无法定位这一封
|
||||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||||
assert.match(p, /m-1/);
|
||||
assert.match(p, /read_inbox/);
|
||||
});
|
||||
|
||||
test('首封带身份,续谈不重复带', () => {
|
||||
const first = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||||
const again = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: true });
|
||||
assert.match(first, /你是 pi/);
|
||||
assert.doesNotMatch(again, /你是 pi/);
|
||||
assert.match(again, /续谈/);
|
||||
});
|
||||
|
||||
test('补投的邮件在提示词里说明来源', () => {
|
||||
// 不说明的话模型会以为这是刚到的、按「立即响应」的语气回
|
||||
const p = buildMailPrompt({
|
||||
agentName: 'pi',
|
||||
data: { ...mailData, catchup: true },
|
||||
kind: 'mail',
|
||||
reused: false,
|
||||
});
|
||||
assert.match(p, /积压/);
|
||||
});
|
||||
|
||||
test('不变量:带上服务端算好的 reply_address', () => {
|
||||
// 模型确实会自己发信(要抄送第三方、或分多封交代不同的事)。
|
||||
// 让它自己拼三维地址的话,`.new` 会被拼进去 —— 回信静默开出一条新会话,
|
||||
// 原来的线索里再无下文。服务端在 new_mail 里已经算好了这个地址。
|
||||
const p = buildMailPrompt({
|
||||
agentName: 'pi',
|
||||
data: { ...mailData, reply_address: 'admin@.排查连接泄漏' },
|
||||
kind: 'mail',
|
||||
reused: false,
|
||||
});
|
||||
assert.match(p, /admin@\.排查连接泄漏/);
|
||||
});
|
||||
|
||||
test('没有 reply_address 时不留空行占位', () => {
|
||||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||||
assert.doesNotMatch(p, /回信地址/);
|
||||
});
|
||||
|
||||
test('权限决策的提示词带决策与决策人', () => {
|
||||
const p = buildMailPrompt({
|
||||
agentName: 'pi',
|
||||
data: { decision: '同意', decided_by: 'zhang' },
|
||||
kind: 'permission',
|
||||
reused: true,
|
||||
});
|
||||
assert.match(p, /同意/);
|
||||
assert.match(p, /zhang/);
|
||||
});
|
||||
|
||||
// ─── 幂等键 ───
|
||||
|
||||
test('relayKey 由会话 id 与叶子 id 组成', () => {
|
||||
assert.equal(relayKeyFor('sess-1', 'leaf-9'), 'sess-1:leaf-9');
|
||||
});
|
||||
|
||||
test('不变量:叶子 id 缺失时仍产出稳定键', () => {
|
||||
// 返回空串会让服务端把 relay_key 当作「没给」,于是幂等失效、同一轮转两次
|
||||
assert.equal(relayKeyFor('sess-1', null), 'sess-1:noleaf');
|
||||
assert.equal(relayKeyFor('sess-1', undefined), 'sess-1:noleaf');
|
||||
});
|
||||
128
plugins/pi-mail-bridge/test/workspace.test.mjs
Normal file
128
plugins/pi-mail-bridge/test/workspace.test.mjs
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 工作目录解析的回归测试。
|
||||
*
|
||||
* 这是「dsh 指定工作目录完全失效,所有对话都落在未分组下」那次故障的直接回归:
|
||||
* 插件曾无视寻址里的 path 位,每封邮件自己拼一个 ~/.dsh/mail-sessions/mail-<uuid>,
|
||||
* 而 DSH 按 cwd 分组,于是所有邮件会话既不属于任何项目、彼此也不同组。
|
||||
*
|
||||
* node --test test/
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js';
|
||||
|
||||
// 兜底目录现在由调用方给(各平台不同)。DSH 用 mailSessionFallback,
|
||||
// opencode 用插件启动时的 directory。
|
||||
const fallbackOf = key => mailSessionFallback(key);
|
||||
|
||||
test('存在的绝对路径直接用作 cwd', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
try {
|
||||
const got = resolveWorkspaceCwd(dir, fallbackOf('mail-1'));
|
||||
assert.equal(got.cwd, dir);
|
||||
assert.equal(got.grouped, true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('不变量:同一 path 的多封邮件得到同一个 cwd(这才能同组)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
try {
|
||||
const a = resolveWorkspaceCwd(dir, fallbackOf('mail-aaa'));
|
||||
const b = resolveWorkspaceCwd(dir, fallbackOf('mail-bbb'));
|
||||
assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('path 为空时回退到兜底目录', () => {
|
||||
const got = resolveWorkspaceCwd('', fallbackOf('mail-2'));
|
||||
assert.equal(got.cwd, fallbackOf('mail-2'));
|
||||
assert.equal(got.grouped, false);
|
||||
});
|
||||
|
||||
test('path 缺失/非字符串时回退', () => {
|
||||
for (const v of [undefined, null, 42, {}]) {
|
||||
const got = resolveWorkspaceCwd(v, fallbackOf('mail-3'));
|
||||
assert.equal(got.grouped, false);
|
||||
assert.equal(got.cwd, fallbackOf('mail-3'));
|
||||
}
|
||||
});
|
||||
|
||||
test('不变量:不存在的目录不创建,回退到兜底', () => {
|
||||
// 一个笔误(/home/porgram/x)不该在磁盘上落下真目录 ——
|
||||
// Agent 会在里面一无所获地干活,比明确回退更难排查。
|
||||
const got = resolveWorkspaceCwd('/nonexistent/path/xyz-should-not-exist', fallbackOf('mail-4'));
|
||||
assert.equal(got.grouped, false);
|
||||
assert.equal(got.cwd, fallbackOf('mail-4'));
|
||||
});
|
||||
|
||||
test('不变量:相对路径被拒绝', () => {
|
||||
// cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 /,
|
||||
// 那是个与邮件语义完全无关的量。
|
||||
for (const rel of ['relative/path', './x', '../y', 'src']) {
|
||||
const got = resolveWorkspaceCwd(rel, fallbackOf('mail-5'));
|
||||
assert.equal(got.grouped, false, `${rel} 不该被当作工作目录`);
|
||||
}
|
||||
});
|
||||
|
||||
test('指向文件而非目录时回退', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
const file = join(dir, 'a-file');
|
||||
writeFileSync(file, 'x');
|
||||
try {
|
||||
const got = resolveWorkspaceCwd(file, fallbackOf('mail-6'));
|
||||
assert.equal(got.grouped, false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('两端空白被修掉', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
|
||||
try {
|
||||
const got = resolveWorkspaceCwd(` ${dir} `, fallbackOf('mail-7'));
|
||||
assert.equal(got.cwd, dir);
|
||||
assert.equal(got.grouped, true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureCwd 只建兜底目录,不碰寻址指定的目录', () => {
|
||||
const base = mkdtempSync(join(tmpdir(), 'ws-ensure-'));
|
||||
try {
|
||||
const target = join(base, 'made-by-ensure');
|
||||
ensureCwd(target, false);
|
||||
// 建出来了
|
||||
const got = resolveWorkspaceCwd(target, '');
|
||||
assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录');
|
||||
|
||||
// grouped=true 时不该创建(那种目录本来就存在)
|
||||
const never = join(base, 'should-not-exist');
|
||||
ensureCwd(never, true);
|
||||
assert.equal(resolveWorkspaceCwd(never, '').grouped, false);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('兜底为空串时返回空 cwd(交给平台自己决定)', () => {
|
||||
// opencode 没配 directory 时就是这种情况:session.create 不带 query.directory,
|
||||
// 由平台按自己的默认规则选目录。比硬塞一个我们猜的路径好。
|
||||
const got = resolveWorkspaceCwd('', '');
|
||||
assert.equal(got.cwd, '');
|
||||
assert.equal(got.grouped, false);
|
||||
});
|
||||
|
||||
test('mailSessionFallback 同一 key 稳定、不同 key 不同', () => {
|
||||
assert.equal(mailSessionFallback('a'), mailSessionFallback('a'));
|
||||
assert.notEqual(mailSessionFallback('a'), mailSessionFallback('b'));
|
||||
assert.match(mailSessionFallback('a'), /mail-sessions/);
|
||||
});
|
||||
Reference in New Issue
Block a user