按 DevDocs/design/22 批次 1 后端(§2/§3/§4.1-4.5/§4.7/§5 验收 1-6)。
- 迁移 0011:chat_sessions.persona_id + chat_messages.persona_id(均 DEFAULT '',无回填 → 升级零风险);同步包内幂等 DDL;**真实库升级验证**(拷贝 data/cyrene.db 到临时目录跑 MigrateUp:既有 1 会话/109 消息 persona 仍为 '',原库未被触碰)
- history:SessionMeta/Message 加 PersonaID + SetSessionPersona;ListSessions/List/scan/Append 带上
- runtime:SessionEnv.PersonaID + loop.personaFor(ctx) 替换固定 l.personaID(memory/request/turn)+ **agent 池**(Options.Agents,按角色取 instructions,未命中回落)+ **提取器不再把 persona 烤死在构造期**(多角色下记忆不再永远记在 cyrene 名下)+ **thinking 旋钮改为按会话角色解析**(否则角色 Y 会用角色 X 的预算/展示)
- gateway:会话 DTO/POST/PATCH 支持 personaId;ws/rest/voice 的 OwnerEnv 硬编改为**按会话解析角色**(actor 元数据语义不变);admin memories 搜索去掉硬编 cyrene
- Docs/api-client v0.42;**两处有意的行为收紧**已写明:POST /sessions body 非法 JSON → 400(原先完全忽略 body;空/null/{} 仍 201);POST /admin/memories/search 省略 personaId 且无默认角色 → 400(原先默认查 cyrene)
- 测试:design/22 §5 验收 1-6(记忆隔离/提示词首条 system/情绪按角色/提取 persona 归属/迁移兼容)+ 进程内 e2e(断言 adapter 收到的 model.Request.Messages[0] 即该角色的 systemPrompt)
- **已知未做(留批次 2 / 待确认)**:① IM 与渠道级未做 → **§7-3「owner 私聊镜像随主会话角色」尚未生效**;② 请求级覆盖未实现(本批只做会话级);③ **§7-5 的「上下文发送角色标记」只完成存储+DTO 半边**——消息 persona 已落库且 DTO 已带,但模型上下文不从 chat_messages 重建,「第 N 条是谁说的」尚未进上下文(需确认是否属批次 2);④「切角色重建摘要」当前是空操作(压缩未接线);⑤ thinking 旋钮仅在流式路径生效(非流式 agent.RunStep 不带,A 批次既有性质)
- 验收:go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译(既有 flake 本轮未触发;同批 session_orchestrator.go 一度 501 行 → 抽出 session_tail.go)
278 lines
11 KiB
Go
278 lines
11 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/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
|
|
}
|