Files
AskaEth 321cc3b763
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 思考轮工具面补齐(裁决 15)—— 能力面接入 + 只读 capability_list + 本轮状态快照
- 缺口 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 同惯例)
2026-10-05 13:54:57 +08:00

313 lines
11 KiB
Go
Raw Permalink 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 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)
}
}