package history import ( "context" "errors" "testing" ) // personaOf returns the stored role binding of a session, failing the test when the // session is absent from the list. 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 is design/22 acceptance 1: the session-level role is // the single source of truth, so a value written through SetSessionPersona must come // back from both the direct read and the list projection (the DTO the client gets), // and the main session — which always exists — starts out unbound ('' = inherit the // global default). An explicit "" clears the binding back to that same state. 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) } } // An explicit empty value is the documented "unbind" (follow the default again), // not a no-op. 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 covers the error contract: binding or reading the // role of a session that does not exist reports ErrSessionNotFound (so the HTTP layer // answers 404) instead of silently creating/looking up nothing. 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 locks the create path: a sub session created with a // role carries it from its first read (no extra PATCH needed), and the plain // NewSubSession still creates an unbound session — the pre-multi-character contract, // which every existing caller relies on. 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 proves design/22 §7-5's storage half: a session may // switch roles while keeping its context, so every message records the role that // SPOKE it. The column survives the write/read round trip through the same scanner // the REST/WS DTOs use, and messages written without a role stay '' (= the default // role), which is what keeps pre-upgrade history readable. 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) } // The persona column must also survive Recent (the WS replay path) and // AppendFull (the orchestrator's realtime-sync path). 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) } }