## 事故现场 用户选中补全里的「项目定位」→ 邮件投进另一条会话,界面显示的名字也不是 自己选的那个。授权页只显示 Agent 名,看不出哪个目录哪条线索。 ## 四处因果链 **① 调度器自己的 new_mail payload(起点)。** `notifyRecipients`(handler) 与 `SendCalendarMail`(scheduler)是两份代码。加 `platform_session_id` 时只改了 handler 那份 → 日历提醒投进接管会话时插件不知道是接管 → 另开一条新会话 → 命名同步冲掉接管会话的别名。 修法:抽出 `internal/notify` 包,唯一入口 `notify.Recipients`。 handler / scheduler / permission.go 都走它。新增字段时不存在「另一处忘了改」。 **② SyncSessionAlias 覆盖接管别名。** 别名是人从补全里选中的平台 slug, 任何平台命名同步都不该动它。加守卫 `platform_id <> ''` → 有绑定就返回当前值。 **③ SuggestSessionCandidates 按别名字符串去重。** 别名一被冲掉,同一条会话 出现两次(一次被冲的名字、一次镜像 slug),而另一条真实会话被吃掉。 改按 `platform_id` 去重。 mail 侧查 `s.platform_id`,镜像侧查 `platform_id`。 **④ FindOrCreateDefaultSession 不排除接管会话。** 日历提醒省略 session 位 → FindOrCreateDefaultSession 挑中人显式指定的接管会话。加 `platform_id = ''` 条件。 ## 权限页 **CreatePermissionMail 不写 from_workspace。** `from_workspace` 存空串 → 前端 `g.path && ...` 不渲染 → 人只看到光秃的 Agent 名,不知道哪个目录 哪条线索在请求权限。修法:INSERT 时从 sessions.workspace 取。 **SSE payload 缺 session_alias。** permission.go 的 SSE 不走 notify 包(决策人 不是地址解析出的参与方),但 payload 也要带 `session_alias` → 前端拼出 `pi@/home/program/agentmail.别名`,而不是光秃的 `pi`。 **mailGroups.ts:path ← session_workspace。** `from_workspace` 对 Agent 存的是 Agent 名(历史遗留),不能当路径用。PermissionList 显示完整三段地址 `agent@path.alias`。 ## NarrowStack z-index 窄屏日历的星期表头(`sticky top-0 z-10`)穿透到二级页面之上。覆盖层 auto z-index 输给 z-10 → 底层组件的层叠穿透到覆盖层。 修法:底层容器加 `isolate`(isolation: isolate),自成层叠上下文; 覆盖层加 `z-10`。只给覆盖层加 z-index 只能治当前一处,底层再写更大的 z-index 又会复现。 ## homeagent SSE 自激振荡 根因:五处缺陷叠加,SSE 每 60 秒断一次 → Gateway 全量重放 → 再断 → 再重放。 1. `p.client`(60s Timeout)跑 SSE 长连接 → 新增 `sseClient`(无超时) 2. `InjectInputSync` 在读循环里同步调用 → 改为 `go p.handleNewMail(evt)` 3. `lastEventID` 无条件赋值,Gateway 重放时发旧 ID → 单调递增 `sseMaxID` 4. 无邮件级去重 → 补 `deliveredMails map[string]bool` 5. 手动 `[]byte` 管理:每次 `buf[lineStart:]` 缩小 cap → 最终 len==cap → Read 零长切片 → 满速空转。改 `bufio.Reader`。 ## 清库 保留 jianf + 4 个 Agent 密钥 + 模型范围配置。清掉 mails/sessions/ calendar_events/attachments/agent_platform_sessions/relayed_mails/ permission_requests/rate_limits。测试数据已全部清零。 ## 测试 - gateway 7 包全过;repo + 7 例(adopt_alias_test.go) - web 182 例(mailGroups 新增 session_workspace 断言) - 前端构建通过
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
groupMailsBySession,
|
||
isFlatGroup,
|
||
isPendingPermission,
|
||
splitByPermission,
|
||
countPendingPermissions,
|
||
groupPermissions
|
||
} from '../../src/lib/mailGroups';
|
||
import type { Mail } from '../../src/types';
|
||
|
||
/**
|
||
* 收件箱会话分组 + 授权请求独立分组。
|
||
*
|
||
* 这些用例锁的是生产实测过的形状:一个会话独占 17 封权限邮件,
|
||
* 把另外两个会话的信挤出视野。修法有两层 ——
|
||
* 权限请求整体移出收件箱(各归各的导航项),剩下的普通邮件按会话折叠。
|
||
*/
|
||
|
||
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',
|
||
to_name: 'jianf',
|
||
to_workspace: '',
|
||
cc_list: [],
|
||
subject: `主题 ${seq}`,
|
||
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
|
||
};
|
||
}
|
||
|
||
function perm(over: Partial<Mail> = {}): Mail {
|
||
return mail({ mail_type: 'permission_request', ...over });
|
||
}
|
||
|
||
describe('groupMailsBySession', () => {
|
||
it('把同一会话的多封邮件折成一组', () => {
|
||
const groups = groupMailsBySession([
|
||
mail({ session_id: 'a' }),
|
||
mail({ session_id: 'a' }),
|
||
mail({ session_id: 'a' }),
|
||
mail({ session_id: 'b' })
|
||
]);
|
||
expect(groups).toHaveLength(2);
|
||
expect(groups.find(g => g.sessionId === 'a')!.mails).toHaveLength(3);
|
||
});
|
||
|
||
it('组内按时间倒序,最新那封做组头', () => {
|
||
const old = mail({ session_id: 'a', created_at: '2026-09-03T08:00:00Z', subject: '旧' });
|
||
const mid = mail({ session_id: 'a', created_at: '2026-09-03T09:00:00Z', subject: '中' });
|
||
const now = mail({ session_id: 'a', created_at: '2026-09-03T10:00:00Z', subject: '新' });
|
||
const [g] = groupMailsBySession([mid, old, now]); // 故意乱序传入
|
||
expect(g.mails.map(m => m.subject)).toEqual(['新', '中', '旧']);
|
||
expect(g.latest.subject).toBe('新');
|
||
// 组标题取最新一封:会话主题随任务推进被改写,最新的最贴切
|
||
expect(g.subject).toBe('新');
|
||
});
|
||
|
||
it('组之间按最新邮件时间倒序', () => {
|
||
const groups = groupMailsBySession([
|
||
mail({ session_id: 'stale', created_at: '2026-09-01T10:00:00Z' }),
|
||
mail({ session_id: 'fresh', created_at: '2026-09-03T10:00:00Z' }),
|
||
mail({ session_id: 'mid', created_at: '2026-09-02T10:00:00Z' })
|
||
]);
|
||
expect(groups.map(g => g.sessionId)).toEqual(['fresh', 'mid', 'stale']);
|
||
});
|
||
|
||
it('同一时刻用 mail_id 兜底定序(SQLite 时间戳精度有限)', () => {
|
||
const ts = '2026-09-03T10:00:00Z';
|
||
const a = mail({ mail_id: 'aaa', session_id: 's', created_at: ts });
|
||
const z = mail({ mail_id: 'zzz', session_id: 's', created_at: ts });
|
||
const [g1] = groupMailsBySession([a, z]);
|
||
const [g2] = groupMailsBySession([z, a]);
|
||
// 两种输入顺序必须给出同一结果,否则「刷新一次顺序就变了」
|
||
expect(g1.mails.map(m => m.mail_id)).toEqual(g2.mails.map(m => m.mail_id));
|
||
expect(g1.mails[0].mail_id).toBe('zzz');
|
||
});
|
||
|
||
it('统计未读数', () => {
|
||
const [g] = groupMailsBySession([
|
||
mail({ session_id: 's', status: 'unread' }),
|
||
mail({ session_id: 's', status: 'unread' }),
|
||
mail({ session_id: 's', status: 'read' })
|
||
]);
|
||
expect(g.unreadCount).toBe(2);
|
||
});
|
||
|
||
it('别名取自最新一封;无别名给空串而不是 undefined', () => {
|
||
const [withAlias] = groupMailsBySession([
|
||
mail({ session_id: 's', session_alias: 'deploy-review' })
|
||
]);
|
||
expect(withAlias.alias).toBe('deploy-review');
|
||
const [without] = groupMailsBySession([mail({ session_id: 's' })]);
|
||
expect(without.alias).toBe('');
|
||
});
|
||
|
||
it('session_id 缺失的脏数据各自成组而不是挤成一堆', () => {
|
||
const groups = groupMailsBySession([
|
||
mail({ mail_id: 'x', session_id: '' }),
|
||
mail({ mail_id: 'y', session_id: '' })
|
||
]);
|
||
// 全归到 '' 这一组会把两封无关的信显示成一个会话
|
||
expect(groups).toHaveLength(2);
|
||
});
|
||
|
||
it('created_at 解析失败不会让顺序变得不确定', () => {
|
||
const bad = mail({ mail_id: 'bad', session_id: 's', created_at: '不是时间' });
|
||
const good = mail({ mail_id: 'good', session_id: 's', created_at: '2026-09-03T10:00:00Z' });
|
||
const [g] = groupMailsBySession([bad, good]);
|
||
// NaN 参与比较恒为 false,会让排序结果取决于原数组顺序
|
||
expect(g.mails.map(m => m.mail_id)).toEqual(['good', 'bad']);
|
||
});
|
||
|
||
it('不修改入参数组', () => {
|
||
const input = [
|
||
mail({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
|
||
mail({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
|
||
];
|
||
const snapshot = input.map(m => m.mail_id);
|
||
groupMailsBySession(input);
|
||
expect(input.map(m => m.mail_id)).toEqual(snapshot);
|
||
});
|
||
|
||
it('空数组返回空数组', () => {
|
||
expect(groupMailsBySession([])).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('isFlatGroup', () => {
|
||
it('单封邮件的组平铺显示,不套折叠头', () => {
|
||
const [g] = groupMailsBySession([mail({ session_id: 'solo' })]);
|
||
expect(isFlatGroup(g)).toBe(true);
|
||
});
|
||
|
||
it('两封以上才折叠', () => {
|
||
const [g] = groupMailsBySession([mail({ session_id: 's' }), mail({ session_id: 's' })]);
|
||
expect(isFlatGroup(g)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('splitByPermission', () => {
|
||
it('权限请求与普通邮件分开', () => {
|
||
const { normal, permissions } = splitByPermission([
|
||
mail({ subject: '来信' }),
|
||
perm({ subject: '要跑 bash' }),
|
||
mail({ subject: '又一封' })
|
||
]);
|
||
expect(normal.map(m => m.subject)).toEqual(['来信', '又一封']);
|
||
expect(permissions.map(m => m.subject)).toEqual(['要跑 bash']);
|
||
});
|
||
|
||
it('保持原有顺序(调用方自己排序)', () => {
|
||
const a = mail({ mail_id: 'a' });
|
||
const b = mail({ mail_id: 'b' });
|
||
expect(splitByPermission([a, b]).normal.map(m => m.mail_id)).toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('空输入给两个空数组而不是 undefined', () => {
|
||
const r = splitByPermission([]);
|
||
expect(r.normal).toEqual([]);
|
||
expect(r.permissions).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('isPendingPermission', () => {
|
||
it('普通邮件不是权限请求', () => {
|
||
expect(isPendingPermission(mail())).toBe(false);
|
||
});
|
||
|
||
it('permission_result 为空串或 null 都算未决策', () => {
|
||
// 后端用 COALESCE(permission_result,'') 归一,两种都会出现
|
||
expect(isPendingPermission(perm({ permission_result: null }))).toBe(true);
|
||
expect(isPendingPermission(perm({ permission_result: '' }))).toBe(true);
|
||
});
|
||
|
||
it('有决策结果就不再是待决策', () => {
|
||
expect(isPendingPermission(perm({ permission_result: '拒绝' }))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('countPendingPermissions', () => {
|
||
it('只数待决策的那些', () => {
|
||
const n = countPendingPermissions([
|
||
perm({ permission_result: null }),
|
||
perm({ permission_result: '' }),
|
||
perm({ permission_result: '同意' }),
|
||
mail()
|
||
]);
|
||
expect(n).toBe(2);
|
||
});
|
||
|
||
it('没有待决策时给 0', () => {
|
||
expect(countPendingPermissions([perm({ permission_result: '同意' })])).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('groupPermissions', () => {
|
||
it('只收权限请求,普通邮件不进来', () => {
|
||
const groups = groupPermissions([
|
||
mail({ session_id: 'a' }),
|
||
perm({ session_id: 'a' }),
|
||
perm({ session_id: 'a' })
|
||
]);
|
||
expect(groups).toHaveLength(1);
|
||
expect(groups[0].pending.length + groups[0].settled.length).toBe(2);
|
||
});
|
||
|
||
it('待决策与已决策分开装', () => {
|
||
const [g] = groupPermissions([
|
||
perm({ session_id: 's', permission_result: null, subject: '在等' }),
|
||
perm({ session_id: 's', permission_result: '同意', subject: '批过' }),
|
||
perm({ session_id: 's', permission_result: '拒绝', subject: '拒过' })
|
||
]);
|
||
expect(g.pending.map(m => m.subject)).toEqual(['在等']);
|
||
expect(g.settled.map(m => m.subject).sort()).toEqual(['批过', '拒过']);
|
||
});
|
||
|
||
it('有待决策的会话排在前面,哪怕它更旧', () => {
|
||
const groups = groupPermissions([
|
||
// 刚刚批完的
|
||
perm({ session_id: 'done', permission_result: '同意', created_at: '2026-09-03T12:00:00Z' }),
|
||
// 三天前就卡着的 —— 那条 Agent 会话正在那儿等
|
||
perm({ session_id: 'stuck', permission_result: null, created_at: '2026-08-31T09:00:00Z' })
|
||
]);
|
||
expect(groups.map(g => g.sessionId)).toEqual(['stuck', 'done']);
|
||
});
|
||
|
||
it('都有待决策时,卡住更多请求的会话更靠前', () => {
|
||
const groups = groupPermissions([
|
||
perm({ session_id: 'one', permission_result: null, created_at: '2026-09-03T12:00:00Z' }),
|
||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:00:00Z' }),
|
||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:01:00Z' }),
|
||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:02:00Z' })
|
||
]);
|
||
expect(groups[0].sessionId).toBe('many');
|
||
});
|
||
|
||
it('都无待决策时按最新时间倒序', () => {
|
||
const groups = groupPermissions([
|
||
perm({ session_id: 'old', permission_result: '同意', created_at: '2026-09-01T10:00:00Z' }),
|
||
perm({ session_id: 'new', permission_result: '同意', created_at: '2026-09-03T10:00:00Z' })
|
||
]);
|
||
expect(groups.map(g => g.sessionId)).toEqual(['new', 'old']);
|
||
});
|
||
|
||
it('组头带上发起请求的 Agent 与会话工作目录', () => {
|
||
const [g] = groupPermissions([
|
||
perm({
|
||
session_id: 's',
|
||
from_name: 'dsh',
|
||
// from_workspace 对 Agent 存的是 **Agent 名**而不是路径(历史遗留)。
|
||
// 拿它当路径用会在授权页拼出 `dsh@dsh`,而权限请求的
|
||
// from_workspace 实测是**空串** —— 于是那一行永远不渲染,
|
||
// 人根本不知道是哪个目录里的哪条线索在请求权限。
|
||
from_workspace: 'dsh',
|
||
session_workspace: '/home/program/llmsproxy'
|
||
})
|
||
]);
|
||
// 同名 Agent 在不同目录是不同的活,光有名字判断不了
|
||
expect(g.agentName).toBe('dsh');
|
||
// path 必须取 session_workspace(会话的 workspace,权威来源)
|
||
expect(g.path).toBe('/home/program/llmsproxy');
|
||
});
|
||
|
||
it('组内时间倒序', () => {
|
||
const [g] = groupPermissions([
|
||
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T08:00:00Z', subject: '早' }),
|
||
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T10:00:00Z', subject: '晚' })
|
||
]);
|
||
expect(g.pending.map(m => m.subject)).toEqual(['晚', '早']);
|
||
});
|
||
|
||
it('没有权限请求时返回空数组', () => {
|
||
expect(groupPermissions([mail(), mail()])).toEqual([]);
|
||
});
|
||
|
||
it('不修改入参数组', () => {
|
||
const input = [
|
||
perm({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
|
||
perm({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
|
||
];
|
||
const snapshot = input.map(m => m.mail_id);
|
||
groupPermissions(input);
|
||
expect(input.map(m => m.mail_id)).toEqual(snapshot);
|
||
});
|
||
});
|
||
|
||
describe('生产实测形状:17 封权限邮件的会话', () => {
|
||
it('权限请求移出收件箱后,剩下的信按会话分组且不再被淹', () => {
|
||
const flood: Mail[] = [];
|
||
for (let i = 0; i < 17; i++) {
|
||
flood.push(
|
||
perm({
|
||
session_id: 'f3ce62b0',
|
||
permission_result: i === 16 ? null : '同意',
|
||
created_at: `2026-09-03T09:${String(i).padStart(2, '0')}:00Z`
|
||
})
|
||
);
|
||
}
|
||
const realMail = [
|
||
mail({ session_id: 'fa420c33', created_at: '2026-09-03T10:00:00Z', subject: '进展汇报' }),
|
||
mail({ session_id: 'c2db6b62', created_at: '2026-09-03T11:00:00Z', subject: '需要确认' })
|
||
];
|
||
const inbox = [...flood, ...realMail];
|
||
|
||
// 收件箱侧:权限请求整体不进来,只剩两封真信
|
||
const { normal, permissions } = splitByPermission(inbox);
|
||
expect(normal).toHaveLength(2);
|
||
expect(permissions).toHaveLength(17);
|
||
const inboxGroups = groupMailsBySession(normal);
|
||
expect(inboxGroups.map(g => g.subject)).toEqual(['需要确认', '进展汇报']);
|
||
|
||
// 授权侧:17 封折成 1 组,只有 1 个在等人
|
||
const permGroups = groupPermissions(permissions);
|
||
expect(permGroups).toHaveLength(1);
|
||
expect(permGroups[0].pending).toHaveLength(1);
|
||
expect(permGroups[0].settled).toHaveLength(16);
|
||
expect(countPendingPermissions(permissions)).toBe(1);
|
||
});
|
||
});
|