- 缺口 d:状态在**思考前**读一次,同一份快照既喂思考轮又喂 Decide(读不到 = Known=false,绝不伪装成中性状态) - 缺口 c:新增只读工具 capability_list(owner-only、三重输出边界、读写隔离),她可自查当下真实可用面 - 缺口 b:内置允许集补 transcode / capability_list(此前 CAPACITY_ENABLED=true 时新工具会从模型可见面消失) - 缺口 e:guidancePrompt 旧文案承诺的「IoT/日历/提醒」在全仓根本没有对应工具(chat 侧也没有)⇒ 按诚实化改为「与主人对话同等工具面 + 指名自查入口」,不再有空头支票 - (a) 未做且**不自创**:那三把工具无任何设计规格(名字/schema/读写边界/输出上限)⇒ 已报回并登记进 design/20(裁决:走能力/插件/MCP 暴露路线) - 变异实测:允许集去掉新工具 ⇒ TestDecide_AllowedSets + 组合根跨层测试红;丢掉状态快照 ⇒ TestLoopThinker_Think_InjectsRoundStateBlock 红 - 已知弱项(如实登记):组合根那行 wireCapabilitySurface() 调用本身无测试覆盖(与既有 wireTranscode 同惯例)
313 lines
11 KiB
Go
313 lines
11 KiB
Go
package registry
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// listProbeTool 是只为「进注册表」而存在的 sdk.Tool 替身:清单工具只读它的
|
||
// 名字/说明,从不执行它。
|
||
type listProbeTool string
|
||
|
||
func (t listProbeTool) Name() string { return string(t) }
|
||
func (t listProbeTool) Description() string { return "probe tool " + string(t) }
|
||
func (t listProbeTool) Parameters() (map[string]any, error) {
|
||
return map[string]any{"type": "object"}, nil
|
||
}
|
||
func (t listProbeTool) Execute(context.Context, any) (any, error) { return nil, nil }
|
||
|
||
// listProbeSource 是一个只读状态源替身,记录被读次数并返回固定文本。
|
||
type listProbeSource struct {
|
||
name string
|
||
text string
|
||
err error
|
||
calls int
|
||
}
|
||
|
||
func (s *listProbeSource) Name() string { return s.name }
|
||
|
||
func (s *listProbeSource) Status(context.Context) (string, error) {
|
||
s.calls++
|
||
return s.text, s.err
|
||
}
|
||
|
||
// seedListRegistry 造一个装了若干能力(含非工具类)的注册表。
|
||
func seedListRegistry(t *testing.T) *Registry {
|
||
t.Helper()
|
||
r := New()
|
||
for _, name := range []string{"zeta", "alpha"} {
|
||
if err := r.RegisterTool(listProbeTool(name)); err != nil {
|
||
t.Fatalf("RegisterTool(%s): %v", name, err)
|
||
}
|
||
}
|
||
if err := r.Register(Capability{Name: "iot", Kind: KindIoT, Description: "peripherals", Value: struct{}{}}); err != nil {
|
||
t.Fatalf("Register(iot): %v", err)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// snapshotRegistry 记下注册表的可比较快照(名字 → 类型 + 策略),
|
||
// 用于断言清单工具**没有**改动注册表(只读红线)。List 的遍历顺序来自 map,
|
||
// 因此快照必须先排序,否则比较的是 map 迭代顺序而不是内容。
|
||
func snapshotRegistry(r *Registry) string {
|
||
caps := r.List("")
|
||
lines := make([]string, 0, len(caps))
|
||
for _, cap := range caps {
|
||
lines = append(lines, fmt.Sprintf("%s|%s|%t|%t|%s",
|
||
cap.Name, cap.Kind, cap.Policy.AllowInGroup, cap.Policy.AllowInStranger, cap.Description))
|
||
}
|
||
sort.Strings(lines)
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// TestListTool_IsReadOnly 断言「只读、无副作用」:执行前后注册表逐字段不变
|
||
// (变异点:在 Execute 里加任何 Register/Remove/SetPolicy ⇒ 本用例变红)。
|
||
func TestListTool_IsReadOnly(t *testing.T) {
|
||
t.Parallel()
|
||
reg := seedListRegistry(t)
|
||
before := snapshotRegistry(reg)
|
||
|
||
lt, err := NewListTool(reg, WithStatusSource(&listProbeSource{name: "iot", text: "devices=1"}))
|
||
if err != nil {
|
||
t.Fatalf("NewListTool: %v", err)
|
||
}
|
||
for i := 0; i < 3; i++ {
|
||
if _, err := lt.Execute(context.Background(), map[string]any{}); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
}
|
||
if after := snapshotRegistry(reg); after != before {
|
||
t.Fatalf("清单工具改动了注册表(只读红线):\nbefore=%s\nafter=%s", before, after)
|
||
}
|
||
}
|
||
|
||
// TestListTool_OutputIsBounded 断言输出有界,且**三道边界各自独立可观测**
|
||
// (entry 数上限、limit 夹持、字符预算):
|
||
//
|
||
// - 短描述下 limit 的两道口径都咬得住(默认 50 条 / 显式超大值被夹到 200 条);
|
||
// - 长描述下由字符预算兜底(条数远小于 maxListLimit),序列化后仍装得进预算。
|
||
func TestListTool_OutputIsBounded(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
short := seedListEntries(t, 400, "d")
|
||
shortTool, err := NewListTool(short)
|
||
if err != nil {
|
||
t.Fatalf("NewListTool: %v", err)
|
||
}
|
||
|
||
// 默认口径:不写 limit 时最多 defaultListLimit 条。
|
||
def, err := shortTool.Execute(context.Background(), map[string]any{})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
defSum := def.(ListSummary)
|
||
if len(defSum.Entries) != defaultListLimit {
|
||
t.Fatalf("不写 limit 时 entries = %d, want %d(默认条目上限)", len(defSum.Entries), defaultListLimit)
|
||
}
|
||
if defSum.Total != 400 || !defSum.Truncated || defSum.Note == "" {
|
||
t.Fatalf("截断必须显式标注且统计为全量:total=%d truncated=%v note=%q",
|
||
defSum.Total, defSum.Truncated, defSum.Note)
|
||
}
|
||
|
||
// 夹持口径:显式要一个荒谬大的 limit,也只能拿到 maxListLimit 条。
|
||
clamped, err := shortTool.Execute(context.Background(), map[string]any{"limit": float64(maxListLimit * 25)})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if n := len(clamped.(ListSummary).Entries); n != maxListLimit {
|
||
t.Fatalf("limit=%d 时 entries = %d, want %d(limit 未被夹到上限)", maxListLimit*25, n, maxListLimit)
|
||
}
|
||
|
||
// 小 limit 口径:要几条就给几条。
|
||
small, err := shortTool.Execute(context.Background(), map[string]any{"limit": float64(3)})
|
||
if err != nil {
|
||
t.Fatalf("Execute(limit=3): %v", err)
|
||
}
|
||
if n := len(small.(ListSummary).Entries); n != 3 {
|
||
t.Fatalf("limit=3 时 entries = %d, want 3", n)
|
||
}
|
||
|
||
// 预算口径:长描述下由字符预算咬住(早于 maxListLimit),且序列化后装得进预算。
|
||
longTool, err := NewListTool(seedListEntries(t, 400, strings.Repeat("很长的说明", 200)))
|
||
if err != nil {
|
||
t.Fatalf("NewListTool: %v", err)
|
||
}
|
||
huge, err := longTool.Execute(context.Background(), map[string]any{"limit": float64(maxListLimit * 25)})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
hugeSum := huge.(ListSummary)
|
||
if len(hugeSum.Entries) >= maxListLimit {
|
||
t.Fatalf("长描述下 entries = %d,字符预算没咬住(want < %d)", len(hugeSum.Entries), maxListLimit)
|
||
}
|
||
if !hugeSum.Truncated {
|
||
t.Fatal("预算截断必须标注 truncated=true")
|
||
}
|
||
for _, e := range hugeSum.Entries {
|
||
if len([]rune(e.Description)) > maxDescriptionChars+1 {
|
||
t.Fatalf("description 未被截断:%d runes", len([]rune(e.Description)))
|
||
}
|
||
}
|
||
raw, err := json.Marshal(hugeSum)
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
if len(raw) > summaryCharBudget+4096 {
|
||
t.Fatalf("序列化后 %d 字节,超出字符预算 %d 的允许余量", len(raw), summaryCharBudget)
|
||
}
|
||
}
|
||
|
||
// seedListEntries 造一个装了 n 个同描述工具能力的注册表(描述长度决定咬住输出的
|
||
// 是条目上限还是字符预算)。
|
||
func seedListEntries(t *testing.T, n int, desc string) *Registry {
|
||
t.Helper()
|
||
reg := New()
|
||
for i := 0; i < n; i++ {
|
||
name := fmt.Sprintf("tool-%03d", i)
|
||
if err := reg.Register(Capability{
|
||
Name: name,
|
||
Kind: KindTool,
|
||
Description: desc,
|
||
Value: listProbeTool(name),
|
||
}); err != nil {
|
||
t.Fatalf("Register(%s): %v", name, err)
|
||
}
|
||
}
|
||
return reg
|
||
}
|
||
|
||
// TestListTool_ListsKindsAndFilters 断言清单如实列出各 kind(含非工具类)并能按
|
||
// kind 过滤;排序确定(kind → name),因此两次调用逐字一致。
|
||
func TestListTool_ListsKindsAndFilters(t *testing.T) {
|
||
t.Parallel()
|
||
reg := seedListRegistry(t)
|
||
lt, err := NewListTool(reg)
|
||
if err != nil {
|
||
t.Fatalf("NewListTool: %v", err)
|
||
}
|
||
|
||
all, err := lt.Execute(context.Background(), map[string]any{})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
sum := all.(ListSummary)
|
||
if sum.Total != 3 || sum.Counts[string(KindTool)] != 2 || sum.Counts[string(KindIoT)] != 1 {
|
||
t.Fatalf("counts = %+v total=%d, want tool=2 iot=1 total=3", sum.Counts, sum.Total)
|
||
}
|
||
names := make([]string, 0, len(sum.Entries))
|
||
for _, e := range sum.Entries {
|
||
names = append(names, e.Kind+":"+e.Name)
|
||
}
|
||
want := []string{"iot:iot", "tool:alpha", "tool:zeta"}
|
||
if strings.Join(names, ",") != strings.Join(want, ",") {
|
||
t.Fatalf("entries = %v, want %v(按 kind+name 确定序)", names, want)
|
||
}
|
||
for _, e := range sum.Entries {
|
||
if !e.OwnerOnly {
|
||
t.Fatalf("条目 %s 报告 ownerOnly=false,但零值策略应为 owner-only(fail-closed)", e.Name)
|
||
}
|
||
}
|
||
|
||
filtered, err := lt.Execute(context.Background(), map[string]any{"kind": string(KindIoT)})
|
||
if err != nil {
|
||
t.Fatalf("Execute(kind=iot): %v", err)
|
||
}
|
||
onlyIoT := filtered.(ListSummary)
|
||
if len(onlyIoT.Entries) != 1 || onlyIoT.Entries[0].Name != "iot" || onlyIoT.Entries[0].Kind != string(KindIoT) {
|
||
t.Fatalf("kind=iot 过滤结果 = %+v", onlyIoT.Entries)
|
||
}
|
||
|
||
if _, err := lt.Execute(context.Background(), map[string]any{"kind": "nope"}); err == nil {
|
||
t.Fatal("未知 kind 必须报错,不得被静默当成不过滤")
|
||
}
|
||
if _, err := lt.Execute(context.Background(), "not-an-object"); err == nil {
|
||
t.Fatal("非对象 args 必须报错")
|
||
}
|
||
}
|
||
|
||
// TestListTool_ReportsStatusSources 断言注入的只读状态源被读出并回报,
|
||
// 失败**如实回报 error**、绝不吞掉(诚实红线)。
|
||
func TestListTool_ReportsStatusSources(t *testing.T) {
|
||
t.Parallel()
|
||
reg := seedListRegistry(t)
|
||
ok := &listProbeSource{name: "iot", text: " 洗衣机 · 运行中"}
|
||
bad := &listProbeSource{name: "calendar", err: errors.New("provider offline")}
|
||
lt, err := NewListTool(reg, WithStatusSource(ok), WithStatusSource(bad))
|
||
if err != nil {
|
||
t.Fatalf("NewListTool: %v", err)
|
||
}
|
||
got, err := lt.Execute(context.Background(), map[string]any{})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
sum := got.(ListSummary)
|
||
if len(sum.Statuses) != 2 {
|
||
t.Fatalf("statuses = %+v, want 2", sum.Statuses)
|
||
}
|
||
if sum.Statuses[0].Name != "iot" || !strings.Contains(sum.Statuses[0].Text, "洗衣机") || sum.Statuses[0].Error != "" {
|
||
t.Fatalf("iot status = %+v", sum.Statuses[0])
|
||
}
|
||
if sum.Statuses[1].Error == "" {
|
||
t.Fatalf("读取失败的状态源必须带 error,got %+v", sum.Statuses[1])
|
||
}
|
||
if ok.calls != 1 || bad.calls != 1 {
|
||
t.Fatalf("status source calls = %d/%d, want 1/1", ok.calls, bad.calls)
|
||
}
|
||
|
||
// 未注入状态源时该字段缺席(不伪造一份空状态)。
|
||
plain, err := NewListTool(reg)
|
||
if err != nil {
|
||
t.Fatalf("NewListTool: %v", err)
|
||
}
|
||
noStatus, err := plain.Execute(context.Background(), map[string]any{})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if s := noStatus.(ListSummary); len(s.Statuses) != 0 {
|
||
t.Fatalf("未注入状态源时 statuses 必须为空, got %+v", s.Statuses)
|
||
}
|
||
}
|
||
|
||
// TestListTool_ContractAndSchema 锁定工具面契约:名字、schema 形状(两个可选参数、
|
||
// kind 是闭集 enum)与 nil 注册表拒绝。
|
||
func TestListTool_ContractAndSchema(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := NewListTool(nil); err == nil {
|
||
t.Fatal("nil 注册表必须被拒(不得返回一份空清单冒充成功)")
|
||
}
|
||
lt, err := NewListTool(seedListRegistry(t))
|
||
if err != nil {
|
||
t.Fatalf("NewListTool: %v", err)
|
||
}
|
||
if lt.Name() != ToolName || ToolName != "capability_list" {
|
||
t.Fatalf("Name = %q, want %q", lt.Name(), ToolName)
|
||
}
|
||
schema, err := lt.Parameters()
|
||
if err != nil {
|
||
t.Fatalf("Parameters: %v", err)
|
||
}
|
||
props, ok := schema["properties"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("schema.properties = %#v", schema["properties"])
|
||
}
|
||
if _, hasRequired := schema["required"]; hasRequired {
|
||
t.Fatal("两个参数都是可选的,schema 不应声明 required")
|
||
}
|
||
kindProp, ok := props["kind"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("schema 缺 kind 属性: %#v", props)
|
||
}
|
||
enum, ok := kindProp["enum"].([]string)
|
||
if !ok || len(enum) != maxListKindNames {
|
||
t.Fatalf("kind enum = %#v, want %d 个闭集取值", kindProp["enum"], maxListKindNames)
|
||
}
|
||
if _, ok := props["limit"].(map[string]any); !ok {
|
||
t.Fatalf("schema 缺 limit 属性: %#v", props)
|
||
}
|
||
}
|