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) )