feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)

L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
This commit is contained in:
2026-09-06 15:16:49 +08:00
parent 13fcb00acc
commit a44fd6949b
32 changed files with 3462 additions and 177 deletions

View File

@ -0,0 +1,162 @@
package main
import (
"fmt"
"testing"
)
// 这些用例钉住的是 bounded.go 的三条性质封顶、FIFO、以及
// 「add 的返回值就是查重结果」—— 后者让调用方能在一把锁里查重 + 登记。
func TestBoundedSetCapsSize(t *testing.T) {
s := newBoundedIDSet(3)
for _, id := range []string{"a", "b", "c", "d", "e"} {
s.add(id)
}
if s.size() != 3 {
t.Fatalf("上限之后 size 必须封顶,得到 %d —— 这正是泄露的反面", s.size())
}
if s.has("a") || s.has("b") {
t.Error("最早插入的两条应当被淘汰")
}
for _, id := range []string{"c", "d", "e"} {
if !s.has(id) {
t.Errorf("%s 应当还在", id)
}
}
if s.evicted != 2 {
t.Errorf("evicted 应为 2得到 %d", s.evicted)
}
}
func TestBoundedSetAddReportsFreshness(t *testing.T) {
// 调用方plugin.go 的两处去重)依赖这个返回值在同一把锁里完成
// 「查重 + 登记」。分两步做需要两次加锁,中间那个窗口正是原来的竞态。
s := newBoundedIDSet(10)
if !s.add("m1") {
t.Error("首次 add 应当返回 true新加入")
}
if s.add("m1") {
t.Error("重复 add 应当返回 false已存在")
}
if s.size() != 1 {
t.Errorf("重复 add 不该占额外位置size=%d", s.size())
}
}
func TestBoundedSetFIFONotLRU(t *testing.T) {
// Go 的 map 不保证遍历顺序,所以这里是显式的 FIFO 而不是 LRU。
// 这条用例把那个决定钉住:反复 has 不会让条目留得更久。
s := newBoundedIDSet(2)
s.add("a")
s.add("b")
for i := 0; i < 5; i++ {
s.has("a") // 在 LRU 语义下这会保住 a
}
s.add("c")
if s.has("a") {
t.Error("FIFO 语义下最早插入的 a 应当被淘汰has() 不刷新活跃度")
}
if !s.has("b") || !s.has("c") {
t.Error("b 与 c 应当都在")
}
}
func TestBoundedSetReAddDoesNotRefreshPosition(t *testing.T) {
// 重复 add 也不改变位置FIFO 由首次插入决定)。写成「已存在时移到队尾」
// 会让一条被反复推送的邮件把别的条目挤出去。
s := newBoundedIDSet(2)
s.add("a")
s.add("b")
s.add("a") // 已存在,位置不变
s.add("c")
if s.has("a") {
t.Error("a 是最早插入的,重复 add 不该救回它")
}
}
func TestBoundedSetIgnoresEmptyID(t *testing.T) {
// 空 mail_id 是「事件残缺」而不是「一封 id 为空的邮件」。
// 记住它会让第二封残缺事件被误判成重复。
s := newBoundedIDSet(5)
if s.add("") {
t.Error("空 id 不该被记住")
}
if s.has("") {
t.Error("空 id 永远不算已见过")
}
if s.size() != 0 {
t.Errorf("空 id 不该占位置size=%d", s.size())
}
}
func TestBoundedSetNilSafe(t *testing.T) {
// 构造函数失败或字段没初始化时不该 panic —— 去重是优化项,
// 退化成「什么都不记得」(多几次重复投递)远好过插件崩溃。
var s *boundedIDSet
if s.has("x") {
t.Error("nil 上 has 应为 false")
}
if s.add("x") {
t.Error("nil 上 add 应为 false")
}
if s.size() != 0 {
t.Error("nil 上 size 应为 0")
}
}
func TestBoundedSetIllegalLimitFallsBackToOne(t *testing.T) {
// 上限非法时回落到 1 而不是 panic 或 0。
// 0 的后果最隐蔽:每次 add 之后立刻把自己淘汰掉 → 去重全失效且不报错。
for _, limit := range []int{0, -1, -100} {
s := newBoundedIDSet(limit)
s.add("a")
if !s.has("a") {
t.Errorf("limit=%d刚加入的那条必须还在回落到 1而不是 0", limit)
}
s.add("b")
if s.size() != 1 {
t.Errorf("limit=%dsize 应为 1得到 %d", limit, s.size())
}
}
}
func TestBoundedSetCompactsOrderSlice(t *testing.T) {
// order 切片的底层数组不能随插入次数无限增长 —— 那正是这张表要修的病,
// 只是换了个地方map 有界了,切片没有)。
s := newBoundedIDSet(10)
for i := 0; i < 10_000; i++ {
s.add(fmt.Sprintf("mail-%d", i))
}
if s.size() != 10 {
t.Fatalf("size 应当封顶在 10得到 %d", s.size())
}
// 压实之后 order 的有效长度不该远大于上限
if live := len(s.order) - s.head; live > 10 {
t.Errorf("order 有效长度 %d 超过上限 10", live)
}
if len(s.order) > 10*4 {
t.Errorf("order 底层长度 %d 相对上限 10 增长失控(压实没生效)", len(s.order))
}
if cap(s.order) > 10*8 {
t.Errorf("order 容量 %d 相对上限 10 增长失控", cap(s.order))
}
// 最新的必须还在,最老的必须没了
if !s.has("mail-9999") {
t.Error("最新的那条必须还在")
}
if s.has("mail-0") {
t.Error("最老的那条必须已被淘汰")
}
if s.evicted != 9990 {
t.Errorf("evicted 应为 9990得到 %d", s.evicted)
}
}
func TestBoundedSetLimitMatchesNodeSide(t *testing.T) {
// 与 Node 侧 lib/bounded.js 的 MAX_TRACKED_MAILS 取同一个数。
// 四个平台在同一套语义下运行,一侧偷偷调小会让「重复投递」只在那个平台出现。
if maxTrackedMails != 2000 {
t.Errorf("maxTrackedMails 应为 2000与 Node 侧一致),得到 %d", maxTrackedMails)
}
}