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

356 lines
13 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 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)
)