ci / vet-and-test (pull_request) Successful in 48s
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>
386 lines
14 KiB
Go
386 lines
14 KiB
Go
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)
|
||
}
|
||
}
|