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
398 lines
14 KiB
Go
398 lines
14 KiB
Go
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)
|
||
}
|
||
}
|