Files
Cyrene_v2/cmd/cyrene/wire_memory_tidy_test.go
T
AskaEth d680390300 feat(memory): 整理频率走面板配置(面板 > env > 默认)+ 分时段动态间隔
主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。

- memory-tidy.json 新增四键:intervalSec / quietIntervalSec / quietStartHour /
  quietEndHour(*int + 指针判定 => 显式 0 是合法值、缺位才回落)
- 优先级逐键三档:面板 > env(既有键,未增删)> 代码默认;坏值不覆盖 + WARN,
  且不启动失败
- 动态间隔由纯函数 intervalAt(at) 决定,调度侧每一拍现算 => 跑完按当时时段重算
- 🔴 语义变更(有意,代码注释已登记,待补设计修订):安静时段从「唯一可跑时段」
  变成「降频时段」—— 旧的「安静窗外一律 continue」被删,否则 intervalSec 永远
  是空壳(白天唤醒必被跳过);窗口仍决定档位与补跑判据
- 6 个变异(含「加回旧的那道门」=> 端到端用例红)
2026-10-07 19:21:32 +08:00

323 lines
13 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"
"database/sql"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理(design/23 §4.1)在**组合根**上的三件事:
// 1. 跑在专属隔离会话,绝不是主人的对话车道(isConversationLane 对它恒 false);
// 2. 接线存在(服务被装配、被启动、被停止,env 键同步),少一处就会静默不工作;
// 3. 「绝不碰主会话上下文」是**可验证**的:跑完一轮整理后,主会话的消息条数、
// 最后一条消息、已读游标逐字不变,且对 chat_messages 零写入。
// tidyTestLog 返回不写任何地方的 logger,保持测试输出干净。
func tidyTestLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// TestMemoryTidySessionIsNotConversationLane 是 design/23 §4.1 B 的对外断言:
// 整理会话不落在主人的对话车道("web" / "web:<uuid>")上。
func TestMemoryTidySessionIsNotConversationLane(t *testing.T) {
t.Parallel()
if isConversationLane(memory.TidySessionID) {
t.Fatalf("整理会话 %q 被判成主人对话回合(会改写她的心情、进主会话泳道)", memory.TidySessionID)
}
// 对照组:主人车道本身必须仍然为 true(避免把断言写成「函数恒 false」)。
if !isConversationLane(history.MainSessionID) || !isConversationLane(history.MainSessionID+":sub-1") {
t.Fatal("主人车道判定被改坏了")
}
}
// TestMemoryTidyIsWired 是接线存在性防回归:组合根少任何一处,整理都会**静默
// 不工作**(不报错、不产候选、日志里一句都没有),而这不会让别的用例变红。
func TestMemoryTidyIsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// 记忆栈建好后就装配整理(它只需要 store + embedder)。
"a.wireMemoryTidy()",
"a.embedder = embedder",
},
"wire_memory_tidy.go": {
"memory.NewTidyService(cfg, memory.TidyDeps{",
// 调优(自动落地开关 + 每轮上限)从 $DATA_DIR/config/memory-tidy.json
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
"runtimeconfig.LoadMemoryTidyTuning",
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
// 的结构性保证,而不是靠自觉。
"Audit: a.auditMemoryTidy",
},
"main.go": {
"a.memoryTidy.Start(ctx)",
},
"wire_services.go": {
// 关库之前先停整理(它每轮要用连接池)。
"a.memoryTidy.Stop(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 记忆整理没有被接上组合根", file, want)
}
}
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{
"MEMORY_TIDY_ENABLED=", "MEMORY_TIDY_INTERVAL_SEC=", "MEMORY_TIDY_QUIET_START_HOUR=",
"MEMORY_TIDY_QUIET_END_HOUR=", "MEMORY_TIDY_RECALL_THRESHOLD=",
"MEMORY_TIDY_SCAN_LIMIT=", "MEMORY_TIDY_CANDIDATE_LIMIT=",
} {
if !strings.Contains(string(raw), key) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10 协同 E6)", key)
}
}
}
// TestMemoryTidyRunLeavesChatHistoryUntouched 是 design/23 §4.1 E 的可验证版本:
// 整理跑完一轮后,主会话的消息条数、最后一条消息、read_at 游标**逐字不变**,
// 且 chat_messages 里没有任何属于整理会话的行。
//
// 用例让整理真的干活(有可合并的一对),否则断言会是空的。
func TestMemoryTidyRunLeavesChatHistoryUntouched(t *testing.T) {
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
hist, err := history.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("history store: %v", err)
}
if _, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
}); err != nil {
t.Fatalf("append user message: %v", err)
}
last, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
})
if err != nil {
t.Fatalf("append assistant message: %v", err)
}
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
t.Fatalf("mark read: %v", err)
}
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
seed := []string{"主人常在深夜与助手聊天,有熬夜习惯", "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"}
for i, content := range seed {
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9,
CreatedAt: time.UnixMilli(1_700_000_000_000 + int64(i)*60_000),
}); err != nil {
t.Fatalf("seed memory %d: %v", i, err)
}
}
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(config.MemoryTidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 100, CandidateLimit: 20,
}), memory.TidyDeps{Store: store, Log: tidyTestLog()})
if err != nil {
t.Fatalf("tidy service: %v", err)
}
stats, err := svc.RunOnce(ctx)
if err != nil {
t.Fatalf("tidy RunOnce: %v", err)
}
if stats.Scanned != 2 || stats.Candidates == 0 {
t.Fatalf("整理没真干活,断言会落空:%+v", stats)
}
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
if before != after {
t.Fatalf("整理改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
}
if after.lastContent != last.Content || after.lastID != last.ID {
t.Fatalf("主会话最后一条消息变了:%+v", after)
}
var tidyMsgs int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&tidyMsgs); err != nil {
t.Fatalf("count tidy messages: %v", err)
}
if tidyMsgs != 0 {
t.Fatalf("整理向 chat_messages 写入了 %d 行(design §4.1 D 要求零写入)", tidyMsgs)
}
}
// conversationFingerprint 是主会话的「逐字不变」指纹:消息条数、最后一条消息
// 的 id 与正文、以及会话已读游标。
type conversationFingerprint struct {
messages int
lastID string
lastContent string
readAt int64
}
// readConversationFingerprint 直接读 chat_messages / chat_sessions(主会话只由
// 消息写入路径触碰,因此这是「整理有没有碰会话」最直接的证据)。
func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB, sessionID string) conversationFingerprint {
t.Helper()
var fp conversationFingerprint
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", sessionID).Scan(&fp.messages); err != nil {
t.Fatalf("count messages: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT id, content FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
sessionID).Scan(&fp.lastID, &fp.lastContent); err != nil {
t.Fatalf("read last message: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT read_at FROM chat_sessions WHERE id = ?", sessionID).Scan(&fp.readAt); err != nil {
t.Fatalf("read cursor: %v", err)
}
return fp
}
// TestApplyMemoryTidyTuning_ReadsDataDirConfig 钉住「自动档开关确实来自
// $DATA_DIR/config/memory-tidy.json」这条接线:它没有 env 键(`.env` 已冻结),
// 因此若组合根没读那个文件,「一键回到只产建议」就是一句空话 —— 文件写了没人看。
func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
// ① 文件不存在 ⇒ 保持代码默认(自动档开着)。
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
cfg := memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
empty.applyMemoryTidyTuning(&cfg)
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
t.Fatalf("没配过时 cfg = %+v, want 保持零值(由 normalize 填代码默认)", cfg)
}
// ② 写 {"autoApply": false, "autoApplyPerRound": 5} ⇒ 关掉 + 抬高上限。
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
if err := os.WriteFile(path, []byte(`{"autoApply": false, "autoApplyPerRound": 5}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
off.applyMemoryTidyTuning(&cfg)
if !cfg.DisableAutoApply || cfg.AutoApplyPerRound != 5 {
t.Fatalf("cfg = %+v, want DisableAutoApply=true 且 AutoApplyPerRound=5", cfg)
}
// ③ 坏文件 ⇒ 记 WARN 并回落代码默认(绝不把自动档读成「无限制」或「关掉」)。
if err := os.WriteFile(path, []byte(`{"autoApply": "nope"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
broken.applyMemoryTidyTuning(&cfg)
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
}
}
// TestApplyMemoryTidyTuning_ScheduleKnobs 钉住**整理频率**四键在组合根上的接线:
// 面板($DATA_DIR/config/memory-tidy.json)优先、`.env` 兜底 —— 主人 2026-10-07
// 口径「.env 只做兜底用 …… 这些键优先从面板配置上取」。
//
// 这里只断言「叠完之后 cfg 里是什么」,不重复 memory 包的优先级链用例:组合根这一层
// 要证明的是**那个文件真的被读了、真的落到了 cfg 上**(少这一句,面板改了频率也无效,
// 而不会有任何用例变红)。
func TestApplyMemoryTidyTuning_ScheduleKnobs(t *testing.T) {
t.Parallel()
// env 层:与面板值故意对立。
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
newApp := func(dataDir string) *app {
return &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
}
apply := func(dataDir string) memory.TidyConfig {
cfg := memory.TidyConfigFromConfig(env)
newApp(dataDir).applyMemoryTidyTuning(&cfg)
return cfg
}
// ① 面板四键都写了 ⇒ 四个都用面板值(覆盖 `.env`)。
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
body := `{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := apply(dataDir)
if cfg.IntervalSec != 3600 {
t.Fatalf("intervalSec = %d, want 3600(面板优先)", cfg.IntervalSec)
}
if cfg.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %d, want 10800(面板优先)", cfg.QuietIntervalSec)
}
if cfg.QuietStartHour != 1 || cfg.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00(面板优先)",
cfg.QuietStartHour, cfg.QuietEndHour)
}
// ② 文件不存在 ⇒ 四键保持 `.env` 值(面板只是「能覆盖」,不是「必须有」)。
empty := t.TempDir()
cfg = apply(empty)
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("没配过时应保持 env 值,得到 %+v", cfg)
}
// ③ 坏文件(类型写错)⇒ 整份回落 `.env`,且**不启动失败**:组合根照旧拿到
// 可用的 cfg(一个坏键不得让整理停摆)。
if err := os.WriteFile(path, []byte(`{"intervalSec": "3600"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
cfg = apply(dataDir)
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("坏文件时应回落 env 值,得到 %+v", cfg)
}
if cfg.QuietIntervalSec != 0 {
t.Fatalf("坏文件时 quietIntervalSec 应当是零值(由 normalize 取 = intervalSec),得到 %d",
cfg.QuietIntervalSec)
}
}