## 事故现场 用户选中补全里的「项目定位」→ 邮件投进另一条会话,界面显示的名字也不是 自己选的那个。授权页只显示 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 断言) - 前端构建通过
275 lines
10 KiB
Go
275 lines
10 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
)
|
||
|
||
// ─── 接管会话的别名不可被平台命名同步覆盖 ───
|
||
//
|
||
// 锁的是一次生产事故的**第二环**(第一环是调度器漏 platform_session_id):
|
||
//
|
||
// 12:12 人选中补全里的「项目定位」→ 接管平台会话 01a05a5e,本侧建 26e26477
|
||
// 12:20 日历提醒省略 session 位 → 落进 26e26477(第三环,见 TestDefaultSession...)
|
||
// 12:20 插件收不到 platform_session_id → 另开一条 pi 会话
|
||
// 12:20 那条新会话的名字经 /sessions/{id}/sync 回写
|
||
// → SyncSessionAlias 把 26e26477 的别名冲成「日程提醒:…」
|
||
//
|
||
// 结果:人在补全里选的名字凭空消失,同一条会话在候选列表里出现两次
|
||
// (一次用被冲掉的别名、一次用镜像里的原始 slug),而另一条真实会话被
|
||
// 按别名字符串去重吃掉了。
|
||
//
|
||
// 接管会话的别名是**人从补全里选中的平台 slug**,任何平台命名同步都不该动它。
|
||
func TestSyncSessionAliasNeverOverwritesAdoptedAlias(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
seedAgent(t, "pi", 20)
|
||
|
||
id, err := AdoptPlatformSession(ctx, "pi", "01a05a5e", "项目定位", "/home/program/agentmail", "标题")
|
||
if err != nil {
|
||
t.Fatalf("接管: %v", err)
|
||
}
|
||
if got := SessionAliasOf(ctx, id); got != "项目定位" {
|
||
t.Fatalf("接管后别名 = %q,期望 项目定位", got)
|
||
}
|
||
|
||
// 平台侧同步一个完全不同的名字(生产上就是日历提醒的主题)
|
||
final, err := SyncSessionAlias(ctx, id, "日程提醒:小宅自测")
|
||
if err != nil {
|
||
t.Fatalf("SyncSessionAlias: %v", err)
|
||
}
|
||
if final != "项目定位" {
|
||
t.Errorf("同步返回 %q —— 接管会话的别名不该被改", final)
|
||
}
|
||
if got := SessionAliasOf(ctx, id); got != "项目定位" {
|
||
t.Errorf("库里别名变成了 %q —— 人在补全里选的名字被冲掉了", got)
|
||
}
|
||
}
|
||
|
||
// 普通(非接管)会话仍然接受平台命名同步 —— 别名复用平台命名是既定决策,
|
||
// 上面那道门不能把它一起关掉。
|
||
func TestSyncSessionAliasStillWorksForNormalSession(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
seedAgent(t, "pi", 20)
|
||
|
||
id, err := CreateSession(ctx, nil, "pi", "邮件驱动的会话", "/tmp/ws")
|
||
if err != nil {
|
||
t.Fatalf("建会话: %v", err)
|
||
}
|
||
if _, err := EnsureSessionAlias(ctx, id, "pi-初始别名"); err != nil {
|
||
t.Fatalf("EnsureSessionAlias: %v", err)
|
||
}
|
||
|
||
final, err := SyncSessionAlias(ctx, id, "平台生成的名字")
|
||
if err != nil {
|
||
t.Fatalf("SyncSessionAlias: %v", err)
|
||
}
|
||
if final != "平台生成的名字" {
|
||
t.Errorf("普通会话应当接受同步,得到 %q", final)
|
||
}
|
||
}
|
||
|
||
// 接管会话**没有**别名时(理论上不会发生,AdoptPlatformSession 一定给一个)
|
||
// 仍然允许写入 —— 否则那条会话永远无法寻址。
|
||
func TestSyncSessionAliasFillsEmptyAdoptedAlias(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
seedAgent(t, "pi", 20)
|
||
|
||
id, err := CreateSession(ctx, nil, "pi", "标题", "/tmp/ws")
|
||
if err != nil {
|
||
t.Fatalf("建会话: %v", err)
|
||
}
|
||
// 手工造出「有 platform_id 但无别名」的状态
|
||
if _, err := db.DB.ExecContext(ctx,
|
||
`UPDATE sessions SET platform_id = 'pid-x' WHERE session_id = $1`, id); err != nil {
|
||
t.Fatalf("置 platform_id: %v", err)
|
||
}
|
||
|
||
final, err := SyncSessionAlias(ctx, id, "补上一个名字")
|
||
if err != nil {
|
||
t.Fatalf("SyncSessionAlias: %v", err)
|
||
}
|
||
if final != "补上一个名字" {
|
||
t.Errorf("无别名的接管会话应当允许写入,得到 %q", final)
|
||
}
|
||
}
|
||
|
||
// ─── 接管会话不是「默认会话」 ───
|
||
//
|
||
// 事故的**第三环**:日历提醒的收件地址省略 session 位(`homeagent` 而不是
|
||
// `homeagent@/x.某会话`),走 FindOrCreateDefaultSession。它原来只按
|
||
// 「参与过 + workspace 匹配 + 未归档」挑最近活跃的一条 —— 于是挑中了人
|
||
// 刚刚显式指定的那条接管会话。
|
||
//
|
||
// 接管会话是人**点名**要谈的一条线索,不该被省略 session 位的邮件当默认会话。
|
||
func TestFindOrCreateDefaultSessionSkipsAdoptedSessions(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
seedAgent(t, "pi", 20)
|
||
|
||
// 一条接管会话,且有邮件(满足 EXISTS 条件)
|
||
adopted, err := AdoptPlatformSession(ctx, "pi", "pid-adopted", "人选的线索", "/home/program/agentmail", "标题")
|
||
if err != nil {
|
||
t.Fatalf("接管: %v", err)
|
||
}
|
||
if _, err := CreateMail(ctx, adopted, nil, "jianf", "", "pi", "/home/program/agentmail",
|
||
"人发的第一封", "正文", nil); err != nil {
|
||
t.Fatalf("建邮件: %v", err)
|
||
}
|
||
|
||
// 省略 session 位投递 → 不该落进那条接管会话
|
||
got, err := FindOrCreateDefaultSession(ctx, "pi", "/home/program/agentmail", "calendar", "日程提醒")
|
||
if err != nil {
|
||
t.Fatalf("FindOrCreateDefaultSession: %v", err)
|
||
}
|
||
if got == adopted {
|
||
t.Error("省略 session 位的邮件落进了接管会话 —— 那是人显式指定的线索")
|
||
}
|
||
|
||
// 该新建一条,且它不带 platform_id
|
||
if pid := PlatformIDOf(ctx, got); pid != "" {
|
||
t.Errorf("新建的默认会话不该有 platform_id,得到 %q", pid)
|
||
}
|
||
}
|
||
|
||
// 普通会话仍然可以作为默认会话被复用 —— 上面那道门不能把它一起关掉,
|
||
// 否则每封省略 session 位的邮件都会新开一条会话。
|
||
func TestFindOrCreateDefaultSessionStillReusesNormalSession(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
seedAgent(t, "pi", 20)
|
||
|
||
first, err := FindOrCreateDefaultSession(ctx, "pi", "/tmp/ws", "jianf", "第一封")
|
||
if err != nil {
|
||
t.Fatalf("第一次: %v", err)
|
||
}
|
||
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/tmp/ws", "第一封", "正文", nil); err != nil {
|
||
t.Fatalf("建邮件: %v", err)
|
||
}
|
||
|
||
second, err := FindOrCreateDefaultSession(ctx, "pi", "/tmp/ws", "jianf", "第二封")
|
||
if err != nil {
|
||
t.Fatalf("第二次: %v", err)
|
||
}
|
||
if second != first {
|
||
t.Error("普通默认会话应当被复用,否则每封省略 session 位的邮件都开新会话")
|
||
}
|
||
}
|
||
|
||
// ─── 候选列表按 platform_id 去重 ───
|
||
//
|
||
// 事故的**第四环**:`SuggestSessionCandidates` 的去重只比别名字符串。
|
||
// 别名一被冲掉,同一条会话就在列表里出现两次:
|
||
//
|
||
// 候选 1 日程提醒:…(被冲掉的别名) source=mail ← 26e26477
|
||
// 候选 2 项目定位(镜像里的原始 slug) source=platform ← 也是 26e26477
|
||
//
|
||
// 更糟的是**另一条真实会话被吃掉了**:它的 slug 恰好等于候选 1 那个
|
||
// 被冲掉的别名,于是 `seen[slug]` 命中、被 continue 跳过。
|
||
// 人在界面上看到两条,实际只有一条能选,而第三条不存在于列表里。
|
||
func TestSuggestSessionCandidatesDedupesByPlatformID(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
seedPlatformAgent(t, "pi")
|
||
|
||
// 接管一条平台会话
|
||
adopted, err := AdoptPlatformSession(ctx, "pi", "01a05a5e", "项目定位", "/home/program/agentmail", "标题")
|
||
if err != nil {
|
||
t.Fatalf("接管: %v", err)
|
||
}
|
||
if _, err := CreateMail(ctx, adopted, nil, "jianf", "", "pi", "/home/program/agentmail",
|
||
"主题", "正文", nil); err != nil {
|
||
t.Fatalf("建邮件: %v", err)
|
||
}
|
||
|
||
// 镜像里同时有它与另一条真实会话
|
||
now := time.Now()
|
||
if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{
|
||
{PlatformID: "01a05a5e", Workspace: "/home/program/agentmail", Slug: "项目定位",
|
||
Title: "项目定位", MailDriven: true, UpdatedAt: &now},
|
||
{PlatformID: "01a06aa5", Workspace: "/home/program/agentmail", Slug: "另一条真实会话",
|
||
Title: "另一条", MailDriven: true, UpdatedAt: &now},
|
||
}); err != nil {
|
||
t.Fatalf("上报镜像: %v", err)
|
||
}
|
||
|
||
got, err := SuggestSessionCandidates(ctx, "jianf", "pi", "/home/program/agentmail")
|
||
if err != nil {
|
||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||
}
|
||
|
||
// 应当恰好两条:接管那条(mail 来源)+ 另一条真实会话(platform 来源)
|
||
if len(got) != 2 {
|
||
t.Fatalf("应有 2 个候选,实际 %d:%+v", len(got), got)
|
||
}
|
||
|
||
byAlias := map[string]SessionCandidate{}
|
||
for _, c := range got {
|
||
byAlias[c.Alias] = c
|
||
}
|
||
if c, ok := byAlias["项目定位"]; !ok {
|
||
t.Error("接管会话应当在候选里")
|
||
} else if c.Source != "mail" {
|
||
t.Errorf("接管会话的来源应是 mail(保证送得到),得到 %q", c.Source)
|
||
}
|
||
if c, ok := byAlias["另一条真实会话"]; !ok {
|
||
t.Error("另一条真实会话被吃掉了 —— 那正是 bug 的表现")
|
||
} else if c.Source != "platform" {
|
||
t.Errorf("未接管的平台会话来源应是 platform,得到 %q", c.Source)
|
||
}
|
||
}
|
||
|
||
// 别名被冲掉之后也不该出现重复项。
|
||
//
|
||
// 这是事故现场的**精确复现**:本侧别名与镜像 slug 不一致(别名被另一条会话的
|
||
// 命名同步冲掉了),此时按别名字符串去重必然漏,只有按 platform_id 才对。
|
||
func TestSuggestSessionCandidatesNoDupWhenAliasDiverged(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
seedPlatformAgent(t, "pi")
|
||
|
||
adopted, err := AdoptPlatformSession(ctx, "pi", "01a05a5e", "项目定位", "/home/program/agentmail", "标题")
|
||
if err != nil {
|
||
t.Fatalf("接管: %v", err)
|
||
}
|
||
if _, err := CreateMail(ctx, adopted, nil, "jianf", "", "pi", "/home/program/agentmail",
|
||
"主题", "正文", nil); err != nil {
|
||
t.Fatalf("建邮件: %v", err)
|
||
}
|
||
// 模拟别名被冲掉(绕过 SyncSessionAlias 的守卫直接改库 ——
|
||
// 存量数据里可能已经有这种状态)
|
||
if _, err := db.DB.ExecContext(ctx,
|
||
`UPDATE sessions SET session_alias = $1 WHERE session_id = $2`,
|
||
"日程提醒:小宅自测", adopted); err != nil {
|
||
t.Fatalf("改别名: %v", err)
|
||
}
|
||
|
||
now := time.Now()
|
||
if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{
|
||
{PlatformID: "01a05a5e", Workspace: "/home/program/agentmail", Slug: "项目定位",
|
||
Title: "项目定位", MailDriven: true, UpdatedAt: &now},
|
||
}); err != nil {
|
||
t.Fatalf("上报镜像: %v", err)
|
||
}
|
||
|
||
got, err := SuggestSessionCandidates(ctx, "jianf", "pi", "/home/program/agentmail")
|
||
if err != nil {
|
||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||
}
|
||
|
||
// 只有一条会话,就该只有一个候选 —— 别名分叉不该让它变成两个
|
||
if len(got) != 1 {
|
||
t.Fatalf("同一条会话应只有 1 个候选,实际 %d:%+v", len(got), got)
|
||
}
|
||
if got[0].Source != "mail" {
|
||
t.Errorf("应保留 mail 来源(它保证送得到),得到 %q", got[0].Source)
|
||
}
|
||
}
|
||
|
||
// 存量数据里可能已经有这种状态(守卫是后加的)。
|