Files
Cyrene_v2/internal/services/backthink/thinker_test.go
T
Aska 93aff2fea5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 自主思考强化 P1——主会话上下文+owner env+去压制(design/20)
backthink 从"心跳空转(独立sess-backthink+写死nothing to report宏)"改成"看得见主人、
能读写记忆、能调工具":think 读主会话最近20条(history.Recent,倒序映射)作为上下文 +
 重写引导prompt(温暖/有针对性/主动表达,删掉 Do not produce chat-style message) + 注入
 loop.OwnerEnv(MemWritable=true/ActorOwner/全工具)。
- context.go: mainContext/recentProvider/thinkInput/guidancePrompt
- thinker.go: LoopThinker 加 recent;Think 读主会话上下文+OwnerEnv+MutedBroadcast
- wire.go: NewLoopThinker(a.loop, a.history)
- 刻意偏离(安全): loop.Run session id 仍用 sess-backthink(不写"web"),避免与用户实时回合
  checkpoint/中断窗口复用污染;上下文来源+运行环境=主会话/owner env,目标达成。红线靠actor元数据。
- 测试: prompt无压制/有鼓励;主会话消息映射;nil退化;端到端Think(owner env 生效)
验收: build/vet/test 全绿+windows/darwin交叉编译绿. P2(主动推送+渠道选择)后置.
2026-09-07 19:21:09 +08:00

238 lines
8.0 KiB
Go

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 is a recentProvider stub returning a fixed tail/error for the
// main-session context read.
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 is a model.Adapter that records every request it is handed so a
// test can assert on the messages the loop actually fed to the model.
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 builds a real loop over ad with a working manual memory-extraction
// path so a test can prove the owner environment is in effect (extractMemory runs
// only when 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 opens an isolated in-memory memory store for one test.
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 returns the concatenated text of a model message's text parts.
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 guards the rewrite:
// the guidance must drop the old suppression phrases and push warm, targeted
// proactive expression (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 proves the recent main-session tail is
// converted in order with roles mapped and image parts preserved.
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 ensures a nil provider or a failed read yields a nil
// tail so the think runs on the guidance prompt alone rather than failing.
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 verifies the assembled input is the recent
// context followed by a single guidance turn.
func TestThinkInput_AppendsGuidanceLast(t *testing.T) {
t.Parallel()
in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat")
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])
}
}
// TestLoopThinker_Think_UsesMainContextAndOwnerEnv drives a real think and asserts
// three things from design/20 P1: the model saw the main-session recent message,
// the guidance prompt carries no suppression, and the think ran under the owner
// environment (the manual extractor persists only when 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 env proved: ManualExtractor only persists under 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 keeps the pre-existing backpressure: with no
// history provider the think still runs (guidance prompt only) and never panics.
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]
// With no history provider the input carries the guidance prompt and no
// main-session messages. (The agent prepends its own instruction system message,
// so we assert presence rather than an exact count.)
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)
}
}