Add the generative-ui plugin, capability gating, and surface projection
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
This commit is contained in:
2026-10-11 10:38:15 +00:00
parent 2bfebe378c
commit 367dc207b3
15 changed files with 939 additions and 3 deletions
+16 -1
View File
@@ -34,6 +34,7 @@ import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/config"
"agent-system-backend/internal/genui"
"agent-system-backend/internal/hostapi"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsem"
@@ -493,6 +494,16 @@ func Run(opt Options) error {
// 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
bus.SetToolGate(func(owner, commandID string) bool {
if !strings.HasPrefix(commandID, genui.PluginID+".") {
return true
}
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
})
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
@@ -824,7 +835,11 @@ func splitKeyPool(s string) []string {
type busAdapter struct{ bus *cmdbus.Bus }
func (a busAdapter) CommandTools() []runmgr.CommandTool {
src := a.bus.CommandTools()
return a.CommandToolsFor("")
}
func (a busAdapter) CommandToolsFor(owner string) []runmgr.CommandTool {
src := a.bus.CommandToolsFor(owner)
out := make([]runmgr.CommandTool, len(src))
for i, ct := range src {
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
+17
View File
@@ -45,12 +45,28 @@ type CommandTool struct {
}
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
// ToolGate 是 owner 级工具注入闸(U3:禁用即不注入能力):返回 false 时该命令
// 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。
type ToolGate func(owner, commandID string) bool
// SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。
func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g }
func (b *Bus) CommandTools() []CommandTool {
return b.CommandToolsFor("")
}
// CommandToolsFor 按 owner 过滤工具投影(gate 为 nil 时不过滤;owner 为空时
// gated 命令一律不投影——fail-closed)。
func (b *Bus) CommandToolsFor(owner string) []CommandTool {
out := []CommandTool{}
for _, r := range b.reg.List() {
if !r.Enabled || r.Spec.ExposeAsTool == nil {
continue
}
if b.toolGate != nil && !b.toolGate(owner, r.Spec.ID) {
continue
}
risk := r.Spec.ExposeAsTool.Risk
if risk == "" {
risk = "low"
@@ -101,6 +117,7 @@ type Bus struct {
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
+28
View File
@@ -6,6 +6,7 @@ package cmdbus
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
@@ -72,3 +73,30 @@ func TestCommandToolDispatchEnvelope(t *testing.T) {
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)
}
}
+53
View File
@@ -41,6 +41,9 @@ type AssembleOptions struct {
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
ThinkReplay bool
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
Surfaces []core.SurfaceSnapshot
}
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
@@ -58,6 +61,17 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
rep.Items = append(rep.Items, "system(画像自定义段)")
}
if len(opts.Surfaces) > 0 {
var b strings.Builder
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
for _, snap := range opts.Surfaces {
b.WriteString(projectSurface(snap))
b.WriteString("\n")
}
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
}
maxHist := prof.Context.MaxHistoryMessages
if maxHist <= 0 {
@@ -499,3 +513,42 @@ func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
return out
}
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
func projectSurface(snap core.SurfaceSnapshot) string {
var b strings.Builder
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
if snap.SemanticText != "" {
fmt.Fprintf(&b, ":%s", snap.SemanticText)
}
b.WriteString("\n 结构:")
var walk func(n core.UINode)
first := true
walk = func(n core.UINode) {
label := string(n.Type)
if n.Title != "" {
label += "(\"" + n.Title + "\")"
}
if n.ID != "" {
label += "#" + n.ID
}
if len(n.Source) > 0 {
label += "[" + strings.Join(n.Source, ",") + "]"
}
if first {
b.WriteString(label)
first = false
} else {
b.WriteString(" > " + label)
}
for _, c := range n.Children {
walk(c)
}
}
for _, n := range snap.Spec.Nodes {
walk(n)
}
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
return b.String()
}
+52
View File
@@ -0,0 +1,52 @@
package ctxasm
// U3/R6:已保存页面的语义投影并入上下文——下一轮模型能看到结构、来源与修订
// 入口(present + baseRevision),可据此解释并修改已有 table/compare/tabs。
import (
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
func surfaceSnap() core.SurfaceSnapshot {
return core.SurfaceSnapshot{
Identity: core.SurfaceIdentity{SurfaceID: "surf_x", Owner: "alice", PluginID: "generative-ui", ConversationID: "c1"},
Revision: 2,
Spec: core.SurfaceSpec{Title: "选型对比", Nodes: []core.UINode{
{Type: core.UISection, ID: "n1", Title: "概览", Children: []core.UINode{
{Type: core.UIMarkdown, ID: "n1a", Source: []string{"doc:a"}, Markdown: "结论"},
}},
{Type: core.UITable, ID: "n2", Source: []string{"doc:a", "doc:b"}},
}},
SemanticText: "三工具对比页",
}
}
func TestSurfaceProjectionInContext(t *testing.T) {
msgs, rep := AssembleInput(core.AgentProfile{}, nil, nil,
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改一下"}}},
nil, nil, nil, nil, nil, nil, AssembleOptions{Surfaces: []core.SurfaceSnapshot{surfaceSnap()}})
joined := make([]string, len(msgs))
for i, m := range msgs {
joined[i] = m.Content
}
all := strings.Join(joined, "\n---\n")
for _, need := range []string{"surf_x", "修订 2", "选型对比", "section(\"概览\")#n1", "markdown#n1a[doc:a]", "table#n2", "baseRevision=2"} {
if !strings.Contains(all, need) {
t.Fatalf("projection missing %q in assembled context:\n%s", need, all)
}
}
found := false
for _, it := range rep.Items {
if strings.HasPrefix(it, "surface(") {
found = true
}
}
if !found {
t.Fatalf("report should count surface injection: %v", rep.Items)
}
_ = agentllm.RoleSystem
}
+282
View File
@@ -0,0 +1,282 @@
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
package genui
import (
"context"
"encoding/json"
"fmt"
"strings"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// PluginID 是插件身份(activation 台账与命令前缀共用)。
const PluginID = "generative-ui"
// CommandPresent 是唯一提交命令 id(revise 即带 surfaceId+baseRevision 的再次 present)。
const CommandPresent = PluginID + ".present"
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
const Catalog = `可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
form{fields:[{key,label,type:text|textarea|number|boolean|select,options?(select必填),required?,placeholder?,help?,default?}]};
markdown{markdown};doc-ref{docId}(须为会话内存在的工件ID);
actions{actions:[{id,label,style?:primary|default|danger}]}(不带command);
chart-lite{chart:{kind:bar|line,labels:[],series:[]等长}};avatar{avatar:{name,sub?,tone?:ok|warn|danger|info,stack?}};
progress{progress:{label?,value:0~1}};compare{compare:{left:{title?,tone?,nodes?},right:{同left}}};
tree{tree:{nodes:[{id,label,tone?,badge?,count?,children?}]}};
table{table:{columns:[{key,label}],rows:[{id?,cells:[[key,值]与columns等长同序}]}};
steps{steps:{current:0起,steps:[{label,status?:pending|active|done|failed,tone?}]}};
tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title,time?,tone?,markdown?,ref?}]}}。
规则:每节点可带 id(整页唯一稳定)与 source[](支撑事实的来源标识);事实性内容标注来源,
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
{"title":"示例页","nodes":[
{"type":"section","id":"n1","title":"结论","children":[
{"type":"markdown","id":"n1a","source":["doc:m1"],"markdown":"结论先行。"}]},
{"type":"table","id":"n2","table":{"columns":[{"key":"k","label":"维度"},{"key":"v","label":"值"}],
"rows":[{"id":"r1","cells":[["k","报价"],["v","¥8/人/天"]]}]}},
{"type":"steps","id":"n3","steps":{"current":0,"steps":[{"label":"评审","status":"pending"}]}}]}`
func init() {
extension.RegisterCompiled(extension.InProc{ID: PluginID, Register: register})
}
func register(reg extension.Registry) error {
spec := json.RawMessage(fmt.Sprintf(`{
"id": %q,
"title": "生成页面",
"description": %q,
"paramsSchema": {
"type": "object",
"properties": {
"spec": {"type": "object", "description": "页面规格:顶层恰为 {title: string, nodes: [节点数组]}",
"properties": {"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object"}}},
"required": ["title", "nodes"]},
"surfaceId": {"type": "string", "description": "修订已有页面时传入"},
"baseRevision": {"type": "integer", "description": "修订基于的当前修订号"},
"summary": {"type": "string", "description": "一句话语义摘要(回放与索引用)"}
},
"required": ["spec"]
},
"exposeAsTool": {"risk": "low"}
}`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog))
return reg.AddCommandHandler(spec, present)
}
// present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。
// 能力缺失(宿主未装配)与未启用明确报错;doc-ref 经 owner 作用域工件解析做
// 真实授权(伪造引用拒绝);校验失败返回字段级清单供模型修复重提(至多一次)。
func present(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
sh, ok := h.(extension.SurfaceHost)
if !ok {
return nil, fmt.Errorf("generative-ui: surface capability not available in this host")
}
enabled, epoch, err := sh.SurfaceActivation(ctx)
if err != nil {
return nil, err
}
if !enabled {
return nil, fmt.Errorf("generative-ui: 插件未启用(owner 未开启 Surface 能力),本次提交被拒绝")
}
raw, _ := json.Marshal(normalizeSpecParam(req.Params["spec"]))
surfaceID, _ := req.Params["surfaceId"].(string)
base := 0
if v, ok := req.Params["baseRevision"].(float64); ok {
base = int(v)
}
summary, _ := req.Params["summary"].(string)
// doc-ref 真实授权:spec 里的 docId 必须是当前 owner 可解析的工件(伪造即拒)。
if errs := authorizeDocRefs(raw, h); len(errs) > 0 {
return toolError("invalid spec", errs), nil
}
snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{
SurfaceID: core.ID(surfaceID),
ConversationID: core.ID(req.ConversationID),
BaseRevision: base,
ObservedEpoch: epoch,
SemanticText: summary,
Spec: raw,
})
if err != nil {
if strings.HasPrefix(err.Error(), "surface: invalid spec:") {
details := strings.TrimSpace(strings.TrimPrefix(err.Error(), "surface: invalid spec:"))
var list []string
for _, d := range strings.Split(details, ";") {
if t := strings.TrimSpace(d); t != "" {
list = append(list, t)
}
}
return toolError("invalid spec", list), nil
}
return nil, err
}
return map[string]any{
"ok": true,
"surfaceId": string(snap.Identity.SurfaceID),
"revision": snap.Revision,
"epoch": snap.ActivationEpoch,
"summary": snap.SemanticText,
"note": "页面已保存;用户在预览区查看。修订时带 surfaceId 与 baseRevision 重提。",
}, nil
}
// toolError 是返回给模型的字段级错误(工具结果错误,非分发错误)。
type toolErrorBody struct {
OK bool `json:"ok"`
Error string `json:"error"`
Details []string `json:"details,omitempty"`
Note string `json:"note"`
}
func toolError(msg string, details []string) toolErrorBody {
return toolErrorBody{OK: false, Error: msg, Details: details,
Note: "按错误修正 spec 后可重提一次;再次失败请改用文本回答。"}
}
// normalizeSpecParam 是插件的 schema 修复策略(r1 KTD1;U3 回放实测的模型偏差
// 逐条落地):单键包装({"page":…})解包;顶层 children→nodes;kv/cells 的
// 对象形式({"k":…,"v":…}/{"key":…,"value":…})改写为二元组数组。只做结构
// 改写,不改内容语义;修复后仍过 core 校验器。
func normalizeSpecParam(v any) any {
if arr, ok := v.([]any); ok {
return map[string]any{"title": "", "nodes": arr}
}
m, ok := v.(map[string]any)
if !ok {
return v
}
if len(m) == 1 {
if inner, ok := m["page"].(map[string]any); ok {
m = inner
}
}
if _, ok := m["nodes"]; !ok {
if ch, ok := m["children"].([]any); ok {
m["nodes"] = ch
delete(m, "children")
}
}
// 页级 source(模型的常见引用写法):分发给未自带 source 的节点——保留引用
// 信息,不发明内容;节点自带 source 者不受影响。
if top, ok := m["source"].([]any); ok {
delete(m, "source")
if nodes, ok := m["nodes"].([]any); ok {
for _, n := range nodes {
if nm, ok := n.(map[string]any); ok {
if _, has := nm["source"]; !has {
cp := make([]any, len(top))
copy(cp, top)
nm["source"] = cp
}
}
}
}
}
repairPairs(m)
return m
}
// repairPairs 递归把 "kv"/"cells" 下的对象项改写为二元组。
func repairPairs(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
for key, val := range m {
if (key == "kv" || key == "cells") && val != nil {
if arr, ok := val.([]any); ok {
fixed := make([]any, 0, len(arr))
changed := false
for _, item := range arr {
if p, ok := pairFromObject(item); ok {
fixed = append(fixed, p)
changed = true
} else {
fixed = append(fixed, item)
}
}
if changed {
m[key] = fixed
}
}
}
repairPairs(val)
}
}
// pairFromObject 从 {"k","v"}/{"key","value"}/{"name","value"} 对象提取二元组。
func pairFromObject(item any) ([2]any, bool) {
obj, ok := item.(map[string]any)
if !ok {
return [2]any{}, false
}
var k, v any
for kk, vv := range obj {
switch kk {
case "k", "key", "name":
k = vv
case "v", "value":
v = vv
default:
return [2]any{}, false
}
}
if k == nil || v == nil {
return [2]any{}, false
}
ks, ok1 := k.(string)
vs, ok2 := v.(string)
if !ok1 || !ok2 {
return [2]any{}, false
}
return [2]any{ks, vs}, true
}
// authorizeDocRefs 收集 spec 中的 doc-ref docId 并经 Host 工件面做 owner 作用域解析。
func authorizeDocRefs(raw []byte, h extension.Host) []string {
var spec struct {
Nodes []core.UINode `json:"nodes"`
}
if err := json.Unmarshal(raw, &spec); err != nil {
return []string{"spec: " + err.Error()}
}
var errs []string
var walk func(n core.UINode, path string)
walk = func(n core.UINode, path string) {
if n.Type == core.UIDocRef && n.DocID != "" {
if _, err := h.ArtifactGet(string(n.DocID)); err != nil {
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用了不存在或无权限的工件 %q", path, n.DocID))
}
}
for i, c := range n.Children {
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
}
if n.Compare != nil {
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
for i, c := range sd.Nodes {
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
}
}
}
if n.Tabs != nil {
for i, tb := range n.Tabs.Tabs {
for j, c := range tb.Nodes {
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
}
}
}
}
for i, n := range spec.Nodes {
walk(n, fmt.Sprintf("$[%d]", i))
}
return errs
}
+203
View File
@@ -0,0 +1,203 @@
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 }
+251
View File
@@ -0,0 +1,251 @@
package genui
// U3 完成条件回放(真实模型,env 门控):任务→规格→保存→(语义投影回灌)→
// 继续修改→新修订。跑生产代码路径:present 处理器 + surfacesvc + store + ctxasm
// 投影(AssembleOptions.Surfaces)。模型 MiniMax-M3(2026-10-11 裁定)。
//
// 启用:GENUI_MINIMAX_API_KEY=<key> go test ./internal/genui/ -run TestRealModelSurfaceLoop -v -count=1
// 未设 env 时跳过(CI 无凭据;本地收据经 make evidence 记录)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
const materials = `材料:
【doc:m1】甲方案:报价 ¥8/人/天;私有化可选;近半年发布 6 次,平均修复 2 天。
【doc:m2】乙方案:报价 ¥12/人/天;仅 SaaS;万行表格场景无官方基准;升级断层(近两季仅小版本)。
【doc:m3】尽调备忘:合规缺口(高)——数据出境条款未覆盖;培训成本(低)——术语不一致。`
type chatResp struct {
Choices []struct {
Message struct {
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
func callModelWithRepair(t *testing.T, ctx context.Context, system, user string, try func(map[string]any) (any, error)) (map[string]any, any, chatResp) {
t.Helper()
var usage chatResp
params, r := callModel(t, ctx, system, user)
usage.Usage.PromptTokens += r.Usage.PromptTokens
usage.Usage.CompletionTokens += r.Usage.CompletionTokens
res, err := try(params)
if err == nil {
if te, ok := res.(toolErrorBody); !ok || te.OK {
return params, res, usage
} else {
res = te
}
} else if res == nil {
t.Fatalf("dispatch error: %v", err)
}
// 修复轮:字段级错误回传,模型重提一次(KTD2)。
fixUser := user + "\n\n上一次提交被拒绝:" + fmt.Sprint(res) + "\n请修正后重新调用工具。"
params2, r2b := callModel(t, ctx, system, fixUser)
usage.Usage.PromptTokens += r2b.Usage.PromptTokens
usage.Usage.CompletionTokens += r2b.Usage.CompletionTokens
res2, err2 := try(params2)
if err2 != nil {
t.Fatalf("repair dispatch error: %v", err2)
}
if te, ok := res2.(toolErrorBody); ok && !te.OK {
t.Fatalf("repair still invalid: %+v", te)
}
return params2, res2, usage
}
func callModel(t *testing.T, ctx context.Context, system, user string) (map[string]any, chatResp) {
t.Helper()
key := os.Getenv("GENUI_MINIMAX_API_KEY")
tool := map[string]any{"type": "function", "function": map[string]any{
"name": CommandPresent, "description": Catalog, "parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"spec": map[string]any{"type": "object"},
"surfaceId": map[string]any{"type": "string"},
"baseRevision": map[string]any{"type": "integer"},
"summary": map[string]any{"type": "string"},
},
"required": []string{"spec"},
}}}
body, _ := json.Marshal(map[string]any{
"model": "MiniMax-M3", "temperature": 0.3, "max_tokens": 14000,
"tools": []any{tool}, "tool_choice": map[string]any{"type": "function", "function": map[string]any{"name": CommandPresent}},
"messages": []any{
map[string]any{"role": "system", "content": system},
map[string]any{"role": "user", "content": user},
}})
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.minimaxi.com/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
var out chatResp
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("model call: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Choices) == 0 || len(out.Choices[0].Message.ToolCalls) == 0 {
t.Fatalf("no tool call in response")
}
var params map[string]any
if err := json.Unmarshal([]byte(out.Choices[0].Message.ToolCalls[0].Function.Arguments), &params); err != nil {
t.Fatalf("tool args: %v", err)
}
return params, out
}
func TestRealModelSurfaceLoop(t *testing.T) {
if os.Getenv("GENUI_MINIMAX_API_KEY") == "" {
t.Skip("GENUI_MINIMAX_API_KEY not set; real-model replay runs locally with recorded receipts")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "replay.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
svc := surfacesvc.New(st, nil)
if _, err := svc.SetActivation(ctx, "alice", PluginID, true); err != nil {
t.Fatal(err)
}
h := &realHost{svc: svc, artifacts: map[string]bool{}}
// 回合 1:任务→规格→保存
sys1 := "你是本产品中的助手。用户需要一页比较视图时,用 generative-ui.present 提交页面。"
try1 := func(p map[string]any) (any, error) {
return present(ctx, h, extension.Request{ID: "t1", Params: p, Owner: "alice", ConversationID: "c1"})
}
_, res1, r1 := callModelWithRepair(t, ctx, sys1, materials+"\n\n目标:给决策会准备风险分析页(按严重度组织,标注来源)。", try1)
m1, _ := res1.(map[string]any)
if m1["ok"] != true {
t.Fatalf("turn1 not accepted: %+v", res1)
}
snap1, err := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
if err != nil {
t.Fatal(err)
}
t.Logf("turn1: surface=%s revision=%d nodes=%d tokens=%d/%d",
m1["surfaceId"], snap1.Revision, countNodes(snap1), r1.Usage.PromptTokens, r1.Usage.CompletionTokens)
// 回合 2:语义投影回灌 + 修订指令 → 新修订
msgs2, _ := ctxasm.AssembleInput(core.AgentProfile{}, nil, nil,
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改为实施顺序"}}},
nil, nil, nil, nil, nil, nil, ctxasm.AssembleOptions{Surfaces: []core.SurfaceSnapshot{snap1}})
var sysParts []string
for _, m := range msgs2 {
if string(m.Role) == "system" {
sysParts = append(sysParts, m.Content)
}
}
sys2 := strings.Join(sysParts, "\n")
try2 := func(p map[string]any) (any, error) {
p["surfaceId"] = m1["surfaceId"]
p["baseRevision"] = float64(snap1.Revision)
return present(ctx, h, extension.Request{ID: "t2", Params: p, Owner: "alice", ConversationID: "c1"})
}
_, res2, r2 := callModelWithRepair(t, ctx, sys1+"\n"+sys2, materials+"\n\n目标:改为实施顺序(阶段与依赖),修订已有页面。", try2)
m2, _ := res2.(map[string]any)
if fmt.Sprint(m2["revision"]) != fmt.Sprint(snap1.Revision+1) {
t.Fatalf("turn2 should revise to %d, got %+v", snap1.Revision+1, res2)
}
snap2, _ := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
types2 := dominantTypes(snap2)
same := len(types2) == len(snap1.Spec.Nodes)
if same {
for i := range types2 {
if types2[i] != string(snap1.Spec.Nodes[i].Type) {
same = false
break
}
}
}
if len(snap2.Spec.Nodes) == 0 || (snap2.Spec.Title == snap1.Spec.Title && same) {
t.Fatalf("turn2 structure did not change: %v", types2)
}
t.Logf("turn2: revision=%d title=%q types=%v tokens=%d/%d",
snap2.Revision, snap2.Spec.Title, types2, r2.Usage.PromptTokens, r2.Usage.CompletionTokens)
totalIn := r1.Usage.PromptTokens + r2.Usage.PromptTokens
totalOut := r1.Usage.CompletionTokens + r2.Usage.CompletionTokens
cost := float64(totalIn)/1e6*0.3*7.2 + float64(totalOut)/1e6*1.2*7.2
t.Logf("cost: in=%d out=%d ≈¥%.3f (M3 $0.3/$1.2 per M, 7.2)", totalIn, totalOut, cost)
}
type realHost struct {
extension.Host
svc *surfacesvc.Service
artifacts map[string]bool
}
func (h *realHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
row, err := h.svc.Activation(ctx, "alice", PluginID)
return row.Enabled, row.Epoch, err
}
func (h *realHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
return h.svc.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 *realHost) SurfaceGet(ctx context.Context, id core.ID) (core.SurfaceSnapshot, error) {
return h.svc.Get(ctx, "alice", id)
}
func (h *realHost) SurfaceList(ctx context.Context, cid core.ID) ([]core.SurfaceMeta, error) {
return h.svc.List(ctx, "alice", cid)
}
func (h *realHost) ArtifactGet(id string) (core.Artifact, error) {
if h.artifacts[id] {
return core.Artifact{ID: core.ID(id)}, nil
}
return core.Artifact{}, store.ErrNotFound
}
func countNodes(s core.SurfaceSnapshot) int {
var n int
var walk func(u core.UINode)
walk = func(u core.UINode) {
n++
for _, c := range u.Children {
walk(c)
}
}
for _, x := range s.Spec.Nodes {
walk(x)
}
return n
}
func dominantTypes(s core.SurfaceSnapshot) []string {
var out []string
for _, n := range s.Spec.Nodes {
out = append(out, string(n.Type))
}
return out
}
+2
View File
@@ -22,6 +22,8 @@ type fakeBusSource struct {
func (f *fakeBusSource) CommandTools() []CommandTool { return f.cmds }
func (f *fakeBusSource) CommandToolsFor(owner string) []CommandTool { return f.cmds }
func (f *fakeBusSource) DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse {
f.lastReq = req
return f.resp
+26 -2
View File
@@ -203,6 +203,8 @@ func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
type CommandSource interface {
CommandTools() []CommandTool
// CommandToolsFor 按 owner 过滤工具投影(U3:禁用即不注入能力)。
CommandToolsFor(owner string) []CommandTool
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
}
@@ -258,6 +260,28 @@ type runCtx struct {
permissionMode string
}
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌;
// 最近 5 页、新→旧;读路径不受启禁影响——禁用挡写入,不丢历史)。
func (c *runCtx) Surfaces() []core.SurfaceSnapshot {
if c.mgr.st == nil || c.run.ConversationID == "" || c.run.Owner == "" {
return nil
}
metas, err := c.mgr.st.ListSurfaces(string(c.run.Owner), string(c.run.ConversationID))
if err != nil || len(metas) == 0 {
return nil
}
if len(metas) > 5 {
metas = metas[:5]
}
out := make([]core.SurfaceSnapshot, 0, len(metas))
for _, m := range metas {
if snap, err := c.mgr.st.GetSurfaceSnapshot(string(c.run.Owner), string(m.SurfaceID), 0); err == nil {
out = append(out, snap)
}
}
return out
}
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
func (c *runCtx) Workflow() (workflow.Launch, bool) {
if c.launch == nil {
@@ -437,7 +461,7 @@ func (c *runCtx) Tools() []core.ToolDesc {
for _, t := range list {
seen[t.Name] = true
}
for _, ct := range c.mgr.commands.CommandTools() {
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
if seen[ct.ID] {
continue
}
@@ -874,7 +898,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
if c.mgr.commands != nil {
for _, ct := range c.mgr.commands.CommandTools() {
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
if ct.ID != call.Name {
continue
}
+1
View File
@@ -270,6 +270,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
// 组装侧不再注入(防双份;注入文案同一形态)。
_ = ctxasm.InjectMemory
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
Surfaces: rc.Surfaces(),
ThinkReplay: thinkReplayEnabled(eff, route.Model),
})
tail := ctxasm.Tail{
+2
View File
@@ -1117,3 +1117,5 @@ func transcriptOf(msgs []agentllm.Message) string {
}
return b.String()
}
func (f *fakeRunCtx) Surfaces() []core.SurfaceSnapshot { return nil }
+2
View File
@@ -50,6 +50,8 @@ type RunCtx interface {
Card(id string) *core.ActivityCard
// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;无则 false)。
Artifact(id string) (core.Artifact, []byte, bool)
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌)。
Surfaces() []core.SurfaceSnapshot
// SetContextObservation 记录本次运行的上下文组装观测(随 run.completed 下发)。
SetContextObservation(o core.ContextObservation)
}
+2
View File
@@ -461,3 +461,5 @@ func containsStr(list []string, want string) bool {
}
return false
}
func (*entryFake) Surfaces() []core.SurfaceSnapshot { return nil }
@@ -291,3 +291,5 @@ func TestJudgeStreamUsesConfiguredModel(t *testing.T) {
t.Fatal("未配置模型应为 nil(验证器不可用 fail-closed)")
}
}
func (*wfFake) Surfaces() []core.SurfaceSnapshot { return nil }