Files
MailUI4Agents/plugins/opencode-mail-bridge/test/inbox-format.test.mjs
JianFeeeee 77699e216b fix(webui): 新到的授权请求藏在折叠分组里 —— 徽标动了,内容看不见
用户报告:"我点到授权界面,才更新显示授权请求"。

先排除了推送本身:实测徽标是**实时**更新的(gui-lab 授权 7→8、jianf 1→2,
都没导航)。问题在内容:`PermissionList` 的展开状态
`const openSet = expanded ?? new Set(autoOpen)` —— 一旦手动点过一次,
`expanded` 就冻结成"点的那一刻"的快照,此后新到的待决请求落在一个折叠的分组里:
徽标数字变了,正文却看不见,直到离开再回到授权页(组件重挂载、`expanded`
回到 null、默认展开重算)才出现。

这违反代码自己的设计意图(注释写着「有待决策请求的会话默认展开:那些是在等人
动手的,藏起来等于没解决问题」)。

修法:加一条**状态迁移**判据 —— 新出现的待决邮件(`sessionId:mailId`)让它所在
的会话自动展开。用 mail_id 而不是"会话有没有待决"作判据,是因为实测撞到的正是
"会话早就有待决、用户把它折叠了,之后又来了一条";而用户在那之后再手动折叠同一
条不会被弹开(没有新 mail_id)。

验证:
  · 真浏览器复现:授权 2 → 3 而新请求正文不可见,重进页面才可见(复现成功)
  · 新增 `test/components/PermissionList-autopen.test.tsx`(3 条,含反向对照)
  · 扰动验证:撤掉修复 → 2 条目标判据红、对照判据仍绿;恢复 → 3/3
  · 部署后同一探针复验:折叠状态下新请求**立刻可见**,不再需要重进页面
  · 前端 239 测试全绿;桌面重打包与 WebUI 同源(index-jaRgHqX2.js)

顺带修掉一个**更严重的缺陷**(在做「用 zcode 写个网页」时被 agent 自己报出来的):

  fix(plugins): zcode 的 read_mail 永远返回空正文

agent 回信原话:「read_mail 返回的正文是空的,收件箱预览在「点击计数…」处被截断」
—— 它因此只看到前两条要求,写出来的页面漏了第 3 条(生成时间)。

根因在 `lib/inbox-format.js` 的渲染端:

    const body = m?.body_preview || m?.body || '';
    lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);

zcode 的 read_mail 用 `bodyLimit = 0` 表示"要全文"(HTTP 侧 `?body_limit=0`
也确实是这个语义,服务端返回了完整正文),但这里 `slice(0, 0)` 把正文渲染成
**空字符串** ⇒ 模型永远读不到全文,只能看收件箱里那段预览。

修法:`bodyLimit <= 0` 视为不截断;不截断时优先取 `body`(单封接口可能同时带
`body_preview`,那是短的那个)。四份副本逐字节同源(`check-shared-libs.sh`
通过),每个桥各加 2 条判据:0 = 不截断、不截断时优先全文。
扰动验证:退回旧写法 → 2 条红。

端到端验证:让 zcode 读全文并原样回报最后一行(一个随机标记)。
修复后它精确回出 `最后一行标记:ZTOKEN-2c7561fd` ✓ —— 修复前这不可能。

四家桥都已重新部署到新快照(pi/opencode/dsh/zcode),部署漂移检查:
「四个宿主都在跑当前代码」。测试基线:pi 417 / opencode 323 / dsh 372 / zcode 382。
2026-09-13 10:39:26 +08:00

288 lines
10 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.

/**
* 收件箱渲染与已读策略的测试。
*
* 每条断言都对应一次真实的错误行为(见 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('★ bodyLimit=0 表示不截断且优先取全文线上故障read_mail 渲染成空)', () => {
// zcode 的 read_mail 就是传 0曾经 slice(0, 0) → 正文成了空字符串
const long = 'y'.repeat(500);
const line = renderMail(mail({ body: long }), 0).split('\n').find(l => l.startsWith('内容: '));
assert.equal(line.length, '内容: '.length + 500);
// 反向对照:正数仍然截断
assert.equal(
renderMail(mail({ body: long }), 10).split('\n').find(l => l.startsWith('内容: ')).length,
'内容: '.length + 10
);
});
test('★ 不截断时优先取 body 而不是 body_preview过短的那个', () => {
const both = mail({ body_preview: '预览很短', body: '全文' + 'z'.repeat(300) });
const full = renderMail(both, 0);
assert.ok(full.includes('全文'));
assert.ok(!full.includes('预览很短'));
// 截断模式(收件箱列表)仍然优先预览 —— 那才是它的用途
assert.match(renderMail(both, 5), /内容: 预览很短/);
});
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);
});