Files
Cyrene_v2/internal/runtime/loop/capacity_awakening_test.go
T
AskaEth 7afef8d037
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx)
  → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处)
  · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构
  · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致)
- **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉
  awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。
  新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障)
- 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop
  流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同);
  **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑)
- 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略,
  需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
2026-10-04 13:07:52 +08:00

86 lines
3.7 KiB
Go
Raw 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 loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定裁决 14.4.3 后半在**循环**这一层的契约:唤醒档位按该 turn 的角色
// 解析(Options.PersonaAwakening),而不是绑定在 Loop 构造期;未注入解析器或
// 角色未知时一律零覆盖、回落到判定器的全局兜底 —— 即接线前的行为。
// lastStreamTools 返回最新一次流式请求广告给模型的工具名集合。
func lastStreamTools(t *testing.T, ad *mockAdapter) map[string]bool {
t.Helper()
if len(ad.streamReqs) == 0 {
t.Fatal("adapter recorded no streaming request")
}
return toolSet(ad.streamReqs[len(ad.streamReqs)-1].Tools)
}
// TestLoop_CapacityAwakeningFollowsTheTurnRole 断言同一句「意图各层都拿不准」的
// 输入下("这个"),声明了档位的角色唤醒、没声明的角色静息,而**未知角色**与
// **未注入解析器**两种退化都保持判定器的既有行为(静息,且不广告 web-search)。
func TestLoop_CapacityAwakeningFollowsTheTurnRole(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "陪你"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
awakened := map[string]capacity.Awakening{"Y": {ForceState: capacity.StateAwake}}
newLoop := func(resolve func(string) capacity.Awakening) *Loop {
return New(Options{
Agent: a,
PersonaID: "X",
Capacity: dec,
OnStream: func(model.StreamEvent) {},
PersonaAwakening: resolve,
})
}
const ambiguous = "这个"
resolved := newLoop(func(id string) capacity.Awakening { return awakened[id] })
runPersonaTurn(t, resolved, roleEnv("X"), "web:x1", ambiguous)
if got := lastStreamTools(t, ad); got["web-search"] {
t.Fatalf("无档位的角色 X 的工具面 = %v, want 静息(不得广告 web-search)", got)
}
runPersonaTurn(t, resolved, roleEnv("Y"), "web:y1", ambiguous)
if got := lastStreamTools(t, ad); !got["web-search"] {
t.Fatalf("声明 force_state=awake 的角色 Y 的工具面 = %v, want 唤醒", got)
}
// 未知角色(解析器不认识它)不得让该 turn 失败,也不得泄漏别人的档位。
runPersonaTurn(t, resolved, roleEnv("nobody"), "web:z1", ambiguous)
if got := lastStreamTools(t, ad); got["web-search"] {
t.Fatalf("未知角色的工具面 = %v, want 静息(按无覆盖处理)", got)
}
// 未注入解析器 = 接线前的形状:所有角色都走全局兜底。
before := newLoop(nil)
runPersonaTurn(t, before, roleEnv("Y"), "web:y2", ambiguous)
if got := lastStreamTools(t, ad); got["web-search"] {
t.Fatalf("未注入解析器时角色 Y 的工具面 = %v, want 与接线前一致(静息)", got)
}
}
// TestLoop_AwakeningForDegradesQuietly 覆盖解析器的两个降级口:未注入解析器、
// 以及该 turn 没有角色(SessionEnv.PersonaID 与 Options.PersonaID 都为空)时,
// awakeningFor 一律返回零覆盖而不是 panic 或猜一个角色。
func TestLoop_AwakeningForDegradesQuietly(t *testing.T) {
t.Parallel()
plain := WithSessionEnv(context.Background(), OwnerEnv())
if got := New(Options{}).awakeningFor(plain); !got.IsZero() {
t.Fatalf("未注入解析器的循环返回了 %+v, want 零覆盖", got)
}
l := New(Options{PersonaAwakening: func(string) capacity.Awakening {
return capacity.Awakening{ForceState: capacity.StateAwake}
}})
if got := l.awakeningFor(plain); !got.IsZero() {
t.Fatalf("没有角色的 turn 返回了 %+v, want 零覆盖(不猜角色)", got)
}
}