package app import ( "go/ast" "go/parser" "go/token" "io/fs" "sort" "strings" "testing" ) // 本文件是一致性守卫:工具表(模型看到的契约)与运行期分派必须始终一致。 // // 一个工具要真正可用必须同时出现在三处:compileCloudAgentTools 里的 add("name", …) 注册、 // advanceCloudAgentTool / cloudAgentReadTool 的运行期分派、以及 CloudAgentSupportedToolNames() // 暴露的平台支持集合。只改其中一两处时,工具对模型不可见、或调用直接落到 default 分支, // 而既有测试(包括工具 schema 预算用例与能力注册表契约用例)依然全绿——这类"半注册"退化 // 必须在 CI 立刻变红,而不是等用户发现工具不生效。 // // 实现方式与取舍:advanceCloudAgentTool 的分派是写在事务闭包里的 switch,没有可注入的 // 分派表,纯行为断言覆盖不到"某个工具名有没有分支"(会被运行期提前 return 掩盖); // 因此这里用 go/ast 读源码抽取集合再做集合断言——检查的正是"分派有没有写"。 func TestCloudAgentToolTableMatchesRuntimeDispatch(t *testing.T) { registered := cloudAgentRegisteredToolNames(t) supported := CloudAgentSupportedToolNames() dispatched := cloudAgentDispatchedToolNames(t) // 1. 平台支持集合里每个名字都能被执行分派处理(读/写两条分派路径都算)。 missing := difference(supported, dispatched) if len(missing) > 0 { t.Fatalf("平台声明支持但运行期没有分派分支的工具:%s", strings.Join(missing, ", ")) } // 2. 工具表里所有 add(...) 注册的名字都有分派分支(集合相等,而不是子集)。 if extra := difference(dispatched, registered); len(extra) > 0 { t.Fatalf("运行期有分派分支但没有注册进工具表的工具:%s", strings.Join(extra, ", ")) } if missing := difference(registered, dispatched); len(missing) > 0 { t.Fatalf("工具表注册了但没有分派分支的工具:%s", strings.Join(missing, ", ")) } // 工具表全集必须与 CloudAgentSupportedToolNames() 逐一相同(后者从工具表派生, // 这条断言守的是"派生用的请求确实覆盖了所有条件暴露的分支")。 if missing := difference(registered, supported); len(missing) > 0 { t.Fatalf("CloudAgentSupportedToolNames() 漏掉了工具表里的:%s", strings.Join(missing, ", ")) } if missing := difference(supported, registered); len(missing) > 0 { t.Fatalf("CloudAgentSupportedToolNames() 多出了工具表里没有的:%s", strings.Join(missing, ", ")) } // 3. cloudAgentWrite 判定为"写操作"的名字必须已注册且有分派分支。 // 上游语义下这张名单同时决定审批链(approval 分支按 cloudAgentWrite(...) 判定), // 漏一个就等于该工具绕过用户审批,因此单独守一条。 writes := cloudAgentWriteToolNames(t) if extra := difference(writes, registered); len(extra) > 0 { t.Fatalf("被判为写操作但不在工具表里的工具:%s", strings.Join(extra, ", ")) } if missing := difference(writes, dispatched); len(missing) > 0 { t.Fatalf("被判为写操作但没有分派分支的工具:%s", strings.Join(missing, ", ")) } } // cloudAgentRegisteredToolNames 收集 compileCloudAgentTools 里所有 add("name", …) 的名字全集。 func cloudAgentRegisteredToolNames(t *testing.T) []string { t.Helper() file := parseCloudAgentFile(t, "cloud_agent_tools.go") names := map[string]bool{} ast.Inspect(file, func(node ast.Node) bool { call, ok := node.(*ast.CallExpr) if !ok { return true } ident, ok := call.Fun.(*ast.Ident) if !ok || ident.Name != "add" || len(call.Args) == 0 { return true } if literal, ok := call.Args[0].(*ast.BasicLit); ok && literal.Kind == token.STRING { names[strings.Trim(literal.Value, `"`)] = true } return true }) if len(names) == 0 { t.Fatal("没有从 cloud_agent_tools.go 解析到任何 add(...) 工具注册") } return sortedKeys(names) } // cloudAgentWriteToolNames 收集 cloudAgentWrite 里的工具名字面量。 // 该函数是 name == "..." 的布尔表达式(不是 switch),因此直接收集函数体里的字符串字面量。 func cloudAgentWriteToolNames(t *testing.T) []string { t.Helper() file := parseCloudAgentFile(t, "cloud_agent_tools.go") names := map[string]bool{} ast.Inspect(file, func(node ast.Node) bool { decl, ok := node.(*ast.FuncDecl) if !ok || decl.Name.Name != "cloudAgentWrite" || decl.Body == nil { return true } collectStringLiterals(decl.Body, names) return false }) if len(names) == 0 { t.Fatal("没有解析到 cloudAgentWrite 的工具名") } return sortedKeys(names) } // cloudAgentDispatchedToolNames 收集运行期两条分派路径覆盖的工具名: // advanceCloudAgentTool(含审批预演、主执行 switch 与媒体/看图等特例分支)与 // cloudAgentReadTool(默认读取分派)。 func cloudAgentDispatchedToolNames(t *testing.T) []string { t.Helper() names := map[string]bool{} for _, target := range []struct { file string fn string }{ {"app 包", "advanceCloudAgentTool"}, {"app 包", "cloudAgentReadTool"}, } { file := parseCloudAgentFile(t, target.file) found := false ast.Inspect(file, func(node ast.Node) bool { decl, ok := node.(*ast.FuncDecl) if !ok || decl.Name.Name != target.fn || decl.Body == nil { return true } found = true collectToolNameBranches(decl.Body, names) return false }) if !found { t.Fatalf("%s 里找不到 %s:分派实现被重命名或搬走了,守卫需要同步更新", target.file, target.fn) } } if len(names) == 0 { t.Fatal("没有解析到任何分派分支的工具名") } return sortedKeys(names) } // collectToolNameBranches 收集两处分派写法涉及的工具名字面量: // - `call.Function.Name == "x"`(含嵌套在 && / || 里的) // - `switch call.Function.Name { case "x", "y": … }` func collectToolNameBranches(body *ast.BlockStmt, out map[string]bool) { ast.Inspect(body, func(node ast.Node) bool { switch typed := node.(type) { case *ast.BinaryExpr: if typed.Op != token.EQL { return true } if literal, ok := comparedToolName(typed); ok { out[literal] = true } case *ast.SwitchStmt: if !isFunctionNameSelector(typed.Tag) { return true } for _, item := range typed.Body.List { clause, ok := item.(*ast.CaseClause) if !ok { continue } collectStringLiteralsOf(clause.List, out) } } return true }) } // comparedToolName 判断 `X == "name"` 形式里 X 是不是 `…Function.Name`,返回字面量。 func comparedToolName(binary *ast.BinaryExpr) (string, bool) { for _, pair := range [][2]ast.Expr{{binary.X, binary.Y}, {binary.Y, binary.X}} { if !isFunctionNameSelector(pair[0]) { continue } if literal, ok := pair[1].(*ast.BasicLit); ok && literal.Kind == token.STRING { return strings.Trim(literal.Value, `"`), true } } return "", false } // isFunctionNameSelector 判断表达式是否是 `X.Function.Name`(工具调用名的统一读法)。 func isFunctionNameSelector(node ast.Node) bool { outer, ok := node.(*ast.SelectorExpr) if !ok || outer.Sel.Name != "Name" { return false } inner, ok := outer.X.(*ast.SelectorExpr) if !ok || inner.Sel.Name != "Function" { return false } _, ok = inner.X.(*ast.Ident) return ok } func collectStringLiterals(node ast.Node, out map[string]bool) { ast.Inspect(node, func(item ast.Node) bool { literal, ok := item.(*ast.BasicLit) if ok && literal.Kind == token.STRING { out[strings.Trim(literal.Value, `"`)] = true } return true }) } func collectStringLiteralsOf(nodes []ast.Expr, out map[string]bool) { for _, node := range nodes { collectStringLiterals(node, out) } } // parseCloudAgentFile 解析 app 包内全部非测试源码并合成一个 *ast.File。 // // 守卫按函数名定位分派实现,而不是写死文件名:大文件按职责拆分后 // (例如 advanceCloudAgentTool 移到 cloud_agent_runtime_tools.go),守卫依然有效。 // name 仅用于错误信息。 func parseCloudAgentFile(t *testing.T, name string) *ast.File { t.Helper() fset := token.NewFileSet() packages, err := parser.ParseDir(fset, ".", func(info fs.FileInfo) bool { return !strings.HasSuffix(info.Name(), "_test.go") }, 0) if err != nil { t.Fatalf("解析 app 包(%s)失败:%v", name, err) } merged := &ast.File{Name: ast.NewIdent("app")} for _, pkg := range packages { for _, file := range pkg.Files { merged.Decls = append(merged.Decls, file.Decls...) } } return merged } func sortedKeys(values map[string]bool) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys } func difference(from, without []string) []string { index := make(map[string]bool, len(without)) for _, item := range without { index[item] = true } diff := make([]string, 0) for _, item := range from { if !index[item] { diff = append(diff, item) } } return diff }