Files
Agent-System-Backend/internal/server/intent_admission_test.go
T
weichen 0e55875329 Admit intents with full slot validity and schema-typed slots
Why: F09 - invalid slot information was dropped at the gate, the router
saw only Start, and the command entrance passed untyped text, so
invalid-slot recognitions could still reach business execution paths.
Evidence: intentgate/route/server tests pass (verify-u6.json);
admission regression cases added.
Impact: invalid or missing slots never start executable actions;
clarification flows unchanged; cmdbus confirmation untouched.
Refs: feature/agent-stack-audit-remediation-b1 U6
2026-10-21 16:34:12 +00:00

398 lines
14 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 server
import (
"context"
"encoding/json"
"sync"
"testing"
agentcommand "agent-command"
agentintent "agent-intent/intent"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/intentgate"
)
// F09 意图接纳:槽位缺失/非法一律不派发业务动作;合法槽位按命令 DECLARED
// schema 映射后进处理器;高危命令的确认语义完全由 cmdbus 收口(此处不动)。
// reportIntent 声明一个带枚举校验与多个可选槽位的意图(合成数据)。
func reportIntent() json.RawMessage {
return json.RawMessage(`{"id":"demo.report","domain":"demo","version":1,
"matcher":{"includes_any":["生成报告"]},
"slots":[
{"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"},
{"key":"mode","required":true,"pattern":"模式[::]\\s*(\\S+)","enum":["摘要","全文"],"question":"要哪种模式?"},
{"key":"count","pattern":"份数[::]\\s*(\\S+)"},
{"key":"enabled","pattern":"归档[::]\\s*(\\S+)"},
{"key":"note","pattern":"备注[::]\\s*(\\S+)"}]}`)
}
// reportCommandSpec 声明参数 schema:count 是 integer、enabled 是 boolean,
// note 未声明——未声明的键不得被塞进 params。
func reportCommandSpec() agentcommand.CommandSpec {
return agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic","mode"],
"properties":{"topic":{"type":"string"},"mode":{"type":"string"},
"count":{"type":"integer"},"enabled":{"type":"boolean"}}}`)}
}
// dispatchLog 记录每次真实派发(处理器被调用才算执行)。
type dispatchLog struct {
mu sync.Mutex
calls []cmdbus.Request
params []map[string]any
}
func (l *dispatchLog) record(req cmdbus.Request) {
l.mu.Lock()
defer l.mu.Unlock()
copied := map[string]any{}
for k, v := range req.Params {
copied[k] = v
}
l.calls = append(l.calls, req)
l.params = append(l.params, copied)
}
func (l *dispatchLog) snapshot() []map[string]any {
l.mu.Lock()
defer l.mu.Unlock()
return append([]map[string]any(nil), l.params...)
}
func (l *dispatchLog) byOwner() map[string]map[string]any {
l.mu.Lock()
defer l.mu.Unlock()
out := map[string]map[string]any{}
for _, req := range l.calls {
out[req.Owner] = req.Params
}
return out
}
// admissionServer 组装带意图闸门与 demo.report 命令的测试服务器。
func admissionServer(t *testing.T) (*Server, *dispatchLog) {
t.Helper()
return admissionServerWith(t, reportCommandSpec())
}
func admissionServerWith(t *testing.T, spec agentcommand.CommandSpec) (*Server, *dispatchLog) {
t.Helper()
srv, _ := newTestServer(t)
log := &dispatchLog{}
if err := srv.Bus.RegisterBuiltin(spec, "", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{reportIntent()}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
return srv, log
}
func clarifyOf(t *testing.T, out map[string]any) map[string]any {
t.Helper()
c, ok := out["clarify"].(map[string]any)
if !ok {
t.Fatalf("响应缺少澄清诉求:%v", out)
}
return c
}
// 缺必填槽位:不执行业务命令,澄清诉求带正确的提问。
func TestMissingSlotBlocksCommandAndSurfacesClarification(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "生成报告 模式:摘要"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("缺必填槽位不得执行业务命令:%v", out)
}
if out["run"] == nil {
t.Fatalf("未派发命令时应回落普通回合:%v", out)
}
c := clarifyOf(t, out)
if c["question"] != "主题是什么?" {
t.Fatalf("澄清提问:%v", c)
}
if c["command"] != "demo.report" || c["intent"] != "demo.report" {
t.Fatalf("被拦下的动作未标明:%v", c)
}
missing, _ := c["missing"].([]any)
if len(missing) != 1 {
t.Fatalf("缺失槽位明细:%v", c)
}
if m := missing[0].(map[string]any); m["key"] != "topic" || m["question"] != "主题是什么?" {
t.Fatalf("缺失槽位键/提问:%v", m)
}
if _, bad := c["invalid"]; bad {
t.Fatalf("缺失不应混进非法:%v", c)
}
if got := log.snapshot(); len(got) != 0 {
t.Fatalf("命令处理器被调用:%v", got)
}
}
// 非法枚举值:不执行业务命令,结构化原因原样透出。
func TestInvalidSlotBlocksCommandWithReason(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "生成报告 主题:核能 模式:随手写"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("非法槽位不得执行业务命令:%v", out)
}
c := clarifyOf(t, out)
if c["question"] != "要哪种模式?" {
t.Fatalf("澄清提问:%v", c)
}
invalid, _ := c["invalid"].([]any)
if len(invalid) != 1 {
t.Fatalf("非法槽位明细:%v", c)
}
got := invalid[0].(map[string]any)
if got["key"] != "mode" || got["reason"] != "value not in enum" || got["question"] != "要哪种模式?" {
t.Fatalf("非法原因/提问未透传:%v", got)
}
if got["invalid"] != true {
t.Fatalf("非法标记:%v", got)
}
if _, missing := c["missing"]; missing {
t.Fatalf("非法不应重复计入缺失:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("命令处理器被调用:%v", log.snapshot())
}
}
// 合法槽位:按声明 schema 映射后到达处理器(类型正确、未声明键不复制)。
func TestValidSlotsReachHandlerMappedPerSchema(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
text := "生成报告 主题:核能 模式:摘要 份数:3 归档:true 备注:随便"
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
if code != 200 || out["command"] != "demo.report" {
t.Fatalf("status %d body %v", code, out)
}
if _, ok := out["clarify"]; ok {
t.Fatalf("完整合法槽位不应带澄清诉求:%v", out)
}
calls := log.snapshot()
if len(calls) != 1 {
t.Fatalf("处理器调用次数:%d", len(calls))
}
params := calls[0]
if params["topic"] != "核能" || params["mode"] != "摘要" || params["text"] != text {
t.Fatalf("字符串参数映射:%v", params)
}
if params["count"] != int64(3) {
t.Fatalf("integer 参数映射:%#v", params["count"])
}
if params["enabled"] != true {
t.Fatalf("boolean 参数映射:%#v", params["enabled"])
}
if _, ok := params["note"]; ok {
t.Fatalf("未声明的槽位不得复制进 params:%v", params)
}
if len(params) != 5 {
t.Fatalf("参数量超出声明:%v", params)
}
}
// 声明的必填参数无法由原文+槽位满足:不派发,转澄清(不再由总线报 schema 错)。
func TestDeclaredRequiredParamBlocksDispatch(t *testing.T) {
srv, log := admissionServerWith(t, agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","target"],
"properties":{"topic":{"type":"string"},"target":{"type":"string"}}}`)})
h := srv.Handler()
_, convID := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "生成报告 主题:核能 模式:摘要"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("必填参数缺失不得执行业务命令:%v", out)
}
c := clarifyOf(t, out)
missing, _ := c["missing"].([]any)
found := false
for _, m := range missing {
if m.(map[string]any)["key"] == "target" {
found = true
}
}
if !found {
t.Fatalf("缺失必填参数未透出:%v", c)
}
if c["reason"] == "" {
t.Fatalf("准入原因缺失:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("命令处理器被调用:%v", log.snapshot())
}
}
// 高危命令仍走 cmdbus 的确认卡收口:接纳通过后创建确认卡,处理器不执行。
func TestHighRiskCommandStillRequiresConfirmation(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{
reportIntent(),
json.RawMessage(`{"id":"demo.purge","domain":"demo","version":1,
"matcher":{"includes_any":["清除报告"]},
"action":{"kind":"command","id":"demo.purge"},
"slots":[{"key":"topic","required":true,"pattern":"对象[::]\\s*(\\S+)","question":"清除哪个对象?"}]}`),
}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.purge", Title: "清除报告",
Danger: agentcommand.DangerHigh,
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic"],
"properties":{"topic":{"type":"string"}}}`)}, "", func(context.Context, cmdbus.Request) (any, error) {
t.Fatalf("高危命令未经确认不得执行处理器")
return nil, nil
}); err != nil {
t.Fatal(err)
}
// 缺必填槽位时连确认卡都不该出现(业务动作根本没到总线)。
_, blocked := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+blocked+"/messages",
map[string]any{"text": "清除报告"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, hasCard := out["card"]; hasCard {
t.Fatalf("槽位未就绪不得创建确认卡:%v", out)
}
if _, executed := out["command"]; executed {
t.Fatalf("槽位未就绪不得执行业务命令:%v", out)
}
// 槽位齐全:派发到总线,高危收口创建确认卡,处理器仍未执行。
_, ready := setupConversation(t, h)
code, out = doJSON(t, h, "POST", "/api/conversations/"+ready+"/messages",
map[string]any{"text": "清除报告 对象:旧归档"})
if code != 200 || out["command"] != "demo.purge" {
t.Fatalf("status %d body %v", code, out)
}
if out["card"] == nil {
t.Fatalf("高危命令必须要求确认:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("确认前不得派发其他命令:%v", log.snapshot())
}
}
// 显式取消命令宿主未实现:如实回落普通回合,不派发同一句里的 Start。
type cancelGenerator struct{}
func (cancelGenerator) Generate(context.Context, string, []agentintent.CatalogSpec) ([]agentintent.Command, error) {
return []agentintent.Command{
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.report"}},
{Kind: intentgate.CommandCancel, Target: "demo.report"},
}, nil
}
func TestUnsupportedCancelCommandFallsBackToTurn(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
gate, ok := srv.Gate.(*intentgate.Gate)
if !ok {
t.Fatalf("闸门类型:%T", srv.Gate)
}
gate.SetGenerator(cancelGenerator{})
_, convID := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "算了吧 生成报告"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("未支持的取消命令不得派发业务动作:%v", out)
}
if out["run"] == nil {
t.Fatalf("未支持的库层命令应回落普通回合:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("命令处理器被调用:%v", log.snapshot())
}
}
// 两个租户的槽位上下文互不串味:各自只看到自己那句输入映射出的参数。
func TestIntentSlotsDoNotLeakAcrossOwners(t *testing.T) {
srv, log := admissionServer(t)
srv.Auth = NewAuthenticator(srv.st, true)
convID := map[string]string{}
for _, owner := range []string{"alice", "bob"} {
if err := srv.st.CreateAccount(owner, owner, "", true); err != nil {
t.Fatal(err)
}
proj, err := srv.st.CreateProject(owner, owner+" 的项目")
if err != nil {
t.Fatal(err)
}
conv, err := srv.st.CreateConversation(owner, proj.ID, owner+" 的会话", "")
if err != nil {
t.Fatal(err)
}
convID[owner] = conv.ID
}
h := srv.Handler()
send := func(owner, text string) map[string]any {
t.Helper()
key, err := srv.st.CreateAPIKey(owner, owner+"-key")
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(map[string]any{"text": text})
if err != nil {
t.Fatal(err)
}
rec := bearerRequest(h, "POST", "/api/conversations/"+convID[owner]+"/messages", string(body), key)
out := map[string]any{}
if rec.Body.Len() > 0 {
json.Unmarshal(rec.Body.Bytes(), &out)
}
if rec.Code != 200 {
t.Fatalf("%s 发送 %q: status %d body %s", owner, text, rec.Code, rec.Body.String())
}
return out
}
if out := send("alice", "生成报告 主题:核能 模式:摘要"); out["command"] != "demo.report" {
t.Fatalf("alice 派发:%v", out)
}
if out := send("bob", "生成报告 主题:水利 模式:全文"); out["command"] != "demo.report" {
t.Fatalf("bob 派发:%v", out)
}
if got := log.byOwner(); len(got) != 2 {
t.Fatalf("派发归属:%v", got)
}
byOwner := log.byOwner()
if byOwner["alice"]["topic"] != "核能" || byOwner["alice"]["mode"] != "摘要" {
t.Fatalf("alice 收到别人的槽位:%v", byOwner["alice"])
}
if byOwner["bob"]["topic"] != "水利" || byOwner["bob"]["mode"] != "全文" {
t.Fatalf("bob 收到别人的槽位:%v", byOwner["bob"])
}
if byOwner["alice"]["topic"] == byOwner["bob"]["topic"] {
t.Fatalf("两个租户共享了同一份槽位上下文:%v", byOwner)
}
}