**授权请求不是「一封信」而是「一件待办」。** 生产取证:一个 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 六项全过。
242 lines
9.2 KiB
TypeScript
242 lines
9.2 KiB
TypeScript
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']);
|
||
});
|
||
});
|