ci / vet-and-test (pull_request) Successful in 42s
Why: Generative UI U3 - the agent needs a real submission path and the next turn needs to see prior pages: a built-in in-process plugin registers generative-ui.present (exposeAsTool) carrying the catalog (prompt=validation single source) with a schema-repair strategy measured against real MiniMax-M3 deviations; the command bus gains an owner-scoped tool gate so a disabled plugin stops injecting its tool (execution guard remains in surfacesvc); ctxasm projects saved surfaces back into context so the model can revise existing structure; doc-refs are authorized against the owner's real artifacts. Evidence: unit suites green (genui guards/epoch/doc-ref, gate fail-closed, projection content); full go test ./... green; cmdbus/ctxasm also green under AGENT_PG_TEST_DSN; real-model closed loop (env-gated TestRealModelSurfaceLoop) passes: task->spec->save->projection->revise with revision 1->2 and structure change, CNY 0.08 total. Impact: New internal/genui package; additive cmdbus/ctxasm/runmgr/ runtime hooks; no storage or contract changes (U2 layer untouched). Refs: Documents proposal r2 U3; Core PR #25/#26; Backend #143; Dev task feature/generative-ui-u3
204 lines
6.8 KiB
Go
204 lines
6.8 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{capture: &captured}
|
|
if err := register(reg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var spec struct {
|
|
ID string `json:"id"`
|
|
Description string `json:"description"`
|
|
ExposeAsTool map[string]any `json:"exposeAsTool"`
|
|
ParamsSchema map[string]any `json:"paramsSchema"`
|
|
}
|
|
if err := json.Unmarshal(captured, &spec); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if spec.ID != CommandPresent || spec.ExposeAsTool == nil {
|
|
t.Fatalf("spec must expose as tool: %s", captured)
|
|
}
|
|
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点"} {
|
|
if !strings.Contains(spec.Description, need) {
|
|
t.Fatalf("catalog incomplete: missing %q", need)
|
|
}
|
|
}
|
|
}
|
|
|
|
type plainHost struct{ extension.Host }
|
|
|
|
type fakeRegistry struct{ capture *json.RawMessage }
|
|
|
|
func (r *fakeRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
|
*r.capture = 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 }
|