- 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`
229 lines
9.4 KiB
Go
229 lines
9.4 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|