Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_skill_search_test.go
Wyatt_K 4e227ab678 fix(cloud-agent): skill_search 两处实机缺陷——skillId 容错与 snippet 多字节越界
两个缺陷都是实机验证时撞出来的,不是理论风险。

1) 参数容错:Agent 首次调用时按 skill_read_file 的参数习惯顺手带上 skillId
   ({"keyword": "...", "skillId": "<已启用技能ID>"}),而参数解码走
   decodeCloudAgentJSONObject(DisallowUnknownFields),直接拒绝 → tool_failed。
   结果是工具在清单里可见、却一次都没调用成功。
   - args struct 增加 SkillID(json:"skillId,omitempty"),仅接收不生效——
     检索范围恒为本轮已启用技能;
   - 工具描述补「不要传 skillId——本工具搜索全部已启用技能」,从源头减少误传;
   - 回归测试走完整的工具分发路径(cloudAgentReadTool),真正穿透解码层,
     并断言该字段不会缩小检索范围。

2) snippet 越界 panic(更严重):cloudAgentSkillSnippet 用 strings.Index 拿
   **字节偏移**,却拿它去切 **[]rune**。中文描述里关键词越靠后、字节偏移越远
   超 rune 数,start 没有夹紧到 len(runes),触发
   panic: runtime error: slice bounds out of range [249:233],
   **整个后端进程被打挂**(实测复现两次)。
   - 改为 rune 级子串查找(cloudAgentSkillRuneIndex),不再混用字节与 rune 下标;
   - start/end 双向夹紧,并补 start>end 兜底;
   - 回归测试用 276 rune、命中词字节偏移 483 的长中文描述,
     在修复前稳定 panic([443:276]),修复后通过。

3) 工具描述补充检索引导(提示文本,非行为改动):
   - skill_search:明写「当本轮启用了多个技能、而你不确定该读哪一个时,先用本工具检索」;
   - skill_read_file:补「若本轮启用了多个技能且不确定该读哪一个,先用 skill_search 检索再读」——
     放在这里是因为模型一定会用到 read_file,提示的可达性高于只写在 search 自己的描述里。

   实测:同一 8 技能满槽任务跑 3 次(原始描述 / 只改 search 描述 / 再改 read_file 描述),
   step-5 **三次都没自发调用** skill_search,skill_read_file 次数 15 / 8 / 9(噪声内)。
   即:**仅靠工具描述不足以驱动该模型自发检索**,需要系统提示层路由或遥测来验证采纳,
   本 PR 不声称改善了采纳率。

门禁:go build ✓  go vet ✓  go test 8/8 ✓
2026-09-22 03:35:24 +08:00

178 lines
7.0 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 app
import (
"encoding/json"
"strings"
"testing"
)
// skill_search 的契约测试:注册、分词打分、索引兜底、边界与空集。
// 沿用 #577 的同文件断言风格(含 sentinel 泄漏检查)。
func skillSearchTestSkills() []cloudAgentSkill {
return []cloudAgentSkill{
{ID: "s1", Name: "consumer-psychology", Description: "当写带货口播、广告脚本、详情页与直播话术,需要设计转化结构时调用。核心能力:价格锚点/比价阻断/社会证据。"},
{ID: "s2", Name: "shortform-drama-playbook", Description: "当写 3 分钟以内反转短剧的分镜脚本时调用。核心能力:钩子/因果/反转收束。"},
{ID: "s3", Name: "h3-video-prompt-suite", Description: "当为 H3 视频模型写生成提示词时调用。核心能力:六段式/运镜三段式/负向提示词。"},
}
}
func TestCloudAgentPolicyRegistersSkillSearchAlongsideReadFile(t *testing.T) {
req := CloudAgentRequest{SkillIDs: []string{"s1"}}
names := map[string]bool{}
for _, tool := range cloudAgentTools(req) {
fn, ok := tool["function"].(map[string]any)
if !ok {
t.Fatalf("tool without function: %v", tool)
}
names[fn["name"].(string)] = true
}
if !names["skill_read_file"] || !names["skill_search"] {
t.Fatalf("skill tools not registered together: %v", names)
}
// 无技能时两个工具都不该出现
for _, tool := range cloudAgentTools(CloudAgentRequest{}) {
fn := tool["function"].(map[string]any)
if fn["name"] == "skill_search" || fn["name"] == "skill_read_file" {
t.Fatalf("skill tool leaked without skills: %v", fn["name"])
}
}
}
func TestCloudAgentSkillSearchRanksNameHitsAboveDescriptionHits(t *testing.T) {
result, err := cloudAgentSearchSkills(skillSearchTestSkills(), "口播 转化", 0)
if err != nil {
t.Fatal(err)
}
matches := result["matches"].([]map[string]any)
if len(matches) == 0 {
t.Fatal("no matches for 口播/转化")
}
if matches[0]["skillId"] != "s1" {
t.Fatalf("expected consumer-psychology first, got %v", matches[0]["skillId"])
}
if matches[0]["path"] != cloudAgentSkillEntryPath {
t.Fatalf("expected entry path, got %v", matches[0]["path"])
}
if matches[0]["score"].(int) <= 0 {
t.Fatalf("expected positive score, got %v", matches[0]["score"])
}
if snippet, _ := matches[0]["snippet"].(string); strings.Contains(snippet, "PRIVATE") || len(snippet) > 200 {
t.Fatalf("snippet unsafe or oversized: %q", snippet)
}
}
func TestCloudAgentSkillSearchWithoutKeywordListsIndex(t *testing.T) {
result, err := cloudAgentSearchSkills(skillSearchTestSkills(), " ", 0)
if err != nil {
t.Fatal(err)
}
matches := result["matches"].([]map[string]any)
if len(matches) != 3 {
t.Fatalf("expected full index of 3, got %d", len(matches))
}
if result["total"].(int) != 3 {
t.Fatalf("expected total 3, got %v", result["total"])
}
}
func TestCloudAgentSkillSearchClampsLimitAndHandlesNoHit(t *testing.T) {
skills := skillSearchTestSkills()
result, err := cloudAgentSearchSkills(skills, "钩子", 99)
if err != nil {
t.Fatal(err)
}
if len(result["matches"].([]map[string]any)) != 1 {
t.Fatalf("expected single hit for 钩子, got %v", result["matches"])
}
result, err = cloudAgentSearchSkills(skills, "量子纠缠炒菜", 0)
if err != nil {
t.Fatal(err)
}
if len(result["matches"].([]map[string]any)) != 0 {
t.Fatalf("expected no matches, got %v", result["matches"])
}
if guidance, _ := result["guidance"].(string); !strings.Contains(guidance, "skill_read_file") {
t.Fatalf("no-hit guidance must point at skill_read_file: %q", guidance)
}
}
func TestCloudAgentSkillSearchEmptySkillsReturnsGuidance(t *testing.T) {
result, err := cloudAgentSearchSkills(nil, "口播", 0)
if err != nil {
t.Fatal(err)
}
if len(result["matches"].([]map[string]any)) != 0 {
t.Fatal("expected empty matches without skills")
}
if guidance, _ := result["guidance"].(string); !strings.Contains(guidance, "没有已启用") {
t.Fatalf("guidance mismatch: %q", guidance)
}
}
func TestCloudAgentSkillSearchToleratesStraySkillIDArgument(t *testing.T) {
// 实测回归:step-5 首次调用时按 skill_read_file 的参数习惯顺手带上 skillId,
// 被 decodeCloudAgentJSONObject(DisallowUnknownFields)拒绝 → tool_failed,
// 检索一次都没成功。这里走完整的工具分发路径,确保解码层真的容忍该字段。
state := &cloudAgentRuntime{Skills: skillSearchTestSkills()}
call := cloudAgentCall{ID: "c1"}
call.Function.Name = "skill_search"
call.Function.Arguments = `{"keyword":"反转 钩子 结构 便利店","skillId":"s1"}`
result, err := cloudAgentReadTool(nil, "u1", state, call)
if err != nil {
t.Fatalf("stray skillId must be tolerated: %v", err)
}
payload, ok := result.(map[string]any)
if !ok {
t.Fatalf("unexpected result type %T", result)
}
matches := payload["matches"].([]map[string]any)
if len(matches) == 0 {
t.Fatal("stray skillId argument must not break the search")
}
// 该字段仅接收不生效:检索范围恒为本轮全部已启用技能,
// 传了 s1 也必须能命中 name/description 在 s2 上的结果。
if matches[0]["skillId"] != "s2" {
t.Fatalf("skillId argument must not narrow the search scope, got %v", matches[0]["skillId"])
}
}
func TestCloudAgentSkillSearchSnippetHandlesMultibyteHitOffset(t *testing.T) {
// 回归:曾把 strings.Index 的字节偏移当成 []rune 的下标使用,中文描述里
// 关键词越靠后、字节偏移越远超 rune 数,start 未夹紧 →
// panic: slice bounds out of range [249:233],整个后端进程被打挂。
// 触发条件:命中词的「字节偏移 - 40」必须大于描述的 rune 总数。
// 本例 276 rune / 命中词字节偏移 483 → 旧逻辑 start=443 > end=276,直接 panic。
long := strings.Repeat("本包由出版书方法论蒸馏重铸,覆盖短剧结构与节奏判断,", 6) +
"核心能力:分镜、钩子与反转收束。" +
strings.Repeat("工位边界:本包只负责创作方法论层,不处理提示词语法。", 4)
skills := []cloudAgentSkill{{ID: "s1", Name: "demo-skill", Description: long}}
result, err := cloudAgentSearchSkills(skills, "分镜", 0)
if err != nil {
t.Fatalf("multibyte description must not break the search: %v", err)
}
matches := result["matches"].([]map[string]any)
if len(matches) != 1 {
t.Fatalf("expected one hit, got %v", matches)
}
snippet, _ := matches[0]["snippet"].(string)
if snippet == "" {
t.Fatal("snippet must not be empty")
}
if !strings.Contains(snippet, "分镜") {
t.Fatalf("snippet should be centred on the hit, got %q", snippet)
}
}
func TestCloudAgentSkillSearchNeverInlinesSkillBody(t *testing.T) {
skills := []cloudAgentSkill{{ID: "s1", Name: "demo", Description: "PRIVATE_SKILL_BODY_SENTINEL 只在正文,不在描述", Instruction: "PRIVATE_INSTRUCTION_SENTINEL"}}
result, err := cloudAgentSearchSkills(skills, "demo", 0)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(result)
if strings.Contains(string(encoded), "PRIVATE_INSTRUCTION_SENTINEL") {
t.Fatal("skill_search leaked instruction body")
}
}