- 缺口 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 同惯例)
356 lines
13 KiB
Go
356 lines
13 KiB
Go
package backthink
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"reflect"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||
)
|
||
|
||
// 本文件是 X1 缺口 d(把用户状态喂给思考轮)与缺口 e(引导文案与实现一致)
|
||
// 的行为断言。
|
||
|
||
// stateCapturingThinker 记录每轮经 ctx 拿到的状态快照,并返回固定结论。
|
||
type stateCapturingThinker struct {
|
||
mu sync.Mutex
|
||
got []RoundState
|
||
text string
|
||
}
|
||
|
||
func (t *stateCapturingThinker) Think(ctx context.Context, _, _ string) (string, error) {
|
||
rs := RoundStateFrom(ctx)
|
||
t.mu.Lock()
|
||
t.got = append(t.got, rs)
|
||
t.mu.Unlock()
|
||
return t.text, nil
|
||
}
|
||
|
||
func (t *stateCapturingThinker) last() RoundState {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
if len(t.got) == 0 {
|
||
return RoundState{}
|
||
}
|
||
return t.got[len(t.got)-1]
|
||
}
|
||
|
||
func (t *stateCapturingThinker) count() int {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
return len(t.got)
|
||
}
|
||
|
||
// seqStateProvider 每次调用返回**不同**的状态,并计数:第一次是「睡着」,
|
||
// 之后是「醒着、心率 90」。若实现把状态读了两次(思考前一次、决策前一次),
|
||
// 决策看到的就是第二份,本文件的断言随即变红 —— 这正是「同一份快照」的判据。
|
||
type seqStateProvider struct {
|
||
mu sync.Mutex
|
||
n int
|
||
}
|
||
|
||
func (p *seqStateProvider) Current(context.Context) (UserState, error) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
p.n++
|
||
if p.n == 1 {
|
||
return UserState{HeartRateBPM: 58, Sleeping: true}, nil
|
||
}
|
||
return UserState{HeartRateBPM: 90, Sleeping: false}, nil
|
||
}
|
||
|
||
func (p *seqStateProvider) calls() int {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
return p.n
|
||
}
|
||
|
||
// daytime 把一轮思考的墙钟钉在下午,使免打扰窗口(默认 23:00-07:00)不参与
|
||
// 判定 —— 否则断言会随测试机运行时刻漂移。
|
||
func daytime() time.Time { return time.Date(2026, 10, 5, 14, 0, 0, 0, time.Local) }
|
||
|
||
// TestThink_StateSnapshotIsSharedWithTheThinkingRound 断言缺口 d:一轮思考只读**一次**
|
||
// 用户状态,同一份快照既交给思考轮(ctx),又交给打扰决策。
|
||
func TestThink_StateSnapshotIsSharedWithTheThinkingRound(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &stateCapturingThinker{text: "我决定:push\n我醒了"}
|
||
provider := &seqStateProvider{}
|
||
b, _ := newBackthink(t, Config{}, Deps{
|
||
Thinker: thinker,
|
||
State: provider,
|
||
Pusher: &mockPusher{result: PushResult{Route: RouteActiveClient, Pushed: true}},
|
||
Now: daytime,
|
||
})
|
||
|
||
b.think(context.Background(), heartbeatTrigger())
|
||
|
||
got := thinker.last()
|
||
if !got.Known {
|
||
t.Fatal("思考轮没拿到状态快照(Known=false)")
|
||
}
|
||
if got.State != (UserState{HeartRateBPM: 58, Sleeping: true}) {
|
||
t.Fatalf("思考轮拿到 %s, want 心率 58 / 睡眠中", got.State)
|
||
}
|
||
if provider.calls() != 1 {
|
||
t.Fatalf("状态被读了 %d 次,want 1(同一份快照 = 一轮只读一次)", provider.calls())
|
||
}
|
||
// 决策用的必须是思考轮那一份(睡着)而不是第二份(醒着、心率 90):
|
||
// 她想 push,仍被「她在睡」扣下。
|
||
recs, err := b.List(context.Background(), 1)
|
||
if err != nil {
|
||
t.Fatalf("List: %v", err)
|
||
}
|
||
if len(recs) != 1 {
|
||
t.Fatalf("records = %d, want 1", len(recs))
|
||
}
|
||
if recs[0].ShouldPush || recs[0].PushResult.Detail != "user is asleep; not interrupting" {
|
||
t.Fatalf("决策结果 = %+v, want 被「睡着」扣下(即用的是第一份快照)", recs[0])
|
||
}
|
||
}
|
||
|
||
// TestThink_UnknownStateDegradesHonestly 断言状态读不到时不撒谎:思考轮拿到
|
||
// Known=false(因此不注入状态块),决策按中性状态走(醒着的下午可以推送),
|
||
// 且绝不因此让整轮失败。
|
||
func TestThink_UnknownStateDegradesHonestly(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &stateCapturingThinker{text: "我决定:push\n在忙吗"}
|
||
pusher := &mockPusher{result: PushResult{Route: RouteActiveClient, Pushed: true}}
|
||
b, _ := newBackthink(t, Config{}, Deps{
|
||
Thinker: thinker,
|
||
State: errStateProvider{err: errors.New("telemetry offline")},
|
||
Pusher: pusher,
|
||
Now: daytime,
|
||
})
|
||
|
||
b.think(context.Background(), heartbeatTrigger())
|
||
|
||
if got := thinker.last(); got.Known {
|
||
t.Fatalf("状态读取失败时 Known 必须为 false, got %+v", got)
|
||
}
|
||
if pusher.count() != 1 {
|
||
t.Fatalf("中性状态下应照常推送, pusher calls = %d", pusher.count())
|
||
}
|
||
if block := roundStateBlock(thinker.last()); block != "" {
|
||
t.Fatalf("未知状态不得渲染状态块: %q", block)
|
||
}
|
||
}
|
||
|
||
// errStateProvider 是总是失败的 StateProvider。
|
||
type errStateProvider struct{ err error }
|
||
|
||
func (p errStateProvider) Current(context.Context) (UserState, error) { return UserState{}, p.err }
|
||
|
||
// TestRoundStateBlock_OnlyReportsObservedFacts 断言状态块只写**观测到**的事实:
|
||
// 零值字段(未观测到)不出现,绝不把「未知」说成「否」。
|
||
func TestRoundStateBlock_OnlyReportsObservedFacts(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// parts 是「已观测到的信号」的唯一渲染处:全零状态必须一个都没有。
|
||
if parts := (RoundState{Known: true}).parts(); len(parts) != 0 {
|
||
t.Fatalf("全零状态的信号 = %v, want 空(未观测到就不渲染)", parts)
|
||
}
|
||
signals := RoundState{Known: true, State: UserState{HeartRateBPM: 58, Sleeping: true, Working: false, Boredom: 0.3}}.parts()
|
||
wantSignals := []string{"心率 58 bpm", "睡眠中", "无聊度 0.30"}
|
||
if !reflect.DeepEqual(signals, wantSignals) {
|
||
t.Fatalf("信号 = %v, want %v(false/零值字段不得出现)", signals, wantSignals)
|
||
}
|
||
|
||
unknown := roundStateBlock(RoundState{Known: true})
|
||
if !strings.Contains(unknown, "未观测到") {
|
||
t.Fatalf("全零状态必须如实说明「未观测到」,got %q", unknown)
|
||
}
|
||
for _, banned := range []string{"睡眠中", "工作中", "忙碌)", "bpm"} {
|
||
if strings.Contains(unknown, banned) {
|
||
t.Fatalf("全零状态里出现了 %q —— 把「未知」渲染成了「否」:%q", banned, unknown)
|
||
}
|
||
}
|
||
|
||
known := roundStateBlock(RoundState{Known: true, State: UserState{HeartRateBPM: 58, Sleeping: true, Boredom: 0.3}})
|
||
for _, want := range wantSignals {
|
||
if !strings.Contains(known, want) {
|
||
t.Fatalf("观测到的信号 %q 没进状态块: %q", want, known)
|
||
}
|
||
}
|
||
if strings.Contains(known, "工作中") {
|
||
t.Fatalf("未观测到工作信号,不得出现「工作中」: %q", known)
|
||
}
|
||
}
|
||
|
||
// TestLoopThinker_Think_InjectsRoundStateBlock 断言缺口 d 端到端:状态块真的抵达模型
|
||
// 请求(走真实 loop),且控制台无抑制措辞。
|
||
func TestLoopThinker_Think_InjectsRoundStateBlock(t *testing.T) {
|
||
t.Parallel()
|
||
ad := &captureAdapter{}
|
||
l, _ := newThinkLoop(t, ad)
|
||
lt := NewLoopThinker(l, nil)
|
||
|
||
ctx := WithRoundState(context.Background(), RoundState{
|
||
Known: true,
|
||
State: UserState{HeartRateBPM: 58, Sleeping: true},
|
||
})
|
||
if _, err := lt.Think(ctx, "sess-backthink", "heartbeat"); err != nil {
|
||
t.Fatalf("Think: %v", err)
|
||
}
|
||
if len(ad.reqs) == 0 {
|
||
t.Fatal("no model request captured")
|
||
}
|
||
last := ad.reqs[len(ad.reqs)-1]
|
||
var text strings.Builder
|
||
for _, m := range last.Messages {
|
||
text.WriteString(modelText(m))
|
||
}
|
||
if !strings.Contains(text.String(), "主人此刻的状态") || !strings.Contains(text.String(), "睡眠中") {
|
||
t.Fatalf("状态块没进模型请求: %s", text.String())
|
||
}
|
||
|
||
// 未挂载状态时同一路径逐字回到接线前的输入(不注入任何状态描述)。
|
||
ad2 := &captureAdapter{}
|
||
l2, _ := newThinkLoop(t, ad2)
|
||
lt2 := NewLoopThinker(l2, nil)
|
||
if _, err := lt2.Think(context.Background(), "sess-backthink", "heartbeat"); err != nil {
|
||
t.Fatalf("Think (no state): %v", err)
|
||
}
|
||
var plain strings.Builder
|
||
for _, m := range ad2.reqs[len(ad2.reqs)-1].Messages {
|
||
plain.WriteString(modelText(m))
|
||
}
|
||
if strings.Contains(plain.String(), "主人此刻的状态") {
|
||
t.Fatalf("未挂载状态时不得注入状态块: %s", plain.String())
|
||
}
|
||
}
|
||
|
||
// regProbeTool 是只为「进注册表」而存在的 sdk.Tool 替身:本用例断言思考轮**看得见**
|
||
// 它,从不执行它。
|
||
type regProbeTool string
|
||
|
||
func (t regProbeTool) Name() string { return string(t) }
|
||
func (t regProbeTool) Description() string { return "probe tool " + string(t) }
|
||
func (t regProbeTool) Parameters() (map[string]any, error) {
|
||
return map[string]any{"type": "object"}, nil
|
||
}
|
||
func (t regProbeTool) Execute(context.Context, any) (any, error) { return nil, nil }
|
||
|
||
// TestLoopThinker_Think_AdvertisesTheRegistryToolFace 断言缺口 a 的机制:一次自主思考
|
||
// 驱动的是**运行时那个循环**,因此模型在该轮看到的工具面就是能力注册表当下的工具面
|
||
// —— 组合根往注册表里加什么,chat 与思考轮同时看得见,不需要第二套注册机制。
|
||
//
|
||
// 负对照:同样的循环若建在空注册表上,该轮就看不到任何工具 —— 证明本断言测的是真实
|
||
// 工具面,而不是「怎么都成立」的空断言(对齐变异「把思考轮的工具面退回空集必须变红」)。
|
||
func TestLoopThinker_Think_AdvertisesTheRegistryToolFace(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const probe = "capability_list"
|
||
|
||
withTool := func() (*loop.Loop, *captureAdapter) {
|
||
reg := registry.New()
|
||
d, err := tool.New(reg)
|
||
if err != nil {
|
||
t.Fatalf("tool.New: %v", err)
|
||
}
|
||
if err := d.Register(regProbeTool(probe)); err != nil {
|
||
t.Fatalf("register %s: %v", probe, err)
|
||
}
|
||
ad := &captureAdapter{}
|
||
ag, err := agent.New("You are Cyrene.", d, nil, agent.WithModel(ad, nil))
|
||
if err != nil {
|
||
t.Fatalf("agent.New: %v", err)
|
||
}
|
||
return loop.New(loop.Options{Agent: ag, Tools: d}), ad
|
||
}
|
||
|
||
l, ad := withTool()
|
||
if _, err := NewLoopThinker(l, nil).Think(context.Background(), "sess-backthink", "heartbeat"); err != nil {
|
||
t.Fatalf("Think: %v", err)
|
||
}
|
||
if len(ad.reqs) == 0 {
|
||
t.Fatal("no model request captured")
|
||
}
|
||
if !advertises(ad.reqs[len(ad.reqs)-1], probe) {
|
||
t.Fatalf("思考轮没看到注册表里的工具 %q:%+v", probe, ad.reqs[len(ad.reqs)-1].Tools)
|
||
}
|
||
|
||
// 负对照:空注册表的同类循环 —— 该轮工具面为空。
|
||
emptyDispatcher, err := tool.New(registry.New())
|
||
if err != nil {
|
||
t.Fatalf("tool.New: %v", err)
|
||
}
|
||
emptyAdapter := &captureAdapter{}
|
||
emptyAgent, err := agent.New("You are Cyrene.", emptyDispatcher, nil, agent.WithModel(emptyAdapter, nil))
|
||
if err != nil {
|
||
t.Fatalf("agent.New: %v", err)
|
||
}
|
||
emptyLoop := loop.New(loop.Options{Agent: emptyAgent, Tools: emptyDispatcher})
|
||
if _, err := NewLoopThinker(emptyLoop, nil).Think(context.Background(), "sess-backthink", "heartbeat"); err != nil {
|
||
t.Fatalf("Think (empty): %v", err)
|
||
}
|
||
if got := emptyAdapter.reqs[len(emptyAdapter.reqs)-1]; len(got.Tools) != 0 {
|
||
t.Fatalf("负对照的工具面 = %+v, want 空(空注册表不该有工具)", got.Tools)
|
||
}
|
||
}
|
||
|
||
// advertises 报告一次模型请求的工具面里是否含 name。
|
||
func advertises(req model.Request, name string) bool {
|
||
for _, s := range req.Tools {
|
||
if s.Name == name {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// TestGuidancePrompt_ToolClaimMatchesImplementation 是缺口 e 的判据:引导文案不得再
|
||
// 承诺**全仓不存在**的工具(旧文案的「IoT / 日历 / 提醒 / …」枚举),而必须指向
|
||
// 可验证的自查入口 capability_list —— 它列出的就是当下真正注册的工具面。
|
||
func TestGuidancePrompt_ToolClaimMatchesImplementation(t *testing.T) {
|
||
t.Parallel()
|
||
prompts := map[string]string{
|
||
"heartbeat": guidancePrompt("heartbeat"),
|
||
"startup": guidancePrompt(StartupSummary),
|
||
}
|
||
for name, p := range prompts {
|
||
if strings.Contains(p, "IoT") {
|
||
t.Fatalf("%s 引导仍承诺 IoT 工具(全仓没有该工具 = 空头支票):%s", name, p)
|
||
}
|
||
if !strings.Contains(p, "capability_list") {
|
||
t.Fatalf("%s 引导没给出可验证的自查入口 capability_list:%s", name, p)
|
||
}
|
||
if !strings.Contains(p, "与主人对话同等") {
|
||
t.Fatalf("%s 引导没有声明「与主人对话同等的工具面」:%s", name, p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestRoundStateFrom_UnmountedIsUnknown 断言「未挂载」与「挂载了但全零」是两回事,
|
||
// 且 nil ctx 不 panic。
|
||
func TestRoundStateFrom_UnmountedIsUnknown(t *testing.T) {
|
||
t.Parallel()
|
||
if rs := RoundStateFrom(context.Background()); rs.Known {
|
||
t.Fatalf("未挂载时必须 Known=false, got %+v", rs)
|
||
}
|
||
if rs := RoundStateFrom(nil); rs.Known {
|
||
t.Fatalf("nil ctx 必须 Known=false, got %+v", rs)
|
||
}
|
||
if ctx := WithRoundState(nil, RoundState{Known: true}); ctx != nil {
|
||
t.Fatal("WithRoundState(nil, ...) 必须返回 nil ctx")
|
||
}
|
||
mounted := RoundStateFrom(WithRoundState(context.Background(), RoundState{Known: true}))
|
||
if !mounted.Known {
|
||
t.Fatalf("挂载后必须读回 Known=true, got %+v", mounted)
|
||
}
|
||
}
|
||
|
||
// 编译期断言:测试用的状态源/思考器确实满足生产接口。
|
||
var (
|
||
_ StateProvider = (*seqStateProvider)(nil)
|
||
_ StateProvider = errStateProvider{}
|
||
_ Thinker = (*stateCapturingThinker)(nil)
|
||
)
|