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
252 lines
9.2 KiB
Go
252 lines
9.2 KiB
Go
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), ¶ms); 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
|
||
}
|