package backthink import ( "context" "errors" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // fakeRecent 是 recentProvider 的 stub,为 // 主会话语境读取返回固定的尾部/错误。 type fakeRecent struct { msgs []history.Message err error } func (f *fakeRecent) Recent(context.Context, string, int) ([]history.Message, error) { return f.msgs, f.err } // captureAdapter 是一个 model.Adapter,记录交给它的每个请求,好让 // 测试能对 loop 实际喂给模型的消息做断言。 type captureAdapter struct { reqs []model.Request } func (c *captureAdapter) Name() string { return "capture" } func (c *captureAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { c.reqs = append(c.reqs, req) return model.Response{Content: "结论:想陪她聊聊"}, nil } func (c *captureAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { return c.Chat(context.Background(), req, model.Options{}) } // newThinkLoop 在 ad 之上构建一个真实 loop,并带一条可用的手动记忆抽取 // 路径,好让测试能证明 owner 环境确实生效(extractMemory 只在 // MemWritable=true 时运行)。 func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) { t.Helper() a, err := agent.New("You are Cyrene.", nil, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } store := newMemoryStore(t) l := loop.New(loop.Options{ Agent: a, Memory: store, Extractor: memory.NewManualExtractor("persona-a", "role-a"), PersonaID: "persona-a", }) return l, store } // newMemoryStore 为单个测试打开一个隔离的内存记忆存储。 func newMemoryStore(t *testing.T) *memory.Store { t.Helper() d, err := db.Open(context.Background(), ":memory:", nil) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { _ = d.Close() }) s, err := memory.NewStore(context.Background(), d, discardLog()) if err != nil { t.Fatalf("memory.NewStore: %v", err) } return s } // modelText 返回一条 model 消息各文本片段拼接后的文本。 func modelText(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() } // TestGuidancePrompt_EncouragesProactiveWithoutSuppression 守护这次改写: // 引导必须去掉旧的抑制措辞,并推动温暖、有针对性的 // 主动表达(design/20 §2)。 func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) { t.Parallel() p := guidancePrompt("heartbeat") for _, banned := range []string{"Do not produce a chat-style message", "nothing to report"} { if strings.Contains(p, banned) { t.Fatalf("guidance prompt still suppresses: contains %q", banned) } } for _, want := range []string{"值得主动告诉", "温暖"} { if !strings.Contains(p, want) { t.Fatalf("guidance should encourage warm proactive expression, missing %q", want) } } } // TestMainContext_MapsRecentMessages 证明主会话最近尾部被 // 按序转换、角色被映射、图片片段被保留。 func TestMainContext_MapsRecentMessages(t *testing.T) { t.Parallel() prov := &fakeRecent{msgs: []history.Message{ {Role: history.RoleUser, Content: "今天有点累"}, {Role: history.RoleAssistant, Content: "我陪你"}, {Role: history.RoleUser, Content: "看这张图", Image: "data:image/png;base64,x"}, }} got := mainContext(context.Background(), prov) if len(got) != 3 { t.Fatalf("len = %d, want 3", len(got)) } if got[0].Role != model.RoleUser || got[0].Content[0].Text != "今天有点累" { t.Fatalf("msg[0] = %+v, want user '今天有点累'", got[0]) } if got[1].Role != model.RoleAssistant { t.Fatalf("msg[1].Role = %q, want assistant", got[1].Role) } if got[2].Content[1].Type != model.ContentTypeImage { t.Fatalf("msg[2] missing image part: %+v", got[2].Content) } } // TestMainContext_Degrades 确保提供者为 nil 或读取失败都得到 nil // 尾部,因此该次思考仅凭引导提示词运行,而不是失败。 func TestMainContext_Degrades(t *testing.T) { t.Parallel() if got := mainContext(context.Background(), nil); got != nil { t.Fatalf("nil provider should yield nil, got %+v", got) } if got := mainContext(context.Background(), &fakeRecent{err: errors.New("boom")}); got != nil { t.Fatalf("failed read should degrade to nil, got %+v", got) } } // TestThinkInput_AppendsGuidanceLast 验证拼装出的输入是最近 // 语境后跟单个引导回合;状态未知时提示词逐字不变(不注入状态块)。 func TestThinkInput_AppendsGuidanceLast(t *testing.T) { t.Parallel() in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat", RoundState{}) if len(in) != 2 { t.Fatalf("len = %d, want 2", len(in)) } if in[1].Role != model.RoleUser || !strings.Contains(modelText(in[1]), guidancePrompt("heartbeat")) { t.Fatalf("last message is not the guidance prompt: %+v", in[1]) } if strings.Contains(modelText(in[1]), "主人此刻的状态") { t.Fatalf("状态未知时不得注入状态块:%s", modelText(in[1])) } } // TestLoopThinker_Think_UsesMainContextAndOwnerEnv 驱动一次真实思考并 // 断言 design/20 P1 的三件事:模型看到了主会话的最近消息、 // 引导提示词不携带抑制措辞,且该次思考跑在 owner // 环境之下(手动抽取器只在 MemWritable=true 时持久化)。 func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) { ad := &captureAdapter{} l, store := newThinkLoop(t, ad) lt := NewLoopThinker(l, &fakeRecent{msgs: []history.Message{ {Role: history.RoleUser, Content: "记住 她喜欢冰美式"}, }}) text, err := lt.Think(context.Background(), "sess-backthink", "heartbeat") if err != nil { t.Fatalf("Think: %v", err) } if text != "结论:想陪她聊聊" { t.Fatalf("text = %q, want the adapter conclusion", text) } if len(ad.reqs) == 0 { t.Fatal("no model request captured") } last := ad.reqs[len(ad.reqs)-1] var sawContext, sawGuidance bool for _, m := range last.Messages { txt := modelText(m) if strings.Contains(txt, "冰美式") { sawContext = true } if strings.Contains(txt, "无人在旁") { sawGuidance = true } } if !sawContext { t.Fatalf("input missing the main-session recent message: %+v", last.Messages) } if !sawGuidance { t.Fatalf("input missing the guidance prompt: %+v", last.Messages) } // 已证明 owner 环境:ManualExtractor 只在 MemWritable=true 时持久化。 items, err := store.ListByPersona(context.Background(), "persona-a") if err != nil { t.Fatalf("ListByPersona: %v", err) } if len(items) == 0 { t.Fatal("owner env not in effect: no memory extracted (extractMemory runs only when MemWritable=true)") } if !strings.Contains(items[0].Content, "冰美式") { t.Fatalf("extracted content = %q, want the remembered fact", items[0].Content) } } // TestLoopThinker_NilHistoryDegrades 保持既有的背压行为:没有 // 历史提供者时该次思考仍会运行(只用引导提示词)且绝不 panic。 func TestLoopThinker_NilHistoryDegrades(t *testing.T) { ad := &captureAdapter{} l, _ := newThinkLoop(t, ad) lt := NewLoopThinker(l, nil) if _, err := lt.Think(context.Background(), "sess-backthink", "heartbeat"); err != nil { t.Fatalf("Think with nil history: %v", err) } if len(ad.reqs) == 0 { t.Fatal("no model request captured") } last := ad.reqs[len(ad.reqs)-1] // 没有历史提供者时,输入携带引导提示词、不含 // 主会话消息。(agent 会自行前置一条指令系统消息, // 所以我们断言存在性而非精确条数。) foundGuidance := false for _, m := range last.Messages { if strings.Contains(modelText(m), guidancePrompt("heartbeat")) { foundGuidance = true break } } if !foundGuidance { t.Fatalf("degraded think missing the guidance prompt: %+v", last.Messages) } }