Files
Cyrene_v2/internal/runtime/loop/memory_writeback_test.go
T
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
2026-10-04 14:37:37 +08:00

229 lines
9.4 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"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestRun_WorkingMemory_WriteOnSettle_回合落定写入工作记忆 是审计 P0-4 的
// 主断言:跑完一个回合后,该会话的工作记忆窗口非空、摘要非空,且
// assembleContext 的读点(loop/memory.go)真的把它作为前导块并入 ——
// 接线之前生产代码从不调用 Add,窗口恒空、块永不出现。
func TestRun_WorkingMemory_WriteOnSettle_回合落定写入工作记忆(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
wm := memory.NewWorkingMemory(0)
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{
Agent: a, Memory: store, WorkingMemory: wm,
PersonaID: "persona-a", MaxRounds: 8,
})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
msgs := []model.Message{model.TextMessage(model.RoleUser, "帮我查一下资料")}
if _, err := l.Run(ctx, "web:wm", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
win := wm.SessionWindow("web:wm")
if len(win) == 0 {
t.Fatal("工作记忆会话窗口仍为空:回合落定没有写入(P0-4 未修)")
}
if win[0].Content[0].Text != "帮我查一下资料" {
t.Fatalf("窗口首条 = %q, want 本轮用户消息", win[0].Content[0].Text)
}
if got := wm.SummarizeSession("web:wm"); got == "" {
t.Fatal("SummarizeSession 仍为 \"\":读点拿不到内容")
}
// 读点:下一次组装把工作记忆并入前导块(此前该块永不出现)。
// 组装在生产路径上总带着该 Run 的会话 id(Run 顶部 withLoopSession),
// 这里同样把会话挂上,免得读到别的会话的窗口。
view := l.assembleContext(withLoopSession(ctx, "web:wm"), msgs, 1)
if len(view) != 2 {
t.Fatalf("assemble = %d 条, want 2(工作记忆前导块 + 对话)", len(view))
}
if head := messageText(view[0]); !strings.Contains(head, "工作记忆") {
t.Fatalf("assemble 首条不是工作记忆块: %q", head)
}
// 会话恢复:同一会话再来一个回合(携带完整历史)时窗口被回填,
// 且不与其他会话/默认窗口混同。
wm.RecordSession("web:other", []model.Message{model.TextMessage(model.RoleUser, "另一个会话")})
if got := wm.SummarizeSession("web:wm"); strings.Contains(got, "另一个会话") {
t.Fatalf("会话隔离被破坏: %q", got)
}
if got := wm.Summarize(); got != "" {
t.Fatalf("默认窗口被回合写入污染: %q", got)
}
}
// TestRun_IntentWriteback_OwnerLearns_闭环生效 是审计 P0-17 的主断言:
// owner 会话跑完一个任务回合后,L4 判定被**真实写回记忆**,并且这条
// 记忆能被 L4 读回 —— 一个自身没有强信号的后续表达因此被唤醒。
// 对照(变异反证):不喂召回的同一判定保持静息。
func TestRun_IntentWriteback_OwnerLearns_闭环生效(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
ret := memory.NewRetriever(store)
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{
Agent: a, Memory: store, Retriever: ret, Capacity: dec,
PersonaID: "persona-a", MaxRounds: 8,
})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
if _, err := l.Run(ctx, "web:l4", []model.Message{model.TextMessage(model.RoleUser, "帮我搞一下那个部署")}); err != nil {
t.Fatalf("Run: %v", err)
}
items, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 1 {
t.Fatalf("回合落定产出 %d 条记忆, want 1(L4 判定回写,P0-17 未修)", len(items))
}
record := items[0]
if !strings.HasPrefix(record.Content, "intent:") {
t.Fatalf("回写内容 = %q, want capacity.RecordIntent 的规范格式", record.Content)
}
if record.Scope != memory.ScopeUser {
t.Fatalf("回写作用域 = %q, want user(用户习惯域,L4 的读域)", record.Scope)
}
if record.PersonaID != "persona-a" {
t.Fatalf("回写角色 = %q, want persona-a", record.PersonaID)
}
if _, ok := memory.ParseSourceConversation(record.SourceConversation); !ok {
t.Fatalf("回写溯源 = %q, want 结构化 session/turn 溯源", record.SourceConversation)
}
// 闭环:这条记忆被真实召回后,L4 唤醒一个自身无强信号的表达。
const probe = "那个部署怎么样了"
recall := l.capacityRecall(ctx, []model.Message{model.TextMessage(model.RoleUser, probe)})
if len(recall) == 0 {
t.Fatal("写回的记忆没有被检索召回,闭环不成立")
}
without := dec.Decide(ctx, capacity.Input{Messages: []string{probe}})
if without.State != capacity.StateQuiet {
t.Fatalf("前提不成立:探针文本自身已有强信号(state=%q)", without.State)
}
with := dec.Decide(ctx, capacity.Input{Messages: []string{probe}, Recall: recall})
if with.State == capacity.StateQuiet {
t.Fatalf("L4 未从写回的记忆中学到习惯:state=%q(want 唤醒)", with.State)
}
}
// TestRun_MemoryHooks_RestrictedSessionsSkip_受限会话零写入 是 P0-4/P0-17
// 的红线反证:群聊与陌生人会话(MemWritable=false)既不写共享记忆、
// 也不落任何工作记忆,且别的主人的会话窗口绝不泄露到这些上下文里。
func TestRun_MemoryHooks_RestrictedSessionsSkip_受限会话零写入(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
wm := memory.NewWorkingMemory(0)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
ad := &mockAdapter{}
a := newTestAgent(t, ad, nil)
l := New(Options{
Agent: a, Memory: store, Retriever: memory.NewRetriever(store), Capacity: dec,
WorkingMemory: wm, PersonaID: "persona-a", MaxRounds: 8,
})
// owner 会话先写出一条意图记录与一个工作记忆窗口。
ownerCtx := WithSessionEnv(context.Background(), OwnerEnv())
if _, err := l.Run(ownerCtx, "web:owner", []model.Message{model.TextMessage(model.RoleUser, "帮我搞一下那个部署")}); err != nil {
t.Fatalf("owner Run: %v", err)
}
ownerItems, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(ownerItems) != 1 {
t.Fatalf("owner 回合写入 %d 条, want 1", len(ownerItems))
}
restricted := []struct {
name string
env SessionEnv
}{
{"group", SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}},
{"stranger", SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}},
}
// 主人私有的意图记录(含原话)落在共享的用户习惯域以便 L4 闭环,
// 但绝不能被群聊/陌生人读走:受限块里只允许出现非敏感共享记忆。
seedIntentAndHabit(t, store)
before, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
for _, tc := range restricted {
ctx := WithSessionEnv(context.Background(), tc.env)
if _, err := l.Run(ctx, "sess-"+tc.name, []model.Message{model.TextMessage(model.RoleUser, "帮我搞一下那个部署")}); err != nil {
t.Fatalf("%s Run: %v", tc.name, err)
}
if got := len(wm.SessionWindow("sess-" + tc.name)); got != 0 {
t.Fatalf("%s 会话写了工作记忆: %d 条(只读隔离被破坏)", tc.name, got)
}
// 主人会话的窗口绝不进入受限上下文的前导块。
view := l.assembleContext(ctx, []model.Message{model.TextMessage(model.RoleUser, "go")}, 1)
// 正向对照:非敏感共享记忆(用户习惯)确实注入,证明这个块不是
// 空的 —— 因此下面的「不出现」是真过滤,而不是什么都没注入。
block := ""
for _, m := range view {
block += messageText(m) + "\n"
}
if !strings.Contains(block, "喜欢手冲咖啡") {
t.Fatalf("%s 上下文缺少非敏感共享记忆(测试前提不成立):\n%s", tc.name, block)
}
for _, m := range view {
text := messageText(m)
if strings.Contains(text, "工作记忆") {
t.Fatalf("%s 上下文泄露了工作记忆块: %q", tc.name, text)
}
if strings.Contains(text, "intent:") {
t.Fatalf("%s 上下文泄露了主人的 L4 意图记录(原话): %q", tc.name, text)
}
if strings.Contains(text, "帮我搞一下那个部署") {
t.Fatalf("%s 上下文泄露了主人原话: %q", tc.name, text)
}
}
}
after, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(after) != len(before) {
t.Fatalf("受限会话写入了记忆: %d 条 -> %d 条(只读隔离被破坏)", len(before), len(after))
}
}
// seedIntentAndHabit 往共享域里放一条 L4 意图记录(主人原话)与一条
// 普通用户习惯记忆:受限上下文只允许后者出现。
func seedIntentAndHabit(t *testing.T, store *memory.Store) {
t.Helper()
seeds := []memory.MemoryItem{
{
PersonaID: "persona-a", Scope: memory.ScopeUser, Kind: memory.KindRelationship,
Content: "intent:isTask=true;taskKind=ops;confidence=0.9;text=帮我搞一下那个部署",
},
{
PersonaID: "persona-a", Scope: memory.ScopeUser, Kind: memory.KindRelationship,
Content: "用户习惯:喜欢手冲咖啡",
},
}
for _, it := range seeds {
if _, err := store.Add(context.Background(), it); err != nil {
t.Fatalf("seed memory: %v", err)
}
}
}