Files
Agent-System-Backend/internal/genui/genui_test.go
T
weichenandCursor 5e0077dc11
ci / vet-and-test (pull_request) Successful in 48s
Whitelist B-class surface actions for help and intent
Why: Generative UI U5 left the B-class action allowlist empty; owner
authorized first entries builtin.help and system.intent.
Evidence: go test ./internal/genui/ ./internal/surfacesvc/ ./internal/server/
Impact: Surface buttons may bind help/intent; button-entrance intent
matches only and does not nest-dispatch write commands.
Refs: docs/proposals/2026-10-12-generative-ui-bclass-help-intent.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:18:57 +00:00

386 lines
14 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 genui
// U3 红测(提案 U3 验证行):能力缺失明确拒绝;未启用拒绝;伪造 doc-ref 拒绝;
// 目录违规返回字段级清单(供模型修复重提);成功路径经 EXT-17 提交并携带观察 epoch。
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// realSvc 让 fakeHost.SurfaceSave 走真实 surfacesvc(校验/守卫行为与生产一致)。
var realSvc *surfacesvc.Service
func newRealSvc(t *testing.T) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
realSvc = surfacesvc.New(st, nil)
if _, err := realSvc.SetActivation(context.Background(), "alice", PluginID, true); err != nil {
t.Fatal(err)
}
}
type fakeHost struct {
extension.Host // 未覆盖方法仅测试未触达
enabled bool
epoch int64
saved []extension.SurfaceSaveRequest
artifacts map[string]bool
}
func (h *fakeHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
return h.enabled, h.epoch, nil
}
func (h *fakeHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
h.saved = append(h.saved, req)
return realSvc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID,
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
SemanticText: req.SemanticText, Spec: req.Spec,
})
}
func (h *fakeHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
return realSvc.Get(ctx, "alice", surfaceID)
}
func (h *fakeHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
return realSvc.List(ctx, "alice", conversationID)
}
func (h *fakeHost) ArtifactGet(id string) (core.Artifact, error) {
if h.artifacts[id] {
return core.Artifact{ID: core.ID(id)}, nil
}
return core.Artifact{}, store.ErrNotFound
}
func validSpec() map[string]any {
return map[string]any{
"title": "选型对比",
"nodes": []any{
map[string]any{"type": "section", "id": "n1", "title": "概览", "children": []any{
map[string]any{"type": "markdown", "id": "n1a", "source": []any{"doc:a"}, "markdown": "结论先行。"},
}},
map[string]any{"type": "table", "id": "n2", "table": map[string]any{
"columns": []any{map[string]any{"key": "k", "label": "维度"}},
"rows": []any{map[string]any{"cells": []any{[]any{"k", "价格"}}}},
}},
},
}
}
func reqWith(spec map[string]any, extra ...map[string]any) extension.Request {
params := map[string]any{"spec": spec}
for _, e := range extra {
for k, v := range e {
params[k] = v
}
}
return extension.Request{ID: "r1", Params: params, Owner: "alice", ConversationID: "c1"}
}
func TestPresentGuards(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
// 能力缺失(Host 未实现 SurfaceHost)→ 明确报错
if _, err := present(ctx, plainHost{}, reqWith(validSpec())); err == nil {
t.Fatal("capability-missing host should fail explicitly")
}
// 未启用 → 拒绝
h := &fakeHost{enabled: false, artifacts: map[string]bool{}}
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
t.Fatal("disabled plugin should reject submit")
}
// 伪造 doc-ref:工件不存在 → 字段级工具错误
h = &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{"type": "doc-ref", "docId": "art_ghost"}}}
res, err := present(ctx, h, reqWith(spec))
if err != nil {
t.Fatalf("doc-ref violation should be tool-error, got %v", err)
}
body, _ := json.Marshal(res)
if !strings.Contains(string(body), "art_ghost") {
t.Fatalf("field errors should name the bad docId: %s", body)
}
// 目录违规(未知字段 color)→ 字段级清单
bad := map[string]any{"title": "x", "nodes": []any{map[string]any{"type": "list", "items": []any{"a"}, "color": "red"}}}
res, err = present(ctx, h, reqWith(bad))
if err != nil {
t.Fatalf("invalid spec should be tool-error, got %v", err)
}
te, ok := res.(toolErrorBody)
if !ok || te.OK || len(te.Details) == 0 {
t.Fatalf("expected field-level details, got %+v", res)
}
}
func TestPresentSubmitCarriesEpochAndRevises(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
res, err := present(ctx, h, reqWith(validSpec()))
if err != nil {
t.Fatal(err)
}
m, _ := res.(map[string]any)
if m["ok"] != true || fmt.Sprint(m["revision"]) != "1" {
t.Fatalf("submit result: %+v", m)
}
if len(h.saved) != 1 || h.saved[0].ObservedEpoch != 1 {
t.Fatalf("submit must carry observed epoch: %+v", h.saved)
}
// 修订:带 surfaceId + baseRevision → revision 2
res2, err := present(ctx, h, reqWith(validSpec(), map[string]any{
"surfaceId": m["surfaceId"], "baseRevision": float64(1)}))
if err != nil {
t.Fatal(err)
}
m2, _ := res2.(map[string]any)
if fmt.Sprint(m2["revision"]) != "2" {
t.Fatalf("revision should advance to 2: %+v", m2)
}
// 禁用后:执行端拒绝(闸外第二道守卫)
h.enabled = false
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
t.Fatal("post-disable submit should reject")
}
}
func TestRegisterSpecShape(t *testing.T) {
var captured []json.RawMessage
reg := &fakeRegistry{captureList: &captured}
if err := register(reg); err != nil {
t.Fatal(err)
}
byID := map[string]json.RawMessage{}
for _, raw := range captured {
var id struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &id); err != nil {
t.Fatal(err)
}
byID[id.ID] = raw
}
var spec struct {
ID string `json:"id"`
Description string `json:"description"`
ExposeAsTool map[string]any `json:"exposeAsTool"`
}
if raw, ok := byID[CommandPresent]; !ok {
t.Fatalf("present command not registered: %v", captured)
} else if err := json.Unmarshal(raw, &spec); err != nil {
t.Fatal(err)
}
if spec.ID != CommandPresent || spec.ExposeAsTool == nil {
t.Fatalf("present must expose as tool: %s", byID[CommandPresent])
}
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit", "builtin.help", "system.intent"} {
if !strings.Contains(spec.Description, need) {
t.Fatalf("catalog incomplete: missing %q", need)
}
}
var submit struct {
ExposeAsTool map[string]any `json:"exposeAsTool"`
}
if raw, ok := byID[CommandSubmit]; !ok {
t.Fatalf("submit command not registered: %v", captured)
} else {
_ = json.Unmarshal(raw, &submit)
if submit.ExposeAsTool != nil {
t.Fatal("submit must NOT expose as tool (button-only; model cannot call)")
}
}
}
type plainHost struct{ extension.Host }
type fakeRegistry struct{ captureList *[]json.RawMessage }
func (r *fakeRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
*r.captureList = append(*r.captureList, spec)
return nil
}
func (r *fakeRegistry) AddSkill(name, markdown string) error { return nil }
func (r *fakeRegistry) AddCommand(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return nil }
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
return nil
}
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{
"type": "actions", "id": "a1",
"actions": []any{
map[string]any{"id": "shell.exec", "label": "危险"},
map[string]any{"id": "generative-ui.submit", "label": "提交"},
}}}}
res, err := present(ctx, h, reqWith(spec))
if err != nil {
t.Fatalf("should be tool error, got %v", err)
}
te, ok := res.(toolErrorBody)
if !ok || te.OK || len(te.Details) == 0 {
t.Fatalf("expected field errors, got %+v", res)
}
if !strings.Contains(strings.Join(te.Details, ";"), "shell.exec") {
t.Fatalf("errors should name the unknown action: %+v", te)
}
}
func TestValidateActionIDsAllowsBClassHelpAndIntent(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"title": "t", "nodes": []any{map[string]any{
"type": "actions", "id": "a1",
"actions": []any{
map[string]any{"id": ActionHelp, "label": "帮助"},
map[string]any{"id": ActionIntent, "label": "意图"},
map[string]any{"id": CommandSubmit, "label": "提交"},
}}}})
if errs := ValidateActionIDs(raw); len(errs) != 0 {
t.Fatalf("B-class help/intent + submit should pass, got %v", errs)
}
}
func TestSubmitFormStoresLedger(t *testing.T) {
// C 类:表单数据落插件 KV 台账(键含 requestId 天然幂等)。
var kv map[string]string = map[string]string{}
h := &ledgerHost{kv: kv}
res, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
"surfaceId": "surf_x", "requestId": "req-1", "revision": float64(1),
"data": map[string]any{"years": "5"},
}})
if err != nil {
t.Fatal(err)
}
m, _ := res.(map[string]any)
if m["ok"] != true || m["stored"] != true {
t.Fatalf("submit result: %+v", m)
}
if v, ok := kv["form:surf_x:req-1"]; !ok || !strings.Contains(v, `"years":"5"`) {
t.Fatalf("ledger missing data: %v", kv)
}
// 幂等重放同键覆盖同值
if _, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
"surfaceId": "surf_x", "requestId": "req-1", "data": map[string]any{"years": "5"},
}}); err != nil {
t.Fatal(err)
}
}
type ledgerHost struct {
extension.Host
kv map[string]string
}
func (h *ledgerHost) KVPut(key, value string) error { h.kv[key] = value; return nil }
func (h *ledgerHost) Audit(action, target, evidence string) error { return nil }
func TestNormalizeChildrenShape(t *testing.T) {
// 真实偏差(2026-10-11 dev 实测):模型把 children 写成按 key 编号的对象映射。
in := map[string]any{
"type": "section", "id": "n1", "title": "结论",
"children": map[string]any{
"item": map[string]any{"type": "markdown", "id": "m1", "markdown": "结论先行"},
},
}
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
nodes := out.(map[string]any)["nodes"].([]any)
ch := nodes[0].(map[string]any)["children"].([]any)
if len(ch) != 1 || ch[0].(map[string]any)["id"] != "m1" {
t.Fatalf("children should be repaired to array, got %v", ch)
}
}
// 真实偏差(2026-10-12 dev 实测,事件 seq 1395/2171):MiniMax-M3 对数组字段输出
// XML-RPC 风格 {"item": …} 包装——kv/cells/items 整字段被包一层 {"item":[…]},
// 数组元素再被包 {"item":["k","v"]}/{"item":"v"};表格行 cells 另有与列等长的
// 裸标量数组形态。修复层须解包并按列 key 配对,present 才能落库。
func TestPresentRepairsItemWrappers(t *testing.T) {
newRealSvc(t)
h := &fakeHost{enabled: true, epoch: 1}
spec := map[string]any{
"title": "本月支出结构分析(示例数据演示)",
"nodes": []any{
map[string]any{"type": "markdown", "id": "disclaimer", "markdown": "示例数据,非真实账单。"},
map[string]any{"type": "kv", "id": "kv1", "kv": map[string]any{"item": []any{
map[string]any{"item": []any{"月份", "2026-10"}},
map[string]any{"item": []any{"总支出", "¥10,000"}},
}}},
map[string]any{"type": "list", "id": "l1", "items": map[string]any{"item": []any{
map[string]any{"item": "餐饮 35%"},
map[string]any{"item": "住房 25%"},
}}},
map[string]any{"type": "table", "id": "t1", "table": map[string]any{
"columns": []any{
map[string]any{"key": "cat", "label": "分类"},
map[string]any{"key": "amt", "label": "金额"},
map[string]any{"key": "pct", "label": "占比"},
},
"rows": []any{
map[string]any{"cells": map[string]any{"item": []any{
map[string]any{"item": "餐饮"}, map[string]any{"item": "¥3,500"}, map[string]any{"item": "35%"},
}}},
},
}},
},
}
res, err := present(context.Background(), h, reqWith(spec))
if err != nil {
t.Fatalf("present: %v", err)
}
if body, ok := res.(toolErrorBody); ok {
t.Fatalf("present should repair item wrappers, got %s %v", body.Error, body.Details)
}
if len(h.saved) != 1 {
t.Fatalf("expected one saved surface, got %d", len(h.saved))
}
var saved core.SurfaceSpec
if err := json.Unmarshal(h.saved[0].Spec, &saved); err != nil {
t.Fatalf("saved spec should unmarshal: %v", err)
}
if saved.Nodes[1].KV[0] != [2]string{"月份", "2026-10"} || saved.Nodes[1].KV[1][1] != "¥10,000" {
t.Fatalf("kv pairs should be unwrapped, got %v", saved.Nodes[1].KV)
}
if len(saved.Nodes[2].Items) != 2 || saved.Nodes[2].Items[0] != "餐饮 35%" {
t.Fatalf("list items should be unwrapped, got %v", saved.Nodes[2].Items)
}
cells := saved.Nodes[3].Table.Rows[0].Cells
if len(cells) != 3 || cells[0] != [2]string{"cat", "餐饮"} || cells[2][1] != "35%" {
t.Fatalf("table cells should pair with column keys, got %v", cells)
}
}
// 长度不匹配的裸 cells 不做发明性配对:保留原样交校验器给字段级错误。
func TestRepairTableCellsLeavesMismatchedShapes(t *testing.T) {
in := map[string]any{"type": "table", "id": "t1", "table": map[string]any{
"columns": []any{map[string]any{"key": "a"}, map[string]any{"key": "b"}},
"rows": []any{map[string]any{"cells": []any{"x", "y", "z"}}},
}}
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
rows := out.(map[string]any)["nodes"].([]any)[0].(map[string]any)["table"].(map[string]any)["rows"].([]any)
got := rows[0].(map[string]any)["cells"].([]any)
if len(got) != 3 || got[0] != "x" {
t.Fatalf("mismatched scalars should stay untouched, got %v", got)
}
}