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:")上。 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", // 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件 // **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」 // 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。 "a.seedMemoryTidyPanel(cfg)", "runtimeconfig.SeedMemoryTidy(", // 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」 // 的结构性保证,而不是靠自觉。 "Audit: a.auditMemoryTidy", }, "main.go": { "a.memoryTidy.Start(ctx)", }, // 到期清扫(切片 M3,design/23 §19 的第 ② / ③ 步):临时记忆到期后要 // **真删掉**,而它起在 TidyService.Start 里(与心跳同一个 cctx / wg)。 // 少了这一句,过期行只会被读侧时效闸「挡在门外」而**永远不删** —— // 不报错、不红任何别的用例,只是库里的临时层无界留存。因此这里把它 // 钉成 seam:`TestTidyServiceStart_清扫随服务起停` 是它的运行时对照。 "../../internal/runtime/memory/tidy_service.go": { "go s.expirySweepLoop(cctx)", }, "../../internal/runtime/memory/expire_loop.go": { "func (s *TidyService) expirySweepLoop(ctx context.Context)", "DefaultExpireSweepInterval", }, "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) } }