feat(backthink): 思考轮工具面补齐(裁决 15)—— 能力面接入 + 只读 capability_list + 本轮状态快照
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 缺口 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 同惯例)
This commit is contained in:
AskaEth committed 2026-10-05 13:54:57 +08:00
1 parent 34cef95e17
commit 321cc3b763
14 files changed
+1664 -73

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// surfaceApp 组装一个只含能力面所需的 app:注册表 + 分发表 + mock IoT 子系统。
// 它走的是与生产相同的两个入口(wireTranscode / wireCapabilitySurface),
// 因此断言的是真实接线,而不是测试自造的替身。
func surfaceApp(t *testing.T) *app {
t.Helper()
caps := registry.New()
d, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
mgr, err := iot.New(config.IoTConfig{Provider: "mock"}, testLog())
if err != nil {
t.Fatalf("iot.New: %v", err)
}
a := &app{log: testLog(), caps: caps, dispatcher: d, iot: mgr}
a.cfg = &config.Config{}
a.cfg.Capacity.Enabled = true
a.wireTranscode()
a.wireCapabilitySurface()
return a
}
// toolNames 返回注册表里当前所有**可调用工具**的名字(模型真正会看到的那一面)。
func toolNames(a *app) []string {
caps := a.caps.List(registry.KindTool)
out := make([]string, 0, len(caps))
for _, c := range caps {
out = append(out, c.Name)
}
return out
}
// TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool 断言 (a)+(c) 的组合根
// 落地:IoT 子系统以 KindIoT 进能力面(此前全仓无人注册该 kind)、只读的
// capability_list 进注册表并被广告给模型,且它读出的外围状态经真实分发器可用。
func TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool(t *testing.T) {
t.Parallel()
a := surfaceApp(t)
iotCap, err := a.caps.Get(iotCapabilityName)
if err != nil {
t.Fatalf("IoT 子系统没进能力注册表: %v", err)
}
if iotCap.Kind != registry.KindIoT {
t.Fatalf("IoT 能力类型 = %q, want %q", iotCap.Kind, registry.KindIoT)
}
if _, ok := iotCap.Value.(*iot.Manager); !ok {
t.Fatalf("IoT 能力值类型 = %T, want *iot.Manager", iotCap.Value)
}
listCap, err := a.caps.Get(registry.ToolName)
if err != nil {
t.Fatalf("capability_list 没进能力注册表: %v", err)
}
if listCap.Kind != registry.KindTool {
t.Fatalf("清单能力类型 = %q, want %q", listCap.Kind, registry.KindTool)
}
if listCap.Policy.AllowInGroup || listCap.Policy.AllowInStranger {
t.Fatalf("清单工具的策略必须保持 fail-closed(owner-only),got %+v", listCap.Policy)
}
advertised := false
for _, s := range a.dispatcher.ToolSchemas() {
if s.Name == registry.ToolName {
advertised = true
}
}
if !advertised {
t.Fatalf("capability_list 未被广告给模型:%v", toolNames(a))
}
// 走真实分发器(含 JSON-Schema 校验与工具执行),断言外围只读状态真的读得出来。
devices, err := a.iot.List(context.Background())
if err != nil {
t.Fatalf("iot.List: %v", err)
}
val, err := a.dispatcher.Invoke(context.Background(), registry.ToolName, map[string]any{})
if err != nil {
t.Fatalf("Invoke(%s): %v", registry.ToolName, err)
}
raw, err := json.Marshal(val)
if err != nil {
t.Fatalf("marshal: %v", err)
}
text := string(raw)
if !strings.Contains(text, fmt.Sprintf(`"devices=%d`, len(devices))) {
t.Fatalf("清单没报出外围设备台数(want devices=%d):%s", len(devices), text)
}
if !strings.Contains(text, `"name":"iot"`) || !strings.Contains(text, `"kind":"iot"`) {
t.Fatalf("清单没列出 IoT 子系统条目:%s", text)
}
if !strings.Contains(text, `"name":"transcode"`) {
t.Fatalf("清单没列出已注册的 transcode 工具:%s", text)
}
// 只读:清单调用(含外围状态读取)不得改动任何设备。
after, err := a.iot.List(context.Background())
if err != nil {
t.Fatalf("iot.List (after): %v", err)
}
if got, want := deviceSnapshot(after), deviceSnapshot(devices); got != want {
t.Fatalf("清单调用改动了设备状态(只读红线):\nwant=%s\ngot=%s", want, got)
}
}
// deviceSnapshot 把设备清单渲染成可比较的快照(按 id 排序,状态键排序)。
func deviceSnapshot(devices []iot.Device) string {
lines := make([]string, 0, len(devices))
for _, d := range devices {
keys := make([]string, 0, len(d.State))
for k := range d.State {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, fmt.Sprintf("%s=%v", k, d.State[k]))
}
lines = append(lines, fmt.Sprintf("%s|%s|%s|%t|%s", d.ID, d.Name, d.Type, d.Online, strings.Join(pairs, ",")))
}
sort.Strings(lines)
return strings.Join(lines, "\n")
}
// TestWireCapabilitySurface_NewToolsReachableUnderCapacity 断言 (b):CAPACITY_ENABLED=true
// 时**新工具**(transcode / capability_list)在静息与唤醒两个内置档位集的允许集内。
//
// 判据用**注册表里的真实工具名**驱动判定器:若 signals.go 的档位集字面量与注册名
// 漂移(例如把 capability_list 写成 capability-list),该名字就不在 AllToolNames 里、
// 交集后消失 ⇒ 本用例变红。
func TestWireCapabilitySurface_NewToolsReachableUnderCapacity(t *testing.T) {
t.Parallel()
a := surfaceApp(t)
dec := a.buildCapacity()
if dec == nil || !dec.Enabled() {
t.Fatal("CAPACITY_ENABLED=true 时判定器必须启用")
}
names := toolNames(a)
cases := []struct {
name string
msg string
want capacity.State
}{
{"静息", "你好", capacity.StateQuiet},
{"唤醒", "帮我查一下资料", capacity.StateAwake},
}
for _, c := range cases {
dec := dec.Decide(context.Background(), capacity.Input{
Messages: []string{c.msg},
CurrentState: capacity.StateQuiet,
AllToolNames: names,
})
if dec.State != c.want {
t.Fatalf("输入 %q 判定态 = %s, want %s", c.msg, dec.State, c.want)
}
for _, want := range []string{"transcode", registry.ToolName} {
if !containsTool(dec.AllowedTools, want) {
t.Fatalf("%s档位允许集缺 %q:%v(新工具被挡住 = 裁决 15 在安静态失效)",
c.name, want, dec.AllowedTools)
}
}
}
}
// containsTool 报告名字是否在允许集里。
func containsTool(names []string, want string) bool {
for _, n := range names {
if n == want {
return true
}
}
return false
}