Files
MailUI4Agents/plugins/pi-mail-bridge/test/model-scope.test.mjs
JianFeeeee e6fd2fafdc 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),未手工拼写
2026-09-03 12:09:12 +08:00

249 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 模型范围与降级尝试的测试。
*
* 最要紧的一条:范围为空时必须返回 `[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 个/);
});