Files
Agent-System-Backend/internal/cmdbus/cmdbus_test.go
T
weichen cb26e4c947
ci / vet-and-test (pull_request) Successful in 41s
Add EXT-1 inproc plugin command handler with owner-scoped host handle
Why: 进程内插件命令此前只登记规格不绑处理器,分发即 "no host handler bound",
且插件无 owner 凭证(H4);EXT-1 最小集补上处理器绑定与 owner 作用域宿主句柄。
Evidence: pkg/extension 新增 Request/Handler/Host + Registry.AddCommandHandler(surface.txt 12→15);
cmdbus.RegisterPluginCommand 三例测试(committed/未绑 rejected/高危确认卡先拦);
rag.IndexChunks 抽出并两例直测;internal/app hostcaps+inprocHost 复用 hostapi owner 作用域实现,
applyCompiled 携 caps,inproc_host_test 双 owner 隔离 + provider=hash 向量写入均通过;go test ./... 29 包全绿。
Impact: 公开面依赖从零外部模块变为 +agent-system-core(声明式 go.mod 需按 compat-policy §4 加 core require+replace);
既有路径语义不变,SurfaceVersion 保持 2.0.0-experimental。
Refs: documents 提案 2026-10-05-ext-1-inproc-command-handler.md;V1.2 方案批注 1。
2026-10-05 11:39:25 +00:00

283 lines
11 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 cmdbus
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
agentcommand "agent-command"
"agent-system-core"
)
// memAudit / memCards 测试替身。
type memAudit struct {
mu sync.Mutex
logs []agentcommand.AuditEnvelope
fail bool
}
func (m *memAudit) AppendAudit(env agentcommand.AuditEnvelope) error {
if m.fail {
return errors.New("audit down")
}
m.mu.Lock()
defer m.mu.Unlock()
m.logs = append(m.logs, env)
return nil
}
type memCards struct{ created []core.ActivityCard }
func (m *memCards) Create(owner string, c core.ActivityCard, ref string) (core.ActivityCard, error) {
c.ID = core.ID("card_x1")
m.created = append(m.created, c)
return c, nil
}
func (m *memCards) SetStatus(owner, id string, st core.CardStatus, tone, note string) (core.ActivityCard, error) {
return core.ActivityCard{ID: core.ID(id), Status: st}, nil
}
func newTestBus(a AuditStore, c CardPort) *Bus {
b := New(a, c, func() time.Time { return time.Unix(1700000000, 0).UTC() })
_ = b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "/ping",
func(ctx context.Context, req Request) (any, error) { return map[string]any{"pong": true}, nil })
_ = b.RegisterBuiltin(agentcommand.CommandSpec{
ID: "demo.risky", Title: "危险演示", Danger: agentcommand.DangerHigh,
Description: "高危命令演示",
ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`),
}, "/risky", func(ctx context.Context, req Request) (any, error) {
t, _ := req.Params["target"].(string)
if t == "boom" {
return nil, errors.New("handler rejected")
}
return map[string]any{"did": t}, nil
})
return b
}
func TestDispatchFourEntrancesSameAudit(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
for _, ent := range []string{"button", "slash", "chat", "card", "api", "weird-unknown"} {
resp := b.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: ent})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("entrance %s: state=%s reason=%s", ent, resp.Receipt.State, resp.Receipt.Reason)
}
}
if len(a.logs) != 6 {
t.Fatalf("audit logs = %d, want 6", len(a.logs))
}
want := map[string]string{"button": "button", "slash": "slash", "chat": "chat", "card": "card", "api": "api", "weird-unknown": "api"}
for i, env := range a.logs {
if env.Source != agentcommand.SourceBuiltin {
t.Fatalf("log %d source = %s", i, env.Source)
}
}
_ = want
// 入口归一:未知名归 api
if got := a.logs[5].Entrance; got != "api" {
t.Fatalf("unknown entrance canonicalized to %s, want api", got)
}
if a.logs[0].OperationID == "" {
t.Fatal("committed receipt must carry operationID")
}
}
func TestDispatchUnknownAndBadParams(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
resp := b.Dispatch(context.Background(), Request{ID: "nope.x"})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "unknown") {
t.Fatalf("unknown: %+v", resp.Receipt)
}
resp = b.Dispatch(context.Background(), Request{ID: "demo.risky", Confirmed: true, Params: map[string]any{}})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "required param target") {
t.Fatalf("missing required: %+v", resp.Receipt)
}
}
func TestHighDangerConfirmFlow(t *testing.T) {
a := &memAudit{}
cards := &memCards{}
b := newTestBus(a, cards)
// 未确认:创建内联确认卡,回执 accepted + operationID=卡 ID。
resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "prod"}, Entrance: "button", ConversationID: "cnv_1", AnchorMessageID: "msg_1"})
if resp.Receipt.State != agentcommand.StateAccepted || resp.Receipt.OperationID != "card_x1" {
t.Fatalf("pre-confirm: %+v", resp.Receipt)
}
if resp.Card == nil || resp.Card.Kind != core.CardKindConfirm || resp.Card.Status != core.CardActionRequired {
t.Fatalf("confirm card not created properly: %+v", resp.Card)
}
if resp.Card.ConversationID != "cnv_1" || resp.Card.AnchorMessageID == "" {
t.Fatalf("card anchoring missing: %+v", resp.Card)
}
// 确认:执行挂起请求,committed。
resp2 := b.Confirm("owner", "card_x1", "confirm")
if resp2.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("confirm: %+v", resp2.Receipt)
}
if m, _ := resp2.Payload.(map[string]any); m == nil || m["did"] != "prod" {
t.Fatalf("confirm payload: %+v", resp2.Payload)
}
// 二次确认(已消费):rejected expired。
resp3 := b.Confirm("owner", "card_x1", "confirm")
if resp3.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp3.Receipt.Reason, "expired") {
t.Fatalf("re-confirm: %+v", resp3.Receipt)
}
// 审计链:accepted(button) → committed(card);过期确认未触碰命令,不产生审计。
if len(a.logs) != 2 {
t.Fatalf("audit logs = %d, want 2", len(a.logs))
}
if a.logs[0].State != agentcommand.StateAccepted || a.logs[0].Entrance != "button" ||
a.logs[1].State != agentcommand.StateCommitted || a.logs[1].Entrance != "card" {
t.Fatalf("audit chain wrong: %+v", a.logs)
}
}
func TestHighDangerCancelAndHandlerReject(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "x"}, ConversationID: "cnv_1"})
if r := b.Confirm("o", "card_x1", "cancel"); r.Receipt.State != agentcommand.StateRejected || !strings.Contains(r.Receipt.Reason, "cancelled") {
t.Fatalf("cancel: %+v", r.Receipt)
}
// handler 拒绝路径(确认后执行失败)→ rejected + 理由。
b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "boom"}, ConversationID: "cnv_1"})
r := b.Confirm("o", "card_x1", "confirm")
if r.Receipt.State != agentcommand.StateRejected || !strings.Contains(r.Receipt.Reason, "handler rejected") {
t.Fatalf("handler reject: %+v", r.Receipt)
}
}
func TestHighDangerWithoutCardPortRejected(t *testing.T) {
b := newTestBus(&memAudit{}, nil)
resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "x"}, ConversationID: "cnv_1"})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "conversation context") {
t.Fatalf("no card port: %+v", resp.Receipt)
}
}
func TestAuditUnavailableYieldsUnknown(t *testing.T) {
a := &memAudit{fail: true}
b := newTestBus(a, &memCards{})
resp := b.Dispatch(context.Background(), Request{ID: "demo.ping"})
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
t.Fatalf("audit down: %+v", resp.Receipt)
}
}
func TestPluginCommandsAndSlash(t *testing.T) {
b := newTestBus(&memAudit{}, &memCards{})
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"plug.do","title":"插件命令"}`)}); err != nil {
t.Fatal(err)
}
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"bad id","title":"x"}`)}); err == nil {
t.Fatal("invalid plugin spec must be rejected")
}
if id, ok := b.ResolveSlash("/risky"); !ok || id != "demo.risky" {
t.Fatalf("slash resolve: %s %v", id, ok)
}
if _, ok := b.ResolveSlash("/nope"); ok {
t.Fatal("unknown slash must not resolve")
}
if !b.HasCommand("plug.do") || b.HasCommand("plug.missing") {
t.Fatal("HasCommand wrong")
}
}
func TestValidateParamsScalars(t *testing.T) {
schema := json.RawMessage(`{"type":"object","required":["n"],"properties":{"n":{"type":"number"},"s":{"type":"string"},"b":{"type":"boolean"},"i":{"type":"integer"}}}`)
if err := validateParams(schema, map[string]any{"n": 1.5, "s": "x", "b": true, "i": 3}); err != nil {
t.Fatalf("valid params rejected: %v", err)
}
if err := validateParams(schema, map[string]any{}); err == nil || !strings.Contains(err.Error(), "required") {
t.Fatalf("missing required not caught: %v", err)
}
if err := validateParams(schema, map[string]any{"n": "not-a-number"}); err == nil {
t.Fatal("type mismatch not caught")
}
if err := validateParams(schema, map[string]any{"n": 1, "i": 2.5}); err == nil {
t.Fatal("integer fraction not caught")
}
if err := validateParams(nil, map[string]any{"any": 1}); err != nil {
t.Fatalf("nil schema must pass: %v", err)
}
}
// ---- EXT-1:进程内插件命令处理器 ----
func TestRegisterPluginCommandDispatch(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.ingest", Title: "入库"}, func(ctx context.Context, req Request) (any, error) {
return map[string]any{"echo": req.Params["ref"], "owner": req.Owner}, nil
})
if err != nil {
t.Fatal(err)
}
resp := b.Dispatch(context.Background(), Request{ID: "rdm.ingest", Params: map[string]any{"ref": "doc-1"}, Owner: "alice"})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("dispatch: %+v", resp.Receipt)
}
m, _ := resp.Payload.(map[string]any)
if m["echo"] != "doc-1" || m["owner"] != "alice" {
t.Fatalf("payload: %+v", resp.Payload)
}
if len(a.logs) != 1 || a.logs[0].Source != agentcommand.SourcePlugin {
t.Fatalf("audit: %+v", a.logs)
}
// 重复注册同一 ID:fail-closed。
if err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.ingest", Title: "重复"}, nil); err == nil {
t.Fatal("duplicate plugin command must be rejected")
}
}
func TestPluginCommandWithoutHandlerRejected(t *testing.T) {
b := newTestBus(&memAudit{}, &memCards{})
// 声明式路径只登记规格(manifest provides.commands)。
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"plug.only","title":"仅规格"}`)}); err != nil {
t.Fatal(err)
}
resp := b.Dispatch(context.Background(), Request{ID: "plug.only"})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "no host handler bound") {
t.Fatalf("unbound: %+v", resp.Receipt)
}
}
func TestHighDangerPluginCommandConfirmsFirst(t *testing.T) {
a := &memAudit{}
cards := &memCards{}
b := newTestBus(a, cards)
executed := 0
err := b.RegisterPluginCommand(agentcommand.CommandSpec{
ID: "rdm.purge", Title: "清库", Danger: agentcommand.DangerHigh,
}, func(ctx context.Context, req Request) (any, error) {
executed++
return "purged", nil
})
if err != nil {
t.Fatal(err)
}
// 未确认:先出内联确认卡,处理器不得执行。
resp := b.Dispatch(context.Background(), Request{ID: "rdm.purge", ConversationID: "cnv_9", Owner: "alice"})
if resp.Receipt.State != agentcommand.StateAccepted || resp.Card == nil || resp.Card.Kind != core.CardKindConfirm {
t.Fatalf("pre-confirm: %+v card=%+v", resp.Receipt, resp.Card)
}
if executed != 0 {
t.Fatal("handler must not run before confirm")
}
// 确认后执行 committed。
resp2 := b.Confirm("alice", "card_x1", "confirm")
if resp2.Receipt.State != agentcommand.StateCommitted || resp2.Payload != "purged" || executed != 1 {
t.Fatalf("confirm: %+v executed=%d", resp2.Receipt, executed)
}
}