package loop import ( "context" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // This file locks the multi-character behavior of design/22 §4.2: prompt, memory // scope, emotion persona and reasoning knobs must all be resolved from the SAME // per-turn role, so a session can never answer with role Y's voice but role X's // memory (design/22 §5 风险表 "提示词/情绪/记忆错配"). // personaAgent builds an agent whose instructions name the role, so a test can tell // which pool entry the loop resolved from the leading system message. func personaAgent(t *testing.T, instructions string, ad model.Adapter) *agent.Agent { t.Helper() ag, err := agent.New(instructions, nil, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New(%q): %v", instructions, err) } return ag } // roleEnv is the environment a session resolver produces for personaID: full-access // owner identity (the identity lane is unchanged) plus the explicitly bound role. func roleEnv(personaID string) SessionEnv { return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: personaID} } // runPersonaTurn drives one turn for sessionID under env and fails the test on error. func runPersonaTurn(t *testing.T, l *Loop, env SessionEnv, sessionID, text string) *Result { t.Helper() res, err := l.Run(WithSessionEnv(context.Background(), env), sessionID, []model.Message{model.TextMessage(model.RoleUser, text)}) if err != nil { t.Fatalf("Run(%s): %v", sessionID, err) } return res } // lastChatRequest returns the newest request the non-streaming path recorded. func lastChatRequest(t *testing.T, ad *mockAdapter) model.Request { t.Helper() if len(ad.reqs) == 0 { t.Fatal("adapter recorded no request") } return ad.reqs[len(ad.reqs)-1] } // requestContains reports whether any message of req carries want. func requestContains(req model.Request, want string) bool { for _, m := range req.Messages { if strings.Contains(messageText(m), want) { return true } } return false } // TestTurnResolvesSystemPromptFromSessionRole is design/22 acceptance 3: switching a // session's role switches the identity the model is given. The first system message // must be the bound role's prompt — that is the whole point of the per-role agent // pool — while an unbound session and an unknown role both stay on the default role // instead of failing the turn. func TestTurnResolvesSystemPromptFromSessionRole(t *testing.T) { t.Parallel() ad := &mockAdapter{} x := personaAgent(t, "你是角色X。", ad) y := personaAgent(t, "你是角色Y。", ad) l := New(Options{ Agent: x, Agents: map[string]*agent.Agent{"X": x, "Y": y}, PersonaID: "X", }) runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "你好呀") req := lastChatRequest(t, ad) if got := messageText(req.Messages[0]); got != "你是角色Y。" { t.Fatalf("first system message = %q, want the bound role Y's prompt", got) } // A session with no explicit binding inherits the loop's configured default. runPersonaTurn(t, l, roleEnv(""), "web", "你好") if got := messageText(lastChatRequest(t, ad).Messages[0]); got != "你是角色X。" { t.Fatalf("unbound session prompt = %q, want the default role X's prompt", got) } // A binding to a role with no pooled agent (deleted/unknown persona) degrades to // the default agent rather than erroring. runPersonaTurn(t, l, roleEnv("nobody"), "web:z1", "你好") if got := messageText(lastChatRequest(t, ad).Messages[0]); got != "你是角色X。" { t.Fatalf("unknown role prompt = %q, want the default role X's prompt", got) } } // TestPersonaScopeMemoryIsolationAndExtraction is design/22 acceptance 2 and 5: the // memory namespace follows the turn's role, so two sessions bound to different roles // each recall only their own role's memories while two sessions of the SAME role see // the same memories (口径 1: memory is persona-scoped, not session-scoped); and the // extracted item is filed under the role that produced the turn — the regression // guard for the removal of the extractor's construction-time persona. func TestPersonaScopeMemoryIsolationAndExtraction(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) ad := &mockAdapter{} x := personaAgent(t, "你是角色X。", ad) y := personaAgent(t, "你是角色Y。", ad) l := New(Options{ Agent: x, Agents: map[string]*agent.Agent{"X": x, "Y": y}, PersonaID: "X", Memory: store, Retriever: memory.NewRetriever(store), // The extractor is built WITHOUT a persona, exactly as cmd/cyrene builds it: // the loop is responsible for stamping the turn's role onto each item. Extractor: memory.NewManualExtractor("", memoryOwnerRoleIDForTest), }) runPersonaTurn(t, l, roleEnv("X"), "web:x1", "记住 我喜欢喝乌龙茶") runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "记住 我讨厌香菜") // Acceptance 5: each extracted memory is filed under the role of its turn. byContent := map[string]string{} for _, it := range listMemories(t, store) { byContent[it.Content] = it.PersonaID } if got := byContent["我喜欢喝乌龙茶"]; got != "X" { t.Fatalf("memory extracted in an X turn is filed under %q, want X (items=%v)", got, byContent) } if got := byContent["我讨厌香菜"]; got != "Y" { t.Fatalf("memory extracted in a Y turn is filed under %q, want Y (items=%v)", got, byContent) } // Acceptance 2a: the X session recalls X's memory and never Y's. runPersonaTurn(t, l, roleEnv("X"), "web:x1", "乌龙茶怎么样") req := lastChatRequest(t, ad) if !requestContains(req, "我喜欢喝乌龙茶") { t.Fatalf("X's turn did not recall X's own memory: %+v", req.Messages) } if requestContains(req, "我讨厌香菜") { t.Fatalf("X's turn leaked Y's memory: %+v", req.Messages) } // Acceptance 2b: another session of the SAME role sees it too (memory is // persona-scoped, so a new session does not start from a blank memory). runPersonaTurn(t, l, roleEnv("X"), "web:x2", "乌龙茶怎么样") if !requestContains(lastChatRequest(t, ad), "我喜欢喝乌龙茶") { t.Fatalf("a second X session did not see X's memory: %+v", lastChatRequest(t, ad).Messages) } // Acceptance 2c: the Y session recalls only Y's memory. runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "香菜怎么样") req = lastChatRequest(t, ad) if !requestContains(req, "我讨厌香菜") { t.Fatalf("Y's turn did not recall Y's own memory: %+v", req.Messages) } if requestContains(req, "我喜欢喝乌龙茶") { t.Fatalf("Y's turn leaked X's memory: %+v", req.Messages) } } // memoryOwnerRoleIDForTest mirrors the composition root's authoring role id, so the // test builds the extractor the same way production does. const memoryOwnerRoleIDForTest = "owner" // listMemories returns every stored memory item. func listMemories(t *testing.T, store *memory.Store) []memory.MemoryItem { t.Helper() items, err := store.List(context.Background()) if err != nil { t.Fatalf("memory list: %v", err) } return items } // frameAdapter streams one thinking frame and one text frame, so a test can assert // both the request the loop built (thinking knobs) and what it broadcast // (showToUser gate) on the streaming path. type frameAdapter struct { mockAdapter } func (f *frameAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) { f.streamReqs = append(f.streamReqs, req) if on != nil { on(model.StreamEvent{Kind: model.StreamKindThinkingDelta, Delta: "想一下"}) on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "答案"}) } return model.Response{Content: "答案"}, nil } // TestThinkingKnobsFollowTheTurnRole locks the second half of design/22 §4.2: the // reasoning budget / effort / thinking-broadcast switch that batch A resolved once // at wiring time must now follow the turn's role, so a session bound to role Y runs // with Y's budget and Y's broadcast switch instead of the default role's. The // configured options stay the fallback for an unknown role. func TestThinkingKnobsFollowTheTurnRole(t *testing.T) { t.Parallel() ad := &frameAdapter{} x := personaAgent(t, "你是角色X。", ad) y := personaAgent(t, "你是角色Y。", ad) hide := false budgetX := &model.Thinking{BudgetTokens: 100} knobs := map[string]LoopKnobs{ "X": {Thinking: budgetX, ReasoningEffort: "high"}, "Y": {ThinkingShowToUser: &hide}, } var events []model.StreamEvent l := New(Options{ Agent: x, Agents: map[string]*agent.Agent{"X": x, "Y": y}, PersonaID: "X", Thinking: budgetX, ReasoningEffort: "high", OnStream: func(ev model.StreamEvent) { events = append(events, ev) }, PersonaKnobs: func(personaID string) (LoopKnobs, bool) { k, ok := knobs[personaID]; return k, ok }, }) // Role X: its budget reaches the request and its thinking frames are broadcast // (showToUser unset = the documented "broadcast as usual"). runPersonaTurn(t, l, roleEnv("X"), "web:x1", "你好") req := ad.streamReqs[len(ad.streamReqs)-1] if req.Thinking == nil || req.Thinking.BudgetTokens != 100 { t.Fatalf("X's request thinking = %+v, want budget 100", req.Thinking) } if req.ReasoningEffort != "high" { t.Fatalf("X's reasoning effort = %q, want high", req.ReasoningEffort) } if !hasThinkingFrame(events) { t.Fatalf("X's thinking frames were withheld: %+v", events) } // Role Y: Y configures no budget and hides thinking frames, so Y's request must // carry NO thinking directive (the default role's budget must not leak in) and // only the text frame may reach the client. events = nil runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "你好") req = ad.streamReqs[len(ad.streamReqs)-1] if req.Thinking != nil { t.Fatalf("Y's request thinking = %+v, want nil (Y configures none)", req.Thinking) } if req.ReasoningEffort != "" { t.Fatalf("Y's reasoning effort = %q, want empty", req.ReasoningEffort) } if hasThinkingFrame(events) { t.Fatalf("Y's thinking frames were broadcast despite showToUser=false: %+v", events) } if len(events) == 0 { t.Fatal("Y's text frames were suppressed too, want the answer to still stream") } // An unknown role keeps the loop's configured defaults instead of zeroing them. events = nil runPersonaTurn(t, l, roleEnv("nobody"), "web:z1", "你好") req = ad.streamReqs[len(ad.streamReqs)-1] if req.Thinking == nil || req.Thinking.BudgetTokens != 100 { t.Fatalf("unknown role thinking = %+v, want the configured default budget", req.Thinking) } if !hasThinkingFrame(events) { t.Fatalf("unknown role thinking frames were withheld: %+v", events) } } // hasThinkingFrame reports whether the broadcast set carried a reasoning frame. func hasThinkingFrame(events []model.StreamEvent) bool { for _, ev := range events { if isThinkingKind(ev.Kind) { return true } } return false }