fix(web): 授权独立成导航项 + 收件箱按会话分组 + 回复对端判定

**授权请求不是「一封信」而是「一件待办」。**

生产取证:一个 pi 会话独占 17 封权限邮件(后涨到 39),另外两个会话各
3 封 / 1 封 —— 收件箱被一件事塞满,失去了它唯一的作用(让人知道有哪几件
事在等我)。

第一版做成会话折叠,用户纠正后重做:权限请求的生命周期是「等人点头 →
决策完就作废」,与普通邮件混在一个箱子里两者互相伤害。收件箱只放要读的,
授权项只放要批的。

`groupPermissions` 的排序判据是「要不要我动手」而非时间:三天前发起、
至今还卡着的授权比十分钟前刚批完的重要得多。纯按时间排会把它压到底部,
而 Agent 那条会话正在等 —— 那正是权限死锁在 UI 上的样子。

未读徽标用红色、待决策用橙色,且两个数字**互不重复计数**:未读是
「有内容没看」,待决策是「有 Agent 卡着等我」。

---

**回复发给自己的 bug(用户报)。**

根因:ReplyBar 的对端判定写死 `from_name === 'human'` —— 单用户时代遗留
(当时人类只有 human@ 一个身份)。登录名是 jianf 时判据恒为假,
于是取 from_name(自己)。

生产链条:`27c22900 jianf→pi` 对(锚点是 pi 发来的),
`8e519925 jianf→jianf` 错(锚点是自己发的)。

两处修正:
1. **判据必须是当前登录用户名**,不是字面量 'human'。同一遗留判据在三处:
   对端解析 / replyAll 去自己 / ThreadCard 图标。
2. **会话视图的回复对端是会话的属性,不看任何单封邮件。**
   人在那儿打字就是「给这次任务的对方追加一句」;用「最后一封」当锚点时,
   自己刚发过信就会把自己算成对端。sessionCounterpart 扫全会话取首个
   非我参与方(收件人优先于发件人,同刻用 mail_id 定序)。

ThreadCard 顺带显示真实发件人名而不是统一渲染成 'human' —— 会话里可能有
多个人类参与方。

测试:mailGroups 30 例(含「生产实测形状:17 封权限邮件」)、
replyTarget 24 例(含生产链条重现:断言 target 以 pi@ 开头而非 jianf@)。
手工验收脚本 inbox-group-verify.mjs 六项全过。
This commit is contained in:
2026-09-04 06:29:21 +08:00
parent 5b76ac5c57
commit e504eccf3a
9 changed files with 1572 additions and 65 deletions

View File

@ -0,0 +1,241 @@
import { describe, it, expect } from 'vitest';
import {
formatAddress,
mailCounterpart,
sessionCounterpart,
sessionReplyTarget,
mailReplyTarget,
replyAllCC
} from '../../src/lib/replyTarget';
import type { Mail, Session } from '../../src/types';
/**
* 「这封回复该发给谁」。
*
* 锁的是一次生产事故:判据写死 `from_name === 'human'`(单用户时代的遗留),
* 多用户下登录名是 jianf判据恒为假 → 对端取成 from_name自己→ 信发给自己。
* 数据库里的链条:
* pi → jianf 权限请求
* jianf → pi Re: 权限请求 (对的,锚点是 pi 发来的)
* pi → jianf 权限请求
* jianf → jianf Re: Re: 权限请求 (错的,锚点是自己发的)
*/
let seq = 0;
function mail(over: Partial<Mail> = {}): Mail {
seq += 1;
return {
mail_id: `m${String(seq).padStart(3, '0')}`,
session_id: 's1',
parent_mail_id: null,
from_name: 'pi',
from_workspace: '/home/program/llmsproxy',
to_name: 'jianf',
to_workspace: '',
cc_list: [],
subject: '主题',
body: '正文',
mail_type: 'normal',
permission_options: null,
permission_result: null,
status: 'read',
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
...over
};
}
const session = (alias: string | null): Session => ({
session_id: 's1',
session_alias: alias,
from_agent: 'jianf',
subject: '关于llmsproxy工程的联合审查',
status: 'active',
created_at: '2026-09-03T09:00:00Z',
updated_at: '2026-09-03T10:00:00Z'
});
describe('formatAddress', () => {
it('三段齐全', () => {
expect(formatAddress('pi', '/home', 'deploy')).toBe('pi@/home.deploy');
});
it('无会话段时省略', () => {
expect(formatAddress('pi', '/home')).toBe('pi@/home');
expect(formatAddress('pi', '/home', null)).toBe('pi@/home');
});
it('path 为空仍保留 @(人类没有工作目录)', () => {
expect(formatAddress('jianf', '')).toBe('jianf@');
});
it('名字为空给空串而不是拼出 @', () => {
expect(formatAddress('', '/home')).toBe('');
});
});
describe('mailCounterpart', () => {
it('别人发来的 → 回给发件人', () => {
const m = mail({ from_name: 'pi', to_name: 'jianf' });
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
});
it('我发出的 → 回给收件人(不是回给自己)', () => {
const m = mail({
from_name: 'jianf',
from_workspace: '',
to_name: 'pi',
to_workspace: '/home/program/llmsproxy'
});
// 这一条就是生产 bug原代码在这里返回 jianf
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
});
it('登录名未知时退化为回给发件人,不会回给自己', () => {
const m = mail({ from_name: 'pi', to_name: 'jianf' });
expect(mailCounterpart(m, '').name).toBe('pi');
});
it('不把 human 当特殊值', () => {
// 老判据写死 human现在它只是个普通名字
const m = mail({ from_name: 'human', to_name: 'pi' });
expect(mailCounterpart(m, 'jianf').name).toBe('human');
expect(mailCounterpart(m, 'human').name).toBe('pi');
});
});
describe('sessionCounterpart', () => {
it('按会话定对端,不受最后一封是谁发的影响', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }),
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }),
// 最后一封是我自己发的 —— 原代码在这里会把自己当对端
mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:00:00Z' })
];
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
});
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }),
mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
];
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
});
it('补上首次出现时缺失的 path', () => {
const mails = [
// 首封的 to_workspace 是空的
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
// 后一封才带上目录
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
];
// 同名 Agent 在不同目录是不同的活path 不能丢
expect(sessionCounterpart(mails, 'jianf')).toEqual({
name: 'pi',
path: '/home/program/llmsproxy'
});
});
it('同刻邮件用 mail_id 定序,结果稳定', () => {
const ts = '2026-09-03T09:00:00Z';
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: ts });
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: ts });
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
});
it('全是自己的会话返回 null 而不是自己', () => {
const mails = [mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf' })];
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
});
it('空会话返回 null', () => {
expect(sessionCounterpart([], 'jianf')).toBeNull();
});
it('created_at 解析失败不影响确定性', () => {
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '不是时间' });
const good = mail({ mail_id: 'good', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: '2026-09-03T09:00:00Z' });
expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe(
sessionCounterpart([good, bad], 'jianf')?.name
);
});
});
describe('sessionReplyTarget', () => {
it('带上会话别名:不带会落到该 Agent 的默认会话', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy' })
];
expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe(
'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查'
);
});
it('会话未命名时省略会话段', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home' })
];
expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home');
});
it('生产链条重现:回复自己发的那封仍指向 pi', () => {
const mails = [
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }),
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }),
mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:56:34Z' })
];
const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf');
expect(target.startsWith('pi@')).toBe(true);
expect(target.startsWith('jianf@')).toBe(false);
});
it('找不到对端时给空串(调用方据此禁用发送)', () => {
expect(sessionReplyTarget([], session('x'), 'jianf')).toBe('');
});
});
describe('mailReplyTarget', () => {
it('单封视图带上该封的会话别名', () => {
const m = mail({ from_name: 'pi', to_name: 'jianf', session_alias: 'deploy-review' });
expect(mailReplyTarget(m, 'jianf')).toBe('pi@/home/program/llmsproxy.deploy-review');
});
});
describe('replyAllCC', () => {
it('去掉自己与主收件人', () => {
const m = mail({
from_name: 'pi',
from_workspace: '/home',
to_name: 'jianf',
to_workspace: '',
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
});
// 主收件人是 pi回复对象自己是 jianf —— 都不该出现在抄送里
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
});
it('自己是 jianf 时不会把自己抄送进去(老判据只挡 human', () => {
const m = mail({ from_name: 'pi', from_workspace: '/home', to_name: 'jianf', to_workspace: '' });
expect(replyAllCC(m, 'jianf', 'pi')).toEqual([]);
});
it('cc_list 取 raw 保留会话段', () => {
const m = mail({
from_name: 'pi',
from_workspace: '/home',
to_name: 'jianf',
cc_list: [{ name: 'dsh', path: '/opt', session: 'audit', raw: 'dsh@/opt.audit' }]
});
// 重新拼 name@path 会丢掉 .audit
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.audit']);
});
it('同一个人既在 to 又在 cc 时只出现一次', () => {
const m = mail({
from_name: 'pi',
from_workspace: '/home',
to_name: 'dsh',
to_workspace: '/opt',
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
});
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
});
});