Files
agent-exec-transport 56700f0a26
governance / governance (pull_request) Successful in 28s
ci / vet-and-test (pull_request) Successful in 2m32s
agent-exec transport 2db9c40c541363422c723d1144447eef/125a24e9759a
2026-10-23 01:54:26 +00:00

341 lines
13 KiB
Go
Raw Permalink 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 intentgate
import (
"context"
"encoding/json"
"strings"
"testing"
agentintent "agent-intent/intent"
)
func raw(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
// spec 采用 agent-intent overlay 形态(matcher 嵌套)。
func demoSpec(t *testing.T) json.RawMessage {
return raw(t, map[string]any{
"id": "demo.show", "domain": "demo", "version": 1,
"description": "示例意图",
"matcher": map[string]any{"includes_any": []string{"示例", "演示"}},
})
}
func TestGateMatchAndFallback(t *testing.T) {
g := New()
if _, id := g.Match(context.Background(), "任意"); id != "" || g.Enabled() {
t.Error("empty gate should passthrough")
}
if err := g.Rebuild(context.Background(), []json.RawMessage{demoSpec(t)}); err != nil {
t.Fatalf("rebuild: %v", err)
}
if !g.Enabled() || g.SpecCount() != 1 {
t.Fatalf("enabled=%v specs=%d", g.Enabled(), g.SpecCount())
}
branch, top := g.Match(context.Background(), "来个示例看看")
if branch != "matched" || top != "demo.show" {
t.Errorf("want matched/demo_intent, got %s/%s", branch, top)
}
branch, _ = g.Match(context.Background(), "完全无关的一句话")
if branch != "fallback" {
t.Errorf("want fallback, got %s", branch)
}
}
// fail-closed:坏 spec 保留上一快照。
func TestGateFailClosed(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), []json.RawMessage{demoSpec(t)}); err != nil {
t.Fatal(err)
}
if err := g.Rebuild(context.Background(), []json.RawMessage{json.RawMessage(`{"id":`)}); err == nil {
t.Error("broken spec should error")
}
if !g.Enabled() || g.SpecCount() != 1 {
t.Error("previous snapshot must be kept on failed rebuild")
}
// 空重建 = 显式清空(卸载全部插件意图)。
if err := g.Rebuild(context.Background(), nil); err != nil {
t.Fatal(err)
}
if g.Enabled() {
t.Error("empty rebuild should disable gate")
}
}
func TestRecognizeKeepsSlots(t *testing.T) {
g := New()
spec := raw(t, map[string]any{
"id": "demo.brief", "domain": "demo", "version": 1,
"matcher": map[string]any{"includes_any": []string{"简报"}},
"slots": []map[string]any{{
"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`,
"question": "主题是什么?",
}},
})
if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil {
t.Fatal(err)
}
miss := g.Recognize(context.Background(), "写一份简报")
if miss.Branch != "matched" || miss.ID != "demo.brief" || len(miss.Missing) != 1 || miss.Missing[0].Question != "主题是什么?" {
t.Fatalf("%+v", miss)
}
hit := g.Recognize(context.Background(), "主题:核能 写一份简报")
if hit.Slots["topic"] != "核能" || len(hit.Missing) != 0 {
t.Fatalf("%+v", hit)
}
if branch, id := g.Match(context.Background(), "写一份简报"); branch != "matched" || id != "demo.brief" {
t.Fatalf("Match wrapper = %s %s", branch, id)
}
}
// ---- I2 慢通道接线(默认关;SetSemantic/SetGenerator 开启)----
// stubScorer 固定返回脚本分(按 examples[0] 查表)。
type stubScorer struct{ scores map[string]float64 }
func (s stubScorer) Score(_ context.Context, _ string, examples []string) (float64, error) {
if len(examples) == 0 {
return 0, nil
}
return s.scores[examples[0]], nil
}
// stubGenerator 固定返回脚本命令。
type stubGenerator struct{ cmds []Command }
func (g stubGenerator) Generate(_ context.Context, _ string, _ []agentintent.CatalogSpec) ([]agentintent.Command, error) {
return g.cmds, nil
}
func ambiguousOverlay(t *testing.T) []json.RawMessage {
t.Helper()
mk := func(id string, ex []string) json.RawMessage {
return raw(t, map[string]any{
"id": id, "domain": "demo", "version": 1, "priority": 5,
"matcher": map[string]any{"includes_any": []string{"模糊词"}},
"examples": ex,
})
}
return []json.RawMessage{mk("demo.alpha", []string{"查运行进度"}), mk("demo.beta", []string{"闲聊问候"})}
}
// 语义层:歧义被过阈打分裁决为 matched(默认关时不启用)。
func TestGateSemanticResolvesAmbiguous(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), ambiguousOverlay(t)); err != nil {
t.Fatal(err)
}
// 默认(未接线):规则层歧义原样透出。
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
t.Fatalf("默认关:branch = %s", branch)
}
g.SetSemantic(stubScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}, 0)
branch, top := g.Match(context.Background(), "模糊词")
if branch != "matched" || top != "demo.alpha" {
t.Fatalf("语义裁决:%s/%s", branch, top)
}
// 关闭后回到歧义。
g.SetSemantic(nil, 0)
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
t.Fatalf("关闭语义层:branch = %s", branch)
}
}
// 生成层:fallback 时产出合法 start 命令 → matched + Commands。
func TestGateGeneratorProducesCommands(t *testing.T) {
g := New()
overlay := []json.RawMessage{raw(t, map[string]any{
"id": "report.generate", "domain": "report", "version": 1,
"description": "生成报告",
"matcher": map[string]any{"includes_any": []string{"生成报告"}},
"handler": "reportHandler",
})}
if err := g.Rebuild(context.Background(), overlay); err != nil {
t.Fatal(err)
}
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
}}})
res := g.Recognize(context.Background(), "完全不相关的一句话")
if res.Branch != "matched" || res.ID != "report.generate" {
t.Fatalf("生成器命中:%+v", res)
}
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
t.Fatalf("commands: %+v", res.Commands)
}
// 关闭后回到 fallback。
g.SetGenerator(nil)
if branch, _ := g.Match(context.Background(), "完全不相关的一句话"); branch != "fallback" {
t.Fatalf("关闭生成层:branch = %s", branch)
}
}
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径)----
// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。
func modeSpec(t *testing.T) json.RawMessage {
t.Helper()
return raw(t, map[string]any{
"id": "demo.mode", "domain": "demo", "version": 1,
"matcher": map[string]any{"includes_any": []string{"切换"}},
"risk": "high",
"requires_confirm": true,
"action": map[string]any{"kind": "command", "id": "demo.mode"},
"slots": []map[string]any{
{"key": "mode", "required": true, "pattern": `模式[::]\s*(\S+)`, "enum": []string{"摘要", "全文"}, "question": "要哪种模式?"},
{"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`, "question": "主题是什么?"},
},
})
}
func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
t.Fatal(err)
}
bad := g.Recognize(context.Background(), "切换 主题:核能 模式:随手写")
if bad.Branch != "matched" || bad.ID != "demo.mode" {
t.Fatalf("识别分支:%+v", bad)
}
if bad.Complete() {
t.Fatalf("非法枚举值不得算作完整:%+v", bad)
}
if len(bad.Missing) != 0 {
// 必填且非法只进 Invalid,不重复进 Missing。
t.Fatalf("非法槽位不应重复计入缺失:%+v", bad.Missing)
}
if len(bad.Invalid) != 1 {
t.Fatalf("非法槽位详情:%+v", bad.Invalid)
}
d := bad.Invalid[0]
if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" {
t.Fatalf("非法原因/提问未透传:%+v", d)
}
if _, leaked := bad.Slots["mode"]; leaked {
t.Fatalf("非法值不得进入已校验槽位表:%+v", bad.Slots)
}
if bad.Slots["topic"] != "核能" {
t.Fatalf("合法槽位丢失:%+v", bad.Slots)
}
// 风险/确认/动作声明一并透传(宿主据此决定确认与派发)。
if bad.Risk != "high" || !bad.RequiresConfirm {
t.Fatalf("风险元数据未透传:%+v", bad)
}
if bad.Action == nil || bad.Action.Kind != ActionCommand || bad.Action.ID != "demo.mode" {
t.Fatalf("动作声明未透传:%+v", bad.Action)
}
ok := g.Recognize(context.Background(), "切换 主题:核能 模式:摘要")
if !ok.Complete() || ok.Slots["mode"] != "摘要" || len(ok.Invalid) != 0 {
t.Fatalf("合法槽位识别:%+v", ok)
}
}
// 生成路径在上游 agent-intent main 不再跑权威槽位校验:validateProvidedSlots
// (enum/format/validator/未知键)已从库层删除,生成的 Top 直接照搬 Command.Slots,
// 也不挂 risk/confirm/action 元数据;仅 command 的 ActionRef 与槽位**键**仍由
// validateGenerated fail-closed 校验。宿主投影随之退化,断言改写到新等价行为。
func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
t.Fatal(err)
}
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
Slots: map[string]string{"mode": "随手写", "topic": "核能"},
}}})
res := g.Recognize(context.Background(), "跟昨天一样")
if res.Branch != "matched" || res.ID != "demo.mode" {
t.Fatalf("生成路径识别:%+v", res)
}
// 非法枚举值不再被库层拦下,原样透传(宿主不新增强制分类)。
if len(res.Invalid) != 0 || res.Slots["mode"] != "随手写" || res.Slots["topic"] != "核能" {
t.Fatalf("生成路径槽位透传:%+v", res)
}
if !res.Complete() {
t.Fatalf("生成路径不再产出权威缺陷:%+v", res)
}
// 风险/确认/动作声明同理不再由生成路径提供(规则路径仍然提供)。
if res.Risk != "" || res.RequiresConfirm || res.Action != nil {
t.Fatalf("生成路径风险元数据:%+v", res)
}
// 合法槽位:仍然 matched 且完整。
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
Slots: map[string]string{"mode": "摘要", "topic": "核能"},
}}})
clean := g.Recognize(context.Background(), "跟昨天一样")
if !clean.Complete() || clean.Slots["mode"] != "摘要" {
t.Fatalf("生成路径合法槽位:%+v", clean)
}
// 未知槽位键仍由库层 fail-closed 拦住(整单回 fallback,不带 Top)。
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
Slots: map[string]string{"unknown_key": "值"},
}}})
unknown := g.Recognize(context.Background(), "跟昨天一样")
if unknown.Branch != "fallback" || unknown.ID != "" || len(unknown.Slots) != 0 {
t.Fatalf("未知槽位键必须 fail-closed:%+v", unknown)
}
}
// 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。
func TestRecognizeDistinguishesMissingFromInvalid(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
t.Fatal(err)
}
res := g.Recognize(context.Background(), "切换 主题:核能")
if len(res.Missing) != 1 || res.Missing[0].Key != "mode" || res.Missing[0].Question != "要哪种模式?" {
t.Fatalf("缺失投影:%+v", res.Missing)
}
if len(res.Invalid) != 0 {
t.Fatalf("缺失不得混进非法:%+v", res.Invalid)
}
if res.Complete() {
t.Fatal("缺必填槽位不得算作完整")
}
}
// 格式校验失败与枚举失败同款透传(原因原文来自库层,不做改写)。
func TestRecognizeKeepsFormatFailureReason(t *testing.T) {
g := New()
spec := raw(t, map[string]any{
"id": "demo.ticket", "domain": "demo", "version": 1,
"matcher": map[string]any{"includes_any": []string{"开工单"}},
"slots": []map[string]any{{
"key": "issue", "required": true, "pattern": `编号[::]\s*(\S+)`,
"format": `[A-Z]{2}-\d+`, "question": "编号是什么?",
}},
})
if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil {
t.Fatal(err)
}
bad := g.Recognize(context.Background(), "开工单 编号:随手写的")
if len(bad.Invalid) != 1 || bad.Invalid[0].Key != "issue" || bad.Invalid[0].Question != "编号是什么?" {
t.Fatalf("格式失败明细:%+v", bad.Invalid)
}
if !strings.HasPrefix(bad.Invalid[0].Reason, "format mismatch") {
t.Fatalf("格式失败原因:%+v", bad.Invalid[0])
}
if bad.Complete() || len(bad.Missing) != 0 {
t.Fatalf("格式失败不得算作完整或缺失:%+v", bad)
}
if ok := g.Recognize(context.Background(), "开工单 编号:AB-12"); !ok.Complete() || ok.Slots["issue"] != "AB-12" {
t.Fatalf("格式合法:%+v", ok)
}
}