fix(repo): /api/v1/agents 带出 mode_enforcement —— 列表接口静默少了一个事实

# 现象

`/api/v1/agents` 对全部四个 Agent 返回 `mode_enforcement: ""`,
而库里四个值一直是正确的(pi=native / dsh=partial / opencode=advisory /
homeagent=advisory)。这是全功能演练 Phase A 的前置检查抓到的。

# 根因

`ListAgents` 的 SELECT 里没有 `mode_enforcement`,Scan 自然也没扫它。

这与「SELECT 加了列但没加进 Scan」(列数不匹配,直接报错)**不是**一回事:
少取一列不报任何错,只是安静地少一个事实。而「这个 Agent 到底能不能真的拦住
危险操作」是使用者在派活前必须知道的事 —— 缺了它,advisory 档会被当成 native
档用。

# 修法

SELECT 补上 `COALESCE(NULLIF(mode_enforcement, ''), 'advisory')`,与
`permission_mode.go` 同一套兜底(那里也这么写,说明空串确实可能出现)。

# 测试

`repo/agent_mode_list_test.go`:三档各一条 + 一条空串(脏数据),断言**值真的
传出来**而不是「函数没报错」;另加反向对照确认行真的被查到了(避免一个都没查到
也能过)。

反向验证过判据非空转:把修复退掉,测试立刻失败。
This commit is contained in:
2026-09-12 11:19:52 +08:00
parent a696b2a141
commit 0b5fabfd49
2 changed files with 79 additions and 2 deletions

View File

@ -0,0 +1,67 @@
package repo
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/db"
)
// ListAgents 必须把 mode_enforcement 带出来。
//
// # 为什么需要这条测试
//
// 该字段曾是**静默丢失**的:`ListAgents` 的 SELECT 里没有它Scan 自然也没扫,
// 于是 `/api/v1/agents` 对全部 Agent 返回空串 —— 而库里四个 Agent 的值
// native / partial / advisory一直是对的。
//
// 这类故障跟「SELECT 加列但没加进 Scan」列数不匹配直接报错**不是**一回事:
// 少取一列不会报任何错,只是安静地少一个事实。而「这个 Agent 到底能不能真的
// 拦住危险操作」是使用者在派活前必须知道的事缺了它advisory 档会被当成
// native 档用 —— 正是本项目一直在治的那种「静默不一致」。
//
// 所以判据是**值真的传出来**,不是「函数没报错」。
func TestListAgentsCarriesModeEnforcement(t *testing.T) {
ctx := context.Background()
setupTestDB(t)
database := db.DB
// 三轮覆盖三档并包含「空串」这种脏数据permission_mode.go 用
// NULLIF(...,'') 兜底,说明空串确实可能出现)。
cases := []struct{ name, mode, want string }{
{"t-mode-native", "native", "native"},
{"t-mode-partial", "partial", "partial"},
{"t-mode-advisory", "advisory", "advisory"},
{"t-mode-empty", "", "advisory"},
}
for _, c := range cases {
if _, err := database.ExecContext(ctx,
`INSERT INTO agents (agent_name, secret, platform, status, mode_enforcement)
VALUES ($1, 's', 'pi', 'online', $2)`, c.name, c.mode); err != nil {
t.Fatalf("插入 %s 失败: %v", c.name, err)
}
}
agents, err := ListAgents(ctx, "all")
if err != nil {
t.Fatalf("ListAgents 出错: %v", err)
}
got := map[string]string{}
for _, a := range agents {
got[a.Name] = a.ModeEnforcement
}
for _, c := range cases {
if got[c.name] != c.want {
t.Errorf("%smode_enforcement 传出来是 %q应为 %q —— "+
"列表接口必须带出档位,否则调用方会把 advisory 当 native 用",
c.name, got[c.name], c.want)
}
}
// 反向对照:确认这些行确实被查到了(避免「一个都没查到」也能过)。
if len(agents) < len(cases) {
t.Errorf("只查到 %d 行,期望至少 %d 行 —— 上面的断言可能是空转",
len(agents), len(cases))
}
}

View File

@ -187,8 +187,18 @@ func SetAgentDisabled(ctx context.Context, agentName string, disabled bool) (int
func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error) {
// 带上 default_rounds前端补全收件人时要显示「派给它的任务默认几个来回」
// 否则人得先去管理员页查一遍才敢派活。
//
// 也要带 mode_enforcement它是插件自报的**档位强制能力**
// native / partial / advisory。"这个 Agent 到底能不能真的拦住危险操作"
// 是使用者在派活前必须知道的事。
//
// 该字段曾在列表接口**静默丢失**SELECT 里没有Scan 也就没扫),
// 而库里四个 Agent 的值一直是正确的 —— 表现为 /api/v1/agents 全部返回空串。
// 与列数不匹配不同,这种"少取一列"不会报错,只会安静地少一个事实。
// 回归测试repo/agent_mode_list_test.go。
q := `SELECT agent_id, agent_name, workspaces, platform, status,
COALESCE(default_rounds, 0) FROM agents`
COALESCE(default_rounds, 0),
COALESCE(NULLIF(mode_enforcement, ''), 'advisory') FROM agents`
args := []any{}
switch statusFilter {
case "":
@ -212,7 +222,7 @@ func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error
var a models.Agent
var wsJSON []byte
if err := rows.Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status,
&a.DefaultRounds); err != nil {
&a.DefaultRounds, &a.ModeEnforcement); err != nil {
return nil, err
}
if wsJSON != nil {