- 缺口 d:状态在**思考前**读一次,同一份快照既喂思考轮又喂 Decide(读不到 = Known=false,绝不伪装成中性状态) - 缺口 c:新增只读工具 capability_list(owner-only、三重输出边界、读写隔离),她可自查当下真实可用面 - 缺口 b:内置允许集补 transcode / capability_list(此前 CAPACITY_ENABLED=true 时新工具会从模型可见面消失) - 缺口 e:guidancePrompt 旧文案承诺的「IoT/日历/提醒」在全仓根本没有对应工具(chat 侧也没有)⇒ 按诚实化改为「与主人对话同等工具面 + 指名自查入口」,不再有空头支票 - (a) 未做且**不自创**:那三把工具无任何设计规格(名字/schema/读写边界/输出上限)⇒ 已报回并登记进 design/20(裁决:走能力/插件/MCP 暴露路线) - 变异实测:允许集去掉新工具 ⇒ TestDecide_AllowedSets + 组合根跨层测试红;丢掉状态快照 ⇒ TestLoopThinker_Think_InjectsRoundStateBlock 红 - 已知弱项(如实登记):组合根那行 wireCapabilitySurface() 调用本身无测试覆盖(与既有 wireTranscode 同惯例)
241 lines
8.2 KiB
Go
241 lines
8.2 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 是 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)
|
|
}
|
|
}
|