Files
MailUI4Agents/server/internal/handler/permission_decide_test.go
JianFeeeee bbddee26b9 feat(permission): 待办带上失效时刻;越窗的决策不再假装成功
# 起因:一次端到端验证暴露的静默缺口

建了示例工程让 pi 通过邮件干活(plan 档拦截、workspace 档审批、多 agent 指派)。
plan 档与多 agent 都通过,workspace 档却卡住:**人在界面上批准了一条待办,
接口回 200,但那件事什么都没发生。**

追下去是三件事叠在一起:

1. **桥**等不到决策时(pi 的回合超时 TURN_TIMEOUT_MS,默认 10 分钟)会拆掉 worker
   与它的决策路由表;此后再来的决策只会作为**通知**投给 Agent,不恢复当时那次
   工具调用 —— 该轮已经结束了。
2. **服务端**只有 `permission_requests.result IS NULL`,没有「失效」概念。
   迟到决策照样回 `{"status":"decided"}`。
3. **前端**只看 `permission_result` 判待决/已决,没有任何时间或失效提示。

于是那条待办永远挂在授权页上显示「等待你决策」,人点了也白点。这是 I-5
(失败必须当场可见)要消灭的那类静默成功,而且**跨所有客户端**成立 ——
WebUI 不显示,Electron / Harmony 同样无从显示。

# 设计:邮件上给「时刻」,不给「是否失效」的布尔值

服务端不知道插件此刻是否还在等(那是它进程内的状态),所以只标出「这封待办已经
放了很久」,不替插件宣布裁决。

关键取舍:对外只发**截止时刻**(`permission_expires_at`),不发 `stale` 布尔值。
布尔值是「发出那一刻」的快照 —— 经 SSE 推送并被客户端缓存后会永久停在旧值,
界面就会一直显示「等待你决策」。时刻是持久事实,任何客户端在任何时候都能自己
比出现在过没过期。这也是为什么推导而非落库:它是 created_at 的函数,存下来会失真。

`DecidePermission` 的响应里则用布尔值(`expired`)—— 响应本身就是「此刻」的
一次性快照,不会像邮件那样被缓存反复展示。

# 改动

- `models.PermissionWaitWindow`(10 分钟,与 pi 桥的回合超时同量级)+
  `PermissionDeadline(createdAt)`;两端共用这一处算式,避免「界面说已过期、
  决策说没过期」。
- `Mail.PermissionExpiresAt` / `PermissionRequest.ExpiresAt`:由读路径推导填充。
  5 个读路径各插一行(`AttachPermissionDeadline*`)—— 与审计修复① 加
  permission_kind 时同一套路数,漏掉任一路径只会静默变成 nil。
  只给**仍未决策**的待办填,已决策的不再是待办。
- `decideResponse`(抽出纯函数以便测试):越窗时加 `expired` + `warning`,
  讲清「决策已记录、但不会恢复原调用」。**不改 HTTP 状态码**:决策仍是人的真实
  意愿、仍然有效(桥会当通知投递,Agent 重起一轮),所以不能拒掉,但必须说清。
- 前端:列表里失效项不再与「还能立刻生效」的长得一样(灰底 + 「可能已失效」);
  批准面板在决策**前**(人正要按下去)与决策**后**(人以为事情办了)都显示提示。

# 验证

- Go:models/repo/handler 三处新增测试全绿;全量 `go test ./...` 通过;vet 通过
- 前端:typecheck 通过;200 项测试全绿(含新增 4 条失效态)
- 真机(用现成的过期待办,未造合成数据):
  - `/permission/pending` 返回 `expires_at` = 创建 + 10 分钟,服务端判定已过窗
  - 邮件载荷带上 `permission_expires_at`(前端列表的数据源)
  - 对过期待办提交批准 → `{"expired":true, "expires_at":…, "warning":"该请求已超过
    等待窗口(10 分钟)…不会恢复当时那次工具调用…"}`
- 已用 redeploy-gateway.sh 部署,服务 active、四 agent 心跳正常、日志无 panic
2026-09-11 22:02:25 +08:00

93 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"fmt"
"strings"
"testing"
"time"
"github.com/agentmail/gateway/internal/models"
)
// 越窗的决策必须如实告知「这次批准不会恢复原调用」。
//
// # 为什么需要这条测试
//
// 2026-09-11 端到端实测:人在界面上批准了一条待办,接口回 200但那件事什么都没
// 发生 —— 提出询问的桥在回合超时(默认 10 分钟)后已拆掉 worker 与决策路由表,
// 此后的决策只会作为通知投递给 Agent。接口当时照旧回 {"status":"decided"}
// 人无从知道自己的批准落到了一个已经没人等的请求上。
//
// 这条分支只有等满等待窗口才会走到,属于「人工点一遍很难覆盖」的路径,
// 所以把它抽成纯函数并在这里钉住两侧:窗口内不许误报,越窗必须报。
func TestDecideResponseFlagsExpiredRequest(t *testing.T) {
now := time.Date(2026, 9, 11, 20, 30, 0, 0, time.UTC)
const mailID = "decision-mail-id"
t.Run("窗口内:不报过期,且不出现 warning", func(t *testing.T) {
createdAt := now.Add(-2 * time.Minute)
resp := decideResponse(createdAt, now, mailID)
if resp["status"] != "decided" {
t.Errorf("status = %v期望 decided", resp["status"])
}
if resp["decision_mail_id"] != mailID {
t.Errorf("decision_mail_id = %v期望 %s", resp["decision_mail_id"], mailID)
}
if _, ok := resp["expired"]; ok {
t.Error("窗口内不该标 expired —— 误报会让人以为批准没生效,比不报更糟")
}
if _, ok := resp["warning"]; ok {
t.Error("窗口内不该带 warning")
}
})
t.Run("刚好到截止时刻:不算过期(边界取严格大于)", func(t *testing.T) {
createdAt := now.Add(-models.PermissionWaitWindow)
resp := decideResponse(createdAt, now, mailID)
if _, ok := resp["expired"]; ok {
t.Error("恰好等于截止时刻时不该算过期")
}
})
t.Run("越窗:必须标 expired 并附 warning且讲清『不会恢复原调用』", func(t *testing.T) {
createdAt := now.Add(-1 * time.Hour)
resp := decideResponse(createdAt, now, mailID)
if resp["expired"] != true {
t.Errorf("expired = %v期望 true", resp["expired"])
}
wantDeadline := models.PermissionDeadline(createdAt)
if got, ok := resp["expires_at"].(time.Time); !ok || !got.Equal(wantDeadline) {
t.Errorf("expires_at = %v期望 %v", resp["expires_at"], wantDeadline)
}
warning, _ := resp["warning"].(string)
if warning == "" {
t.Fatal("越窗必须带 warning —— 否则就是又一次静默成功")
}
// 必须说清两件事:决策已记录(所以不用重试)、不会恢复原调用(所以别等它)。
for _, want := range []string{"不会恢复", "通知"} {
if !strings.Contains(warning, want) {
t.Errorf("warning 里应含 %q实际 %q", want, warning)
}
}
// 时长要由常量推导,不能写死,否则改了窗口这句话就成了假话。
wantNum := fmt.Sprintf("%.0f", models.PermissionWaitWindow.Minutes())
if !strings.Contains(warning, wantNum) {
t.Errorf("warning 应提到等待窗口(%s 分钟),实际 %q", wantNum, warning)
}
})
}
// 等待窗口必须与插件的回合超时同量级:窗口若短于插件真实等待时间,
// 界面会把「其实还在等」的待办标成过期,同样是在说假话。
func TestPermissionWaitWindowIsSane(t *testing.T) {
if models.PermissionWaitWindow < 1*time.Minute {
t.Fatalf("等待窗口 %v 太短,会把仍在等待的待办误标为过期", models.PermissionWaitWindow)
}
if models.PermissionWaitWindow > 30*time.Minute {
t.Fatalf("等待窗口 %v 过长,界面几乎不会提示过期,等于没做", models.PermissionWaitWindow)
}
}