Files
Cyrene_v2/cmd/cyrene/wire_context_test.go
T
AskaEth da3a386daf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(cyrene,config,loop): 护栏/压缩/身份知识三条落地(P0-5/P0-10/P0-11)+ 身份注入收窄到主人会话
- P0-5 护栏不再是空壳:挂 WithSensitiveInfo(阻断) + 长度上限(输入 262144 / 输出 65536 字符,依据=
  组装完成的请求体量级与「不误伤正常对话」) + 关键词**仅在配置时**挂;正常对话有用例证明放行
- P0-11 上下文压缩默认启用:CONTEXT_MAX_TOKENS 默认 32768(=loop.DefaultMaxContextTokens,两常量由测试锁死),
  0/负 = 关闭(行为退回接线前);长对话真压缩且摘要消息进请求首位、最近一轮保留
- P0-10 身份说明书(owner.yaml identityFile)注入人格上下文,并且**收窄为仅主人会话**:
  · 新增最小钩子 loop.Options.PersonaKnowledgeFor(env):**按该轮会话环境**解析(不看提示词、不看会话 id),
    块只进每轮视图、**不进 messages**(不进 checkpoint、不进记忆提取面)
  · 开关 OWNER_IDENTITY_INJECTION:owner_only(默认) / always(仅自测) / off;非法值与读不动 → WARN + 回退默认(不静默)
  · **顺手关掉第二条同型泄漏**:channelbridge 群聊参与度判定器的 PersonaPrompt 此前带身份正文 ⇒ 现改为纯人格指令
  · 回归:缺文件/目录/未配置逐字节等于旧输出;静态指令与角色池 Instructions() 全不含正文;
    文件里点名的陌生账号仍 IsOwner==false(身份知识绝不参与权限判定)
- 复核:build/vet 绿 + cmd/config/loop 包测试绿;四类断言(主人含 / **群聊与陌生人不含** / always·off 两档 /
  回归不变)+ 4 条变异反证(含把判定改成恒真 → 提示词里真的出现身份说明书)均已还原
- 未做(登记):knowledge 块启动时读一次(改文件需重启);`.env.example` 的 OWNER_IDENTITY_INJECTION 行随后补;
  `internal/user/owner.go` 注释与 design/01 §2.2、design/11 §0 待同步
2026-10-04 14:30:26 +08:00

232 lines
9.1 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 main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"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"
)
// 本文件锁定 P0-11:上下文溢出压缩在生产里**真的启用**,且阈值来自
// CONTEXT_MAX_TOKENS。断言落在「模型实际看到的请求消息」上:
// 预算小时长对话被压成「一条前导摘要 + 近期尾部」,摘要消息确实进了 prompt;
// 预算为 0 时行为与接线前逐条一致(一条消息都不少)。
// loopCompactSummaryMarker 是 loop 压缩摘要消息的开头(internal/runtime/loop/
// compactor.go 的 summaryMarker)。测试按这个可观测契约判定「摘要进了 prompt」。
const loopCompactSummaryMarker = "[前文摘要 · history summary]"
// compactProbeAdapter 记下每次流式请求,使测试能读回模型实际看到的上下文。
type compactProbeAdapter struct{ reqs []model.Request }
func (a *compactProbeAdapter) Name() string { return "compact-probe" }
func (a *compactProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: "好"}, nil
}
func (a *compactProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
a.reqs = append(a.reqs, req)
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好"})
}
return model.Response{Content: "好"}, nil
}
// longConversation 造一段「比小预算长得多、单看每条都很正常」的对话:
// 12 轮问答、每条约 220 个汉字(离线估算约 114 token/条,共约 2700 token)。
func longConversation() []model.Message {
nums := []string{"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"}
filler := strings.Repeat("今天也要好好吃饭早点休息哦", 17)
msgs := make([]model.Message, 0, 2*len(nums))
for _, n := range nums {
msgs = append(msgs,
model.TextMessage(model.RoleUser, "第"+n+"件事:"+filler),
model.TextMessage(model.RoleAssistant, "好呀,人家记下了♪"+filler),
)
}
return msgs
}
// runWithBudget 用给定预算驱动一次真实循环(装配形状与组合根一致:
// MaxContextTokens 取自 a.contextBudget(),Compactor 不注入 —— 由 loop 选用
// 内置的结构化 compactor),返回模型实际看到的请求。
func runWithBudget(t *testing.T, budget int, msgs []model.Message) model.Request {
t.Helper()
adapter := &compactProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
MaxContextTokens: budget,
OnStream: func(model.StreamEvent) {},
})
env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: "cyrene"}
ctx := loop.WithSessionEnv(context.Background(), env)
if _, err := l.Run(ctx, "web", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
if len(adapter.reqs) == 0 {
t.Fatal("没有捕获到任何模型请求")
}
return adapter.reqs[len(adapter.reqs)-1]
}
// summaryMessages 返回请求里所有压缩摘要消息的下标。
func summaryMessages(req model.Request) []int {
var idx []int
for i, m := range req.Messages {
if strings.HasPrefix(loopText(m), loopCompactSummaryMarker) {
idx = append(idx, i)
}
}
return idx
}
// loopText 取一条消息的文本部件拼接(与 loop 的 messageText 同义)。
func loopText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
// TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault 锁定取值链:
// 配置层默认 == loop.DefaultMaxContextTokens(两个常量不许漂移),
// 组合根读的就是配置值(含「0 = 关闭」这条语义)。
func TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault(t *testing.T) {
// 环境里若已有 CONTEXT_MAX_TOKENS,配置层会优先用它,默认值断言就不成立;
// 用 t.Setenv 钉空串也不行(空串会被解析成 0),因此这里显式清掉再还原。
if prev, had := os.LookupEnv("CONTEXT_MAX_TOKENS"); had {
_ = os.Unsetenv("CONTEXT_MAX_TOKENS")
t.Cleanup(func() { _ = os.Setenv("CONTEXT_MAX_TOKENS", prev) })
}
t.Setenv("JWT_SECRET", strings.Repeat("k", 40))
cfg, err := config.Load(filepath.Join(t.TempDir(), "absent.env"))
if err != nil {
t.Fatalf("config.Load: %v", err)
}
if cfg.Context.MaxTokens != config.DefaultContextMaxTokens {
t.Fatalf("CONTEXT_MAX_TOKENS 默认值 = %d,want %d", cfg.Context.MaxTokens, config.DefaultContextMaxTokens)
}
if config.DefaultContextMaxTokens != loop.DefaultMaxContextTokens {
t.Fatalf("默认值与 loop 的常量漂移:%d vs %d",
config.DefaultContextMaxTokens, loop.DefaultMaxContextTokens)
}
if got := (&app{cfg: cfg}).contextBudget(); got != loop.DefaultMaxContextTokens {
t.Fatalf("contextBudget() = %d,want %d", got, loop.DefaultMaxContextTokens)
}
// 显式取值原样透传;0 = 关闭。
cfg.Context.MaxTokens = 4096
if got := (&app{cfg: cfg}).contextBudget(); got != 4096 {
t.Fatalf("contextBudget() = %d,want 4096", got)
}
cfg.Context.MaxTokens = 0
if got := (&app{cfg: cfg}).contextBudget(); got != 0 {
t.Fatalf("contextBudget() = %d,want 0(关闭)", got)
}
}
// TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt 是真断言①:
// 预算为正且对话超出预算时,会话**真的被压缩**,且摘要消息进了模型请求。
func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: &config.Config{}}
a.cfg.Context.MaxTokens = 600 // 远小于 longConversation 的体量
msgs := longConversation()
req := runWithBudget(t, a.contextBudget(), msgs)
summaries := summaryMessages(req)
if len(summaries) != 1 {
t.Fatalf("请求里的摘要消息数 = %d(共 %d 条消息),want 1", len(summaries), len(req.Messages))
}
if summaries[0] != 1 {
t.Fatalf("摘要消息的位置 = %d,want 1(紧跟指令消息的近期窗口之前)", summaries[0])
}
if len(req.Messages) >= len(msgs)+1 {
t.Fatalf("请求消息数 = %d(喂入 %d 条),压缩没有生效", len(req.Messages), len(msgs))
}
// 近期尾部保留(压缩不是把历史全丢掉):最后一条用户消息仍在。
all := ""
for _, m := range req.Messages {
all += loopText(m)
}
if !strings.Contains(all, "第十二件事") {
t.Fatalf("最近一轮已被压掉:请求里找不到最后一轮用户消息")
}
}
// TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:预算为 0
// (关闭)时,行为与接线前完全一致 —— 喂进去的消息一条不少地进了请求,
// 且没有任何摘要消息。
func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: &config.Config{}} // Context.MaxTokens 零值 = 关闭
msgs := longConversation()
req := runWithBudget(t, a.contextBudget(), msgs)
if len(req.Messages) != len(msgs)+1 {
t.Fatalf("请求消息数 = %d,want %d(指令 + 全部喂入消息)", len(req.Messages), len(msgs)+1)
}
if summaries := summaryMessages(req); len(summaries) != 0 {
t.Fatalf("关闭压缩却出现了摘要消息:%v", summaries)
}
}
// TestWireRuntime_UsesTheCompositionRootSeams 是一道「接线存在性」防回归检查。
//
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此 P0-5 / P0-11 的
// 「组合根确实走了这两条缝」只能在源码层钉住:wire_runtime.go 必须调用
// a.buildGuardrailPipeline(),并且把 MaxContextTokens 取自 a.contextBudget()。
// 有人把这两行改回空管线 / 不设预算时,这里立刻红。
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
t.Parallel()
src, err := os.ReadFile("wire_runtime.go")
if err != nil {
t.Fatalf("read wire_runtime.go: %v", err)
}
for _, want := range []string{"a.buildGuardrailPipeline()", "a.contextBudget()"} {
if !strings.Contains(string(src), want) {
t.Fatalf("wire_runtime.go 里找不到 %q —— 组合根没有走这条缝", want)
}
}
}
// TestContextCompaction_HugeBudgetIsAlsoInert 是变异反证:同一段对话只把预算
// 放大到不可能超出的值,压缩同样不发生 —— 证明上一条的压缩确实由「预算」驱动,
// 而不是别的什么副作用。
func TestContextCompaction_HugeBudgetIsAlsoInert(t *testing.T) {
t.Parallel()
msgs := longConversation()
req := runWithBudget(t, 1<<30, msgs)
if len(req.Messages) != len(msgs)+1 {
t.Fatalf("巨大预算下请求消息数 = %d,want %d", len(req.Messages), len(msgs)+1)
}
if summaries := summaryMessages(req); len(summaries) != 0 {
t.Fatalf("巨大预算下不该出现摘要消息:%v", summaries)
}
}