From 30bc8f76754e93f1ebdefb716f708082a70fc6e7 Mon Sep 17 00:00:00 2001 From: AetherNo2332 <130164460+AetherNo2332@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:49:24 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(cloud-agent):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=88=86=E6=B4=BE=20-=20=E6=96=B0=E5=A2=9E=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=9C=9F=E5=88=86=E6=B4=BE?= =?UTF-8?q?=E7=9A=84=E4=B8=80=E8=87=B4=E6=80=A7=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工具生效需要同时改三处:compileCloudAgentTools 的 add(...) 注册、advanceCloudAgentTool / cloudAgentReadTool 的运行期分派、以及 CloudAgentSupportedToolNames() 暴露的平台支持集合。 只改一两处时工具对模型不可见或调用落到 default 分支,而既有测试(含 schema 预算用例与能力 注册表契约用例)依然全绿。 本用例用 go/ast 读源码抽取三条集合后做集合断言:平台支持 ⊆ 分派;注册 == 分派(双向); cloudAgentWrite 的写操作 ⊆ 注册 ⊆ 分派。解析不到 add(...) 或分派函数被改名/搬走时立即 t.Fatal,守卫自身不会静默失效。纯测试文件,零生产代码改动。 --- .../app/cloud_agent_tool_dispatch_test.go | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 backend/internal/app/cloud_agent_tool_dispatch_test.go diff --git a/backend/internal/app/cloud_agent_tool_dispatch_test.go b/backend/internal/app/cloud_agent_tool_dispatch_test.go new file mode 100644 index 000000000..e84cd2094 --- /dev/null +++ b/backend/internal/app/cloud_agent_tool_dispatch_test.go @@ -0,0 +1,243 @@ +package app + +import ( + "go/ast" + "go/parser" + "go/token" + "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 + }{ + {"cloud_agent_runtime.go", "advanceCloudAgentTool"}, + {"cloud_agent_tools.go", "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) + } +} + +func parseCloudAgentFile(t *testing.T, name string) *ast.File { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), name, nil, 0) + if err != nil { + t.Fatalf("解析 %s 失败:%v", name, err) + } + return file +} + +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 +} From 748c20cf3edc607261549fa5f10089774f2a1416 Mon Sep 17 00:00:00 2001 From: AetherNo2332 <130164460+AetherNo2332@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:50:02 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(web):=20=E7=94=BB=E5=B8=83=20Agent=20?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20-=20=E4=BF=AE=E6=AD=A3=20Skill=20=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E6=8F=90=E7=A4=BA=E8=AF=AD=E7=9A=84=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 输入框提示语已随外观配置搬进 lib/canvas/agent-appearance.ts,面板只消费 appearance.inputPlaceholder(canvas-cloud-agent-panel.tsx 的 agentCopy(...)),而这条用例仍在 面板源码里找那句字面文案,于是 Web checks 的 Run tests 一直红:干净 upstream/main 检出上可复现 1994 pass / 1 fail,同期其它作者的 PR 也是同一条失败。 改为断言"文案在 agent-appearance 里 + 面板仍在使用它",与重构后的架构一致。 --- web/test/canvas-resource-mention-editor.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/test/canvas-resource-mention-editor.test.ts b/web/test/canvas-resource-mention-editor.test.ts index fdfbb672f..bbf129013 100644 --- a/web/test/canvas-resource-mention-editor.test.ts +++ b/web/test/canvas-resource-mention-editor.test.ts @@ -122,7 +122,10 @@ describe("canvas resource mention editor", () => { expect(component).toContain("slash.start + 1 + slash.query.length"); expect(component).toContain("buildSkillMentionReferences(availableSlashSkills)"); expect(component).toContain("[/、]([^\\s/、]*)$"); - expect(source("../src/components/canvas/canvas-cloud-agent-panel.tsx")).toContain("用 / 或 、 引用 Skills"); + // 输入框提示语已随外观配置搬进 agent-appearance(面板只消费 appearance.inputPlaceholder), + // 这里断言"文案在、面板仍用它",而不是把文案钉在面板源码里。 + expect(source("../src/lib/canvas/agent-appearance.ts")).toContain("用 / 或 、 引用 Skills"); + expect(source("../src/components/canvas/canvas-cloud-agent-panel.tsx")).toContain("agentCopy(appearance.inputPlaceholder, appearance.agentName)"); }); test("skill chips use one colored icon instead of exposing the serialized token", () => {