package history import ( "context" "errors" "testing" ) // personaOf 返回某个会话已存储的角色绑定,会话不在列表中时使测试失败。 func personaOf(t *testing.T, st *Store, id string) string { t.Helper() sessions, err := st.ListSessions(context.Background()) if err != nil { t.Fatalf("list sessions: %v", err) } for _, m := range sessions { if m.ID == id { return m.PersonaID } } t.Fatalf("session %q not found in %+v", id, sessions) return "" } // TestSessionPersonaRoundTrip 是 design/22 验收 1:会话级角色是唯一事实来源, // 因此通过 SetSessionPersona 写入的值必须能从直接读取与列表投影(客户端拿到的 // DTO)两条路径读回,而始终存在的主会话初始为未绑定(空 = 继承全局默认)。 // 显式空串会把绑定清回同一状态。 func TestSessionPersonaRoundTrip(t *testing.T) { t.Parallel() st := newStore(t) ctx := context.Background() if got := personaOf(t, st, MainSessionID); got != "" { t.Fatalf("main session persona_id = %q, want '' (inherit the global default)", got) } if got, err := st.SessionPersona(ctx, MainSessionID); err != nil || got != "" { t.Fatalf("SessionPersona(main) = (%q, %v), want ('', nil)", got, err) } for _, personaID := range []string{"cyrene", "yuki"} { if err := st.SetSessionPersona(ctx, MainSessionID, personaID); err != nil { t.Fatalf("SetSessionPersona(%s): %v", personaID, err) } if got, err := st.SessionPersona(ctx, MainSessionID); err != nil || got != personaID { t.Fatalf("SessionPersona(main) = (%q, %v), want (%q, nil)", got, err, personaID) } if got := personaOf(t, st, MainSessionID); got != personaID { t.Fatalf("ListSessions persona_id = %q, want %q", got, personaID) } } // 显式空值是文档化的「解绑」(重新跟随默认),不是空操作。 if err := st.SetSessionPersona(ctx, MainSessionID, ""); err != nil { t.Fatalf("SetSessionPersona(\"\"): %v", err) } if got := personaOf(t, st, MainSessionID); got != "" { t.Fatalf("after unbind persona_id = %q, want ''", got) } } // TestSessionPersonaUnknownSession 覆盖错误契约:绑定或读取一个不存在的会话的 // 角色会报 ErrSessionNotFound(因此 HTTP 层回答 404),而不是悄悄创建/查不到 // 任何东西。 func TestSessionPersonaUnknownSession(t *testing.T) { t.Parallel() st := newStore(t) ctx := context.Background() if err := st.SetSessionPersona(ctx, "web:missing", "cyrene"); !errors.Is(err, ErrSessionNotFound) { t.Fatalf("SetSessionPersona(missing) err = %v, want ErrSessionNotFound", err) } if _, err := st.SessionPersona(ctx, "web:missing"); !errors.Is(err, ErrSessionNotFound) { t.Fatalf("SessionPersona(missing) err = %v, want ErrSessionNotFound", err) } } // TestNewSubSessionForBindsPersona 锁定创建路径:带角色创建的子会话从第一次读取 // 起就携带它(无需额外 PATCH),而朴素的 NewSubSession 仍创建未绑定会话 —— 即 // 多角色之前的契约,每个既有调用方都依赖它。 func TestNewSubSessionForBindsPersona(t *testing.T) { t.Parallel() st := newStore(t) ctx := context.Background() bound, err := st.NewSubSessionFor(ctx, "owner", "yuki") if err != nil { t.Fatalf("NewSubSessionFor: %v", err) } if got := personaOf(t, st, bound); got != "yuki" { t.Fatalf("bound sub persona_id = %q, want yuki", got) } plain, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("NewSubSession: %v", err) } if got := personaOf(t, st, plain); got != "" { t.Fatalf("plain sub persona_id = %q, want ''", got) } } // TestMessagePersonaPersisted 证明 design/22 §7-5 的存储一半:一个会话可以在保留 // 上下文的同时切换角色,因此每条消息都记录说出它的那个角色。该列通过 REST/WS // DTO 使用的同一个扫描器活过写入/读取往返,而未指定角色写入的消息保持为空 // (= 默认角色),这正是升级前历史仍可读的原因。 func TestMessagePersonaPersisted(t *testing.T) { t.Parallel() st := newStore(t) ctx := context.Background() if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "yuki 你好", PersonaID: "yuki"}); err != nil { t.Fatalf("append with persona: %v", err) } if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "在~"}); err != nil { t.Fatalf("append without persona: %v", err) } msgs, err := st.List(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list: %v", err) } if len(msgs) != 2 { t.Fatalf("listed %d messages, want 2", len(msgs)) } byRole := map[string]string{} for _, m := range msgs { byRole[m.Role] = m.PersonaID } if got := byRole[RoleUser]; got != "yuki" { t.Fatalf("user message persona_id = %q, want yuki", got) } if got := byRole[RoleAssistant]; got != "" { t.Fatalf("unattributed message persona_id = %q, want ''", got) } // persona 列还必须活过 Recent(WS 回放路径)与 AppendFull(编排器的实时 // 同步路径)。 recent, err := st.Recent(ctx, MainSessionID, 10) if err != nil { t.Fatalf("recent: %v", err) } found := false for _, m := range recent { if m.PersonaID == "yuki" { found = true } } if !found { t.Fatalf("Recent lost the message persona: %+v", recent) } full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "记住了", PersonaID: "yuki"}) if err != nil { t.Fatalf("append full: %v", err) } if full.PersonaID != "yuki" { t.Fatalf("AppendFull returned persona_id = %q, want yuki", full.PersonaID) } after, err := st.ListAfter(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list after: %v", err) } if len(after) == 0 || after[len(after)-1].PersonaID != "yuki" { t.Fatalf("ListAfter lost the message persona: %+v", after) } }