package loop import ( "context" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // faceSkill 是技能面的测试技能:一个实现了 sdk.SkillSpec 的最小定义,字段齐到 // 本片用到的全部(三态 / 触发词 / paths / 工具上限 / 步骤 / 约束)。 type faceSkill struct { name string desc string prompt string whenToUse string invocation sdk.Invocation triggers []string paths []string allowed []string disallowed []string steps []sdk.Step guardrails []sdk.Guardrail contextMode sdk.ContextMode } func (s *faceSkill) Name() string { return s.name } func (s *faceSkill) Description() string { return s.desc } func (s *faceSkill) Prompt() string { return s.prompt } func (s *faceSkill) Steps() []sdk.Step { return s.steps } func (s *faceSkill) Refs() []sdk.Ref { return nil } func (s *faceSkill) Guardrails() []sdk.Guardrail { return s.guardrails } func (s *faceSkill) Version() int { return 1 } func (s *faceSkill) WhenToUse() string { return s.whenToUse } func (s *faceSkill) Invocation() sdk.Invocation { return s.invocation.OrDefault() } func (s *faceSkill) Triggers() []string { return s.triggers } func (s *faceSkill) Paths() []string { return s.paths } func (s *faceSkill) AllowedTools() []string { return s.allowed } func (s *faceSkill) DisallowedTools() []string { return s.disallowed } func (s *faceSkill) ContextMode() sdk.ContextMode { return s.contextMode.OrDefault() } func (s *faceSkill) Agent() string { return "" } func (s *faceSkill) Model() string { return "" } func (s *faceSkill) Effort() string { return "" } func (s *faceSkill) Metadata() map[string]string { return nil } // faceOwnerCtx 返回带会话标识的主人上下文:技能面按会话分桶保存激活状态, // 因此测试必须给出会话标识,否则只投影清单、不激活(正是设计的口径)。 func faceOwnerCtx(session string) context.Context { return withLoopSession(WithSessionEnv(context.Background(), OwnerEnv()), session) } // faceFor 构造一个以 list 为唯一来源的技能面。 func faceFor(list ...sdk.Skill) *SkillFace { return NewSkillFace(SkillFaceOptions{ Source: func() []sdk.Skill { return list }, Log: testLogger(), }) } // systemTexts 收集消息列表里全部 system 消息的文本,供断言注入块内容。 func faceSystemTexts(messages []model.Message) []string { out := make([]string, 0, len(messages)) for _, m := range messages { if m.Role == model.RoleSystem { out = append(out, messageText(m)) } } return out } // joinedSystem 把全部 system 文本拼起来,供「包含/不包含」断言。 func joinedSystem(messages []model.Message) string { return strings.Join(faceSystemTexts(messages), "\n---\n") } // countContains 数 system 消息里含 needle 的条数,用来验证「一个技能一条消息」。 func countContains(messages []model.Message, needle string) int { n := 0 for _, text := range faceSystemTexts(messages) { if strings.Contains(text, needle) { n++ } } return n } // TestSkillFace_清单只有名字与说明 是本片的核心不变式(design/09 §6.3-1): // 清单给模型看得见的只有 name + description(+when-to-use),**正文不在其中**。 func TestSkillFace_清单只有名字与说明(t *testing.T) { t.Parallel() sk := &faceSkill{name: "daily", desc: "晨间简报", prompt: "正文-机密提示词", whenToUse: "整理昨日事项"} face := faceFor(sk) got := face.Assemble(faceOwnerCtx("s1"), []model.Message{model.TextMessage(model.RoleUser, "在吗")}, 1) joined := joinedSystem(got) if !strings.Contains(joined, "daily") || !strings.Contains(joined, "晨间简报") { t.Fatalf("清单缺少 name/description:%q", joined) } if !strings.Contains(joined, "整理昨日事项") { t.Fatalf("清单缺少 when-to-use:%q", joined) } if strings.Contains(joined, "正文-机密提示词") { t.Fatalf("未命中的技能正文不该进上下文:%q", joined) } } // TestSkillFace_三态逐格 断言 invocation 三态与清单逐格对齐(design/09 §6.3-4): // model-only ⇒ 清单**不含** description;user-only ⇒ 清单有 description 但模型不可调。 func TestSkillFace_三态逐格(t *testing.T) { t.Parallel() both := &faceSkill{name: "both", desc: "两边都能调"} modelOnly := &faceSkill{name: "modelOnly", desc: "不该进清单的说明", invocation: sdk.InvocationModelOnly} userOnly := &faceSkill{name: "userOnly", desc: "只给人调", invocation: sdk.InvocationUserOnly} face := faceFor(both, modelOnly, userOnly) joined := joinedSystem(face.Assemble(faceOwnerCtx("s1"), nil, 1)) for _, want := range []string{"both", "modelOnly", "userOnly", "两边都能调", "只给人调"} { if !strings.Contains(joined, want) { t.Fatalf("清单缺少 %q:%q", want, joined) } } if strings.Contains(joined, "不该进清单的说明") { t.Fatalf("model-only 的清单不该带 description:%q", joined) } } // TestSkillFace_命中才展开且一条消息 断言触发词命中时正文作为**一条**消息注入 // (design/09 §6.3-3),未命中时完全不出现。 func TestSkillFace_命中才展开且一条消息(t *testing.T) { t.Parallel() sk := &faceSkill{name: "brief", desc: "简报", prompt: "按以下步骤做简报", triggers: []string{"简报"}} face := faceFor(sk) ctx := faceOwnerCtx("s1") before := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "今天天气不错")}, 1) if strings.Contains(joinedSystem(before), "按以下步骤做简报") { t.Fatalf("未命中就展开了正文:%q", joinedSystem(before)) } after := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "帮我做个简报")}, 2) if got := countContains(after, "技能正文:brief"); got != 1 { t.Fatalf("正文消息条数 = %d, want 1(一个技能一条消息)", got) } if !strings.Contains(joinedSystem(after), "按以下步骤做简报") { t.Fatalf("命中的技能正文没进上下文:%q", joinedSystem(after)) } } // TestSkillFace_跨回合保留且不重复渲染 是本片的第二条核心不变式:正文激活后 // 跨回合保留,且**不重复渲染/读盘**(Stats().BodyRenders 恒为 1)。 func TestSkillFace_跨回合保留且不重复渲染(t *testing.T) { t.Parallel() sk := &faceSkill{name: "brief", prompt: "步骤正文", triggers: []string{"简报"}} face := faceFor(sk) ctx := faceOwnerCtx("s1") face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "做个简报")}, 1) // 后续回合:触发词不再出现,正文仍必须在(跨回合保留)。 later := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "继续")}, 2) again := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "再继续")}, 3) if !strings.Contains(joinedSystem(later), "步骤正文") || !strings.Contains(joinedSystem(again), "步骤正文") { t.Fatalf("正文没有跨回合保留:%q / %q", joinedSystem(later), joinedSystem(again)) } if got := face.Stats().BodyRenders; got != 1 { t.Fatalf("BodyRenders = %d, want 1(同一技能只得渲染一次 = 不重复读盘)", got) } if got := face.Stats().Activations; got != 1 { t.Fatalf("Activations = %d, want 1(同一技能只激活一次)", got) } } // TestSkillFace_激活状态按会话隔离 断言两个会话不共享激活集合:A 会话命中不会 // 让 B 会话凭空拿到正文。 func TestSkillFace_激活状态按会话隔离(t *testing.T) { t.Parallel() sk := &faceSkill{name: "brief", prompt: "正文标记", triggers: []string{"简报"}} face := faceFor(sk) face.Assemble(faceOwnerCtx("session-a"), []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1) other := face.Assemble(faceOwnerCtx("session-b"), []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1) if !strings.Contains(joinedSystem(other), "正文标记") { t.Fatalf("session-b 自己也命中了,应当展开:%q", joinedSystem(other)) } // session-c 从未命中:不得出现任何正文。 plain := face.Assemble(faceOwnerCtx("session-c"), []model.Message{model.TextMessage(model.RoleUser, "你好")}, 1) if strings.Contains(joinedSystem(plain), "正文标记") { t.Fatalf("session-c 未命中却拿到了正文(跨会话泄漏):%q", joinedSystem(plain)) } } // TestSkillFace_userOnly_不自动激活 断言 user-only 的技能清单照常列出、 // 但模型不可调 ⇒ 不参与自动激活(design/09 §6.3-4)。 func TestSkillFace_userOnly_不自动激活(t *testing.T) { t.Parallel() sk := &faceSkill{ name: "humanOnly", desc: "只给人调", prompt: "不该被自动展开", invocation: sdk.InvocationUserOnly, triggers: []string{"简报"}, } face := faceFor(sk) got := face.Assemble(faceOwnerCtx("s1"), []model.Message{model.TextMessage(model.RoleUser, "做简报")}, 1) joined := joinedSystem(got) if !strings.Contains(joined, "humanOnly") { t.Fatalf("user-only 也应在清单里可见:%q", joined) } if strings.Contains(joined, "不该被自动展开") { t.Fatalf("user-only 不该被自动激活:%q", joined) } } // TestSkillFace_群聊陌生人整块不注入 断言技能面是 owner 能力(design/09 §6.5): // 群聊 / 陌生人会话既不注入清单,也不注入正文。 func TestSkillFace_群聊陌生人整块不注入(t *testing.T) { t.Parallel() sk := &faceSkill{name: "brief", desc: "简报", prompt: "正文标记", triggers: []string{"简报"}} face := faceFor(sk) env := SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false} ctx := withLoopSession(WithSessionEnv(context.Background(), env), "s-group") msgs := []model.Message{model.TextMessage(model.RoleUser, "做个简报")} if got := face.Assemble(ctx, msgs, 1); len(got) != len(msgs) { t.Fatalf("群聊会话不该注入任何技能块:got %d 条消息", len(got)) } } // TestSkillFace_正文超预算截断并标 断言注入有字节上限(design/09 §6.3-5): // 超限截断、结果不超上限、且如实带 .truncated 标记。 func TestSkillFace_正文超预算截断并标(t *testing.T) { t.Parallel() const budget = 256 sk := &faceSkill{name: "huge", prompt: strings.Repeat("很长的一步说明", 200), triggers: []string{"简报"}} face := NewSkillFace(SkillFaceOptions{ Source: func() []sdk.Skill { return []sdk.Skill{sk} }, Log: testLogger(), BodyBytes: budget, }) got := face.Assemble(faceOwnerCtx("s1"), []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1) var body string for _, text := range faceSystemTexts(got) { if strings.HasPrefix(text, "技能正文:huge") { body = text } } if body == "" { t.Fatalf("没有找到正文消息:%q", joinedSystem(got)) } if len(body) > budget { t.Fatalf("正文 %d 字节 > 上限 %d(标记须计入预算)", len(body), budget) } if !strings.Contains(body, SkillBodyTruncatedMarker) { t.Fatalf("截断必须如实标记 %q:%q", SkillBodyTruncatedMarker, body) } if face.Stats().BodyTruncations != 1 { t.Fatalf("BodyTruncations = %d, want 1", face.Stats().BodyTruncations) } } // TestSkillFace_清单条数上限如实标 断言清单超出全局条数上限时如实标注, // 绝不静默丢条目(design/09 §6.3-2)。 func TestSkillFace_清单条数上限如实标(t *testing.T) { t.Parallel() list := make([]sdk.Skill, 0, 80) for i := 0; i < 80; i++ { list = append(list, &faceSkill{name: "s" + twoDigits(i), desc: "说明"}) } face := faceFor(list...) got := face.Assemble(faceOwnerCtx("s1"), nil, 1) joined := joinedSystem(got) if !strings.Contains(joined, SkillBodyTruncatedMarker) { t.Fatalf("清单截断没有如实标注:%q", joined) } if face.Stats().Listed != 64 { t.Fatalf("Listed = %d, want 64(技能包的全局条数上限)", face.Stats().Listed) } } // TestSkillFace_展开上限如实标 断言会话展开上限被触发时如实写明「还有几个未展开」, // 而不是静默把正文丢掉。 func TestSkillFace_展开上限如实标(t *testing.T) { t.Parallel() list := make([]sdk.Skill, 0, SkillActiveLimit+2) for i := 0; i < SkillActiveLimit+2; i++ { list = append(list, &faceSkill{ name: "hit" + twoDigits(i), desc: "说明", prompt: "正文-" + twoDigits(i), triggers: []string{"简报"}, }) } face := faceFor(list...) got := face.Assemble(faceOwnerCtx("s1"), []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1) joined := joinedSystem(got) if !strings.Contains(joined, "未展开") || !strings.Contains(joined, SkillBodyTruncatedMarker) { t.Fatalf("超出展开上限必须如实标注:%q", joined) } bodies := 0 for _, text := range faceSystemTexts(got) { if strings.HasPrefix(text, "技能正文:") { bodies++ } } if bodies != SkillActiveLimit { t.Fatalf("注入的正文条数 = %d, want %d", bodies, SkillActiveLimit) } } // TestSkillFace_paths命中 断言工作文件命中 `paths` 也能激活:显式挂载的路径与 // 用户文本里看得出的路径两条来源都验。 func TestSkillFace_paths命中(t *testing.T) { t.Parallel() sk := &faceSkill{name: "go", prompt: "改写 Go 文件", paths: []string{"**/*.go"}} face := faceFor(sk) // 来源一:ctx 上显式挂载的工作文件。 hinted := WithSkillWorkFiles(faceOwnerCtx("s1"), []string{"internal/runtime/loop/run.go"}) if !strings.Contains(joinedSystem(face.Assemble(hinted, nil, 1)), "改写 Go 文件") { t.Fatalf("显式工作文件没有命中 paths:%q", joinedSystem(face.Assemble(hinted, nil, 1))) } // 来源二:用户文本里看得出路径。 ctx := faceOwnerCtx("s2") msgs := []model.Message{model.TextMessage(model.RoleUser, "帮我看看 internal/runtime/loop/memory.go 这段")} if !strings.Contains(joinedSystem(face.Assemble(ctx, msgs, 1)), "改写 Go 文件") { t.Fatalf("文本里的路径没有命中 paths:%q", joinedSystem(face.Assemble(ctx, msgs, 1))) } // 无关文本:不命中。 plain := face.Assemble(faceOwnerCtx("s3"), []model.Message{model.TextMessage(model.RoleUser, "今天吃什么")}, 1) if strings.Contains(joinedSystem(plain), "改写 Go 文件") { t.Fatalf("无关文本不该命中:%q", joinedSystem(plain)) } } // TestSkillFace_未接来源是空操作 断言关闭态:Source 为 nil、或没有技能、 // 或没有会话标识(只投影清单、不激活)时,消息列表逐字节不变。 func TestSkillFace_未接来源是空操作(t *testing.T) { t.Parallel() msgs := []model.Message{model.TextMessage(model.RoleUser, "你好")} if got := NewSkillFace(SkillFaceOptions{}).Assemble(faceOwnerCtx("s1"), msgs, 1); len(got) != len(msgs) { t.Fatalf("Source 为 nil 时应当零改动") } if got := faceFor().Assemble(faceOwnerCtx("s1"), msgs, 1); len(got) != len(msgs) { t.Fatalf("没有技能时应当零改动") } sk := &faceSkill{name: "brief", prompt: "正文", triggers: []string{"你好"}} noSession := WithSessionEnv(context.Background(), OwnerEnv()) if got := faceFor(sk).Assemble(noSession, msgs, 1); len(got) != len(msgs)+1 { t.Fatalf("无会话标识时应只多一条清单块,got %d", len(got)) } } // TestSkillFace_注入块插在前导块之后 断言位置口径:注入块落在开头那段 system // 消息之后、对话之前,前导块次序不被改动。 func TestSkillFace_注入块插在前导块之后(t *testing.T) { t.Parallel() sk := &faceSkill{name: "brief", desc: "简报", triggers: []string{"简报"}} face := faceFor(sk) msgs := []model.Message{ model.TextMessage(model.RoleSystem, "人格知识块"), model.TextMessage(model.RoleSystem, "记忆块"), model.TextMessage(model.RoleUser, "做个简报"), } got := face.Assemble(faceOwnerCtx("s1"), msgs, 1) if len(got) != len(msgs)+2 { t.Fatalf("消息数 = %d, want %d(清单 + 正文各一条)", len(got), len(msgs)+2) } if messageText(got[0]) != "人格知识块" || messageText(got[1]) != "记忆块" { t.Fatalf("前导块次序被改动:%q / %q", messageText(got[0]), messageText(got[1])) } if !strings.HasPrefix(messageText(got[2]), "技能清单") { t.Fatalf("清单块没有插在前导块之后:%q", messageText(got[2])) } if !strings.HasPrefix(messageText(got[3]), "技能正文:brief") { t.Fatalf("正文块位置不对:%q", messageText(got[3])) } if got[4].Role != model.RoleUser { t.Fatalf("对话被挤走了:%q", got[4].Role) } } // twoDigits 把 0..99 渲染成两位数字,使测试技能名保持字典序 = 生成序。 func twoDigits(i int) string { if i < 10 { return "0" + string(rune('0'+i)) } return string(rune('0'+i/10)) + string(rune('0'+i%10)) }