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
158 lines
5.0 KiB
Go
158 lines
5.0 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"testing"
|
||
"time"
|
||
|
||
agentcommand "agent-command"
|
||
"agent-system-backend/internal/cmdbus"
|
||
"agent-system-backend/internal/intentgate"
|
||
"agent-system-backend/internal/runtime"
|
||
"agent-system-backend/internal/settings"
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/internal/workflow"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
type routeCapture struct {
|
||
ch chan workflow.Launch
|
||
}
|
||
|
||
func (c routeCapture) Name() string { return "route-capture" }
|
||
|
||
func (c routeCapture) Execute(rc runtime.RunCtx) error {
|
||
src, _ := rc.(interface {
|
||
Workflow() (workflow.Launch, bool)
|
||
})
|
||
if src == nil {
|
||
return nil
|
||
}
|
||
if launch, ok := src.Workflow(); ok {
|
||
c.ch <- launch
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func TestCommandBusBeatsWorkflow(t *testing.T) {
|
||
srv, st := newTestServer(t)
|
||
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ch := make(chan workflow.Launch, 1)
|
||
srv.mgr.SetWorkflowRunner(routeCapture{ch})
|
||
gate := intentgate.New()
|
||
if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
srv.Gate = gate
|
||
reg := workflow.NewRegistry()
|
||
reg.Set([]core.WorkflowDef{{
|
||
ID: "demo.brief", Title: "简报", IntentID: "demo.brief",
|
||
Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}},
|
||
}}, nil)
|
||
srv.Workflows = reg
|
||
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.brief", Title: "简报命令"}, "", func(context.Context, cmdbus.Request) (any, error) {
|
||
return map[string]any{"ok": true}, nil
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, convID := setupConversation(t, srv.Handler())
|
||
// 槽位齐全才派发命令(F09):必填槽位缺失时命令不得执行,改走澄清回合。
|
||
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "主题:核能 写一份简报"})
|
||
if code != 200 || out["command"] != "demo.brief" {
|
||
t.Fatalf("status %d body %v", code, out)
|
||
}
|
||
if _, ok := out["clarify"]; ok {
|
||
t.Fatalf("complete slots must not clarify: %v", out)
|
||
}
|
||
if _, ok := out["run"]; ok {
|
||
t.Fatal("a command hit must not start a workflow run")
|
||
}
|
||
select {
|
||
case <-ch:
|
||
t.Fatal("workflow runner started for a command")
|
||
default:
|
||
}
|
||
}
|
||
|
||
func TestIntentStartsWorkflowRun(t *testing.T) {
|
||
srv, st := newTestServer(t)
|
||
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ch := make(chan workflow.Launch, 1)
|
||
srv.mgr.SetWorkflowRunner(routeCapture{ch})
|
||
gate := intentgate.New()
|
||
if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
srv.Gate = gate
|
||
reg := workflow.NewRegistry()
|
||
reg.Set([]core.WorkflowDef{{
|
||
ID: "demo.brief", Title: "简报", IntentID: "demo.brief",
|
||
Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}},
|
||
}}, nil)
|
||
srv.Workflows = reg
|
||
_, convID := setupConversation(t, srv.Handler())
|
||
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
|
||
if code != 200 || out["run"] == nil {
|
||
t.Fatalf("status %d body %v", code, out)
|
||
}
|
||
select {
|
||
case launch := <-ch:
|
||
if launch.Def.ID != "demo.brief" || len(launch.Missing) != 1 || launch.Missing[0].Question != "主题是什么?" {
|
||
t.Fatalf("%+v", launch)
|
||
}
|
||
case <-time.After(time.Second):
|
||
t.Fatal("workflow runner was not selected")
|
||
}
|
||
}
|
||
|
||
func TestWorkflowRouteClaimsBeforePersist(t *testing.T) {
|
||
srv, st := newTestServer(t)
|
||
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
gate := intentgate.New()
|
||
if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
srv.Gate = gate
|
||
reg := workflow.NewRegistry()
|
||
reg.Set([]core.WorkflowDef{{
|
||
ID: "demo.brief", Title: "简报", IntentID: "demo.brief",
|
||
Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}},
|
||
}}, nil)
|
||
srv.Workflows = reg
|
||
_, convID := setupConversation(t, srv.Handler())
|
||
conv, err := st.GetConversation(store.OwnerDefault, convID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := srv.mgr.ClaimConversation(convID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
before, err := st.ListMessages(convID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
|
||
if code != 409 {
|
||
t.Fatalf("status %d body %v", code, out)
|
||
}
|
||
after, err := st.ListMessages(convID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(after) != len(before) {
|
||
t.Fatalf("workflow route wrote a message while busy")
|
||
}
|
||
srv.mgr.ReleaseConversation(conv.ID)
|
||
}
|
||
|
||
func briefIntent() json.RawMessage {
|
||
return json.RawMessage(`{"id":"demo.brief","domain":"demo","version":1,"matcher":{"includes_any":["简报"]},"slots":[{"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"}]}`)
|
||
}
|