package cmdbus // EXT-14:Bus.CommandTools 只投影带 ExposeAsTool 的已启用命令;risk 缺省 low、 // description 缺省取 title;未暴露/未启用的命令不出现。 import ( "context" "encoding/json" "strings" "testing" "time" agentcommand "agent-command" ) func newToolBus(t *testing.T) *Bus { t.Helper() return New(nopAudit{}, nil, func() time.Time { return time.Now().UTC() }) } func TestCommandToolsFiltering(t *testing.T) { b := newToolBus(t) specs := []json.RawMessage{ []byte(`{"id":"rdm.trace","title":"追溯查询","description":"查网","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"medium"}}`), []byte(`{"id":"rdm.baseline","title":"基线视图","exposeAsTool":{}}`), []byte(`{"id":"rdm.hidden","title":"未暴露","description":"不出现"}`), } if err := b.RegisterPluginCommands(specs); err != nil { t.Fatal(err) } tools := b.CommandTools() byID := map[string]CommandTool{} for _, ct := range tools { byID[ct.ID] = ct } if len(byID) != 2 { t.Fatalf("projected = %+v, want 2(rdm.trace + rdm.baseline)", tools) } if ct := byID["rdm.trace"]; ct.Risk != "medium" || ct.Description != "查网" || string(ct.Params) != `{"type":"object"}` { t.Fatalf("rdm.trace = %+v", ct) } if ct := byID["rdm.baseline"]; ct.Risk != "low" || ct.Description != "基线视图" { t.Fatalf("rdm.baseline = %+v(risk 缺省 low、description 缺省 title)", ct) } if _, ok := byID["rdm.hidden"]; ok { t.Fatal("未声明 exposeAsTool 的命令不得投影") } } func TestCommandToolDispatchEnvelope(t *testing.T) { b := newToolBus(t) // 注册一个带处理器的暴露命令,经 Dispatch 分发验证完整回执链路。 var spec agentcommand.CommandSpec if err := json.Unmarshal([]byte(`{"id":"rdm.echo","title":"回声","exposeAsTool":{"risk":"low"}}`), &spec); err != nil { t.Fatal(err) } if err := b.RegisterPluginCommand(spec, func(ctx context.Context, req Request) (any, error) { return map[string]any{"echo": req.Params["v"], "actor": req.Actor, "entrance": req.Entrance}, nil }); err != nil { t.Fatal(err) } resp := b.Dispatch(context.Background(), Request{ID: "rdm.echo", Params: map[string]any{"v": 1}, Entrance: "agent", Actor: "bob", Owner: "bob", ConversationID: "cnv-1"}) if resp.Receipt.State != "committed" { t.Fatalf("receipt = %+v (%s)", resp.Receipt, resp.Receipt.Reason) } m, _ := resp.Payload.(map[string]any) if m["entrance"] != "agent" || m["actor"] != "bob" { t.Fatalf("payload = %+v(Entrance=agent 原样入审计)", m) } } type nopAudit struct{} func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil } // U3:owner 级注入闸——gate 拒绝的命令不投影(禁用即不注入能力);fail-closed // (owner 为空且 gate 判否时不投影)。 func TestCommandToolsForGate(t *testing.T) { bus := New(nil, nil, func() time.Time { return time.Now().UTC() }) if err := bus.RegisterPluginCommands([]json.RawMessage{ []byte(`{"id":"generative-ui.present","title":"生成页面","description":"d","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`), []byte(`{"id":"other.echo","title":"回声","description":"e","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`), }); err != nil { t.Fatal(err) } bus.SetToolGate(func(owner, id string) bool { return !strings.HasPrefix(id, "generative-ui.") }) on := bus.CommandToolsFor("alice") for _, ct := range on { if strings.HasPrefix(ct.ID, "generative-ui.") { t.Fatalf("gated command leaked for enabled owner: %+v", ct) } } if len(on) != 1 { t.Fatalf("expected only other.echo, got %+v", on) } if n := len(bus.CommandToolsFor("")); n != 1 { t.Fatalf("empty owner must be fail-closed on gated commands, got %d", n) } }