Files
Agent-System-Backend/internal/genui/replay_test.go
T
weichen 367dc207b3
ci / vet-and-test (pull_request) Successful in 42s
Add the generative-ui plugin, capability gating, and surface projection
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
2026-10-11 10:38:15 +00:00

252 lines
9.2 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 完成条件回放(真实模型,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
}