Files
MailUI4Agents/server/internal/repo/permission_visibility_test.go
JianFeeeee f91efd2d8d feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出
问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批)
与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。
邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现,
于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。

修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让
平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的
会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next():
下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。

- lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail
  单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题
  (label 认不出来就不猜测放行);无选项题走自由文本 custom。
- Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本
  问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」;
  推送 payload 带上 permission_kind / multi_select / options。
- mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径,
  五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。
  container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。
- 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交;
  approval 路径不变(回归测试覆盖)。

测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。
2026-09-11 10:44:18 +08:00

192 lines
5.8 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 repo
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/models"
)
// 待办类型permission / question与多选语义必须在**每一条读路径**上透出。
//
// # 为什么值得单独钉一个测试
//
// 这几个字段mails.permission_kind / permission_multi_select曾经只存在于
// 结构体与写入路径模型里有字段、INSERT 里有列,但五个读路径的 SELECT/Scan
// 都没带上它们。
//
// 这类遗漏不会报错 —— SQL 照常返回,只是那个字段永远为零值。前端于是拿到
// permission_kind="" 与 permission_multi_select=false把「模型问了一个问题」
// 渲染成「批准 / 拒绝」两个按钮,人点出来的答案对模型毫无意义。
//
// 所以判据必须落在「五个不同的读函数都看得见」上,而不是只看写入那一侧。
func TestPermissionFieldsVisibleOnEveryReadPath(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "agent-q", "问与答", "")
if err != nil {
t.Fatal(err)
}
// 一个自由文本问题:无预设选项、不允许多选。
mailID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"配置文件名是什么", "请直接回答", nil, "question", false)
if err != nil {
t.Fatal(err)
}
if err := CreatePermissionRequest(ctx, mailID, sid, "agent-q",
"配置文件名是什么", nil, "请直接回答", "question", false); err != nil {
t.Fatal(err)
}
// 一个多选问题:单独造一封,验证 multi_select 也能透出。
multiID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"要哪些环境", "勾选", []string{"dev", "prod"}, "question", true)
if err != nil {
t.Fatal(err)
}
if err := CreatePermissionRequest(ctx, multiID, sid, "agent-q",
"要哪些环境", []string{"dev", "prod"}, "勾选", "question", true); err != nil {
t.Fatal(err)
}
// 一封普通审批kind 必须是 permissionmulti 必须是 false。
approvalID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"删除 build/", "rm -rf build/", []string{"同意", "拒绝"}, "permission", false)
if err != nil {
t.Fatal(err)
}
assertQuestion := func(what string, m *models.Mail) {
t.Helper()
if m == nil {
t.Fatalf("%s 返回 nil", what)
}
if m.PermissionKind != "question" {
t.Errorf("%sPermissionKind = %q期望 question"+
"(字段没进 SELECT/Scan 时会静默变成空串,前端就会渲染成批准/拒绝)",
what, m.PermissionKind)
}
}
// 1) GetMailByID —— 单封详情(前端点开邮件走这条)。
got, err := GetMailByID(ctx, mailID)
if err != nil {
t.Fatal(err)
}
assertQuestion("GetMailByID", got)
// 2) ListInbox —— 授权页的列表来源。
inbox, err := ListInbox(ctx, "alice", "all", 50)
if err != nil {
t.Fatal(err)
}
var found *models.Mail
for i := range inbox {
if inbox[i].ID == mailID {
found = &inbox[i]
}
}
if found == nil {
t.Fatal("ListInbox 没返回那封询问邮件")
}
assertQuestion("ListInbox", found)
// 3) GetSessionMails —— 会话视图整树。
thread, err := GetSessionMails(ctx, sid)
if err != nil {
t.Fatal(err)
}
var inThread *models.Mail
for i := range thread {
if thread[i].ID == mailID {
inThread = &thread[i]
}
}
if inThread == nil {
t.Fatal("GetSessionMails 没返回那封询问邮件")
}
assertQuestion("GetSessionMails", inThread)
// 4) GetSessionMailByID —— 会话内单封。
one, err := GetSessionMailByID(ctx, sid, mailID)
if err != nil {
t.Fatal(err)
}
assertQuestion("GetSessionMailByID", one)
// 5) ListSentBy —— 发件箱Agent 自查「我问过什么」)。
sent, err := ListSentBy(ctx, "agent-q", 50)
if err != nil {
t.Fatal(err)
}
var inSent *models.Mail
for i := range sent {
if sent[i].ID == mailID {
inSent = &sent[i]
}
}
if inSent == nil {
t.Fatal("ListSentBy 没返回那封询问邮件")
}
assertQuestion("ListSentBy", inSent)
// multi_select 也要透出:勾选语义决定前端渲染单选还是多选。
multi, err := GetMailByID(ctx, multiID)
if err != nil {
t.Fatal(err)
}
if !multi.PermissionMulti {
t.Error("多选问题PermissionMulti = false期望 true字段漏读会让多选退化成单选")
}
// 审批型不能被误标成 question。
approval, err := GetMailByID(ctx, approvalID)
if err != nil {
t.Fatal(err)
}
if approval.PermissionKind != "permission" {
t.Errorf("审批邮件PermissionKind = %q期望 permission", approval.PermissionKind)
}
if approval.PermissionMulti {
t.Error("审批邮件不该带多选语义")
}
}
// 无选项的 question 不能被塞上「同意 / 拒绝」默认选项。
//
// 那是 handler 层的默认值逻辑:审批型没有选项时确实该给两个按钮,
// 但自由文本问题给它们只会让人点出一个毫无意义的答案。
// 这里从 repo 侧确认「传什么就存什么」handler 的默认值规则另有测试。
func TestQuestionKeepsEmptyOptions(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "agent-q2", "自由文本", "")
if err != nil {
t.Fatal(err)
}
mailID, err := CreatePermissionMail(ctx, sid, "agent-q2", "alice",
"你的名字", "请回答", nil, "question", false)
if err != nil {
t.Fatal(err)
}
// GetPermissionByMailID 读的是 permission_requests 表(不是 mails
// 所以必须把那一行也插上 —— 否则拿到的是「没有这条请求」。
if err := CreatePermissionRequest(ctx, mailID, sid, "agent-q2",
"你的名字", nil, "请回答", "question", false); err != nil {
t.Fatal(err)
}
pr, err := GetPermissionByMailID(ctx, mailID)
if err != nil {
t.Fatal(err)
}
if pr.Kind != "question" {
t.Errorf("Kind = %q期望 question", pr.Kind)
}
if len(pr.Options) != 0 {
t.Errorf("无选项问题被写入了选项 %v期望空", pr.Options)
}
}