packages feed

langchain-hs-0.0.6.0: test/Test/Langchain/Memory/Core.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.Langchain.Memory.Core (tests) where

import Control.Concurrent.Async (forConcurrently_)
import Control.Monad.Except (runExceptT)
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Tasty
import Test.Tasty.HUnit

import Langchain.Memory
import Langchain.Model
  ( assistantMessage
  , extractMessageText
  , systemMessage
  , userMessage
  )

tests :: TestTree
tests =
  testGroup
    "Langchain.Memory.Core Tests"
    [ utilityTests
    , windowBufferMemoryTests
    , conversationBufferMemoryTests
    , persistenceTests
    , sessionMemoryTests
    , entityMemoryTests
    , combinedMemoryTests
    , concurrencyTests
    ]

utilityTests :: TestTree
utilityTests =
  testGroup
    "Utility Functions Tests"
    [ testCase "initialMessages creates list with a single system message" $ do
        let result = initialMessages "You are a helpful assistant"
        length result @?= 1
        case result of
          (m : _) -> m @?= systemMessage "You are a helpful assistant"
          [] -> assertFailure "Expected non-empty list"
    , testCase "trimMessages keeps last n messages (non-system)" $ do
        let msgs =
              [ systemMessage "System"
              , userMessage "User1"
              , assistantMessage "AI1"
              , userMessage "User2"
              ]
            trimmed = trimMessages 2 msgs
        trimmed @?= [assistantMessage "AI1", userMessage "User2"]
    , testCase "formatHistory formats roles and messages with default labels" $ do
        let msgs = [userMessage "Hi", assistantMessage "Hello!"]
        formatHistory msgs @?= "Human: Hi\nAI: Hello!"
    ]

windowBufferMemoryTests :: TestTree
windowBufferMemoryTests =
  testGroup
    "WindowBufferMemory Tests"
    [ testCase "messages returns current messages" $ do
        let initialMsgs = [systemMessage "System"]
        memory <- newWindowBufferMemory 3 initialMsgs
        res <- runExceptT $ messages memory
        case res of
          Left err -> assertFailure $ "Expected Right but got Left: " ++ show err
          Right msgs -> msgs @?= initialMsgs
    , testCase "addMessage adds message when under capacity" $ do
        let initialMsgs = [systemMessage "System"]
        memory <- newWindowBufferMemory 3 initialMsgs
        res <- runExceptT $ do
          addMessage memory (userMessage "User1")
          messages memory
        case res of
          Left err -> assertFailure $ "Expected Right but got Left: " ++ show err
          Right msgs -> msgs @?= [systemMessage "System", userMessage "User1"]
    , testCase "addMessage trims oldest non-system message when at capacity" $ do
        let initialMsgs =
              [ systemMessage "System"
              , userMessage "User1"
              , assistantMessage "AI1"
              ]
        memory <- newWindowBufferMemory 3 initialMsgs
        res <- runExceptT $ do
          addMessage memory (userMessage "User2")
          messages memory
        case res of
          Left err -> assertFailure $ "Expected Right but got Left: " ++ show err
          Right msgs ->
            msgs @?= [systemMessage "System", assistantMessage "AI1", userMessage "User2"]
    , testCase "clear resets to default system message" $ do
        let initialMsgs =
              [ systemMessage "System"
              , userMessage "User1"
              , assistantMessage "AI1"
              ]
        memory <- newWindowBufferMemory 3 initialMsgs
        res <- runExceptT $ do
          clear memory
          messages memory
        case res of
          Left err -> assertFailure $ "Expected Right but got Left: " ++ show err
          Right msgs -> do
            length msgs @?= 1
            case msgs of
              (m : _) -> m @?= systemMessage "You are a helpful AI assistant"
              [] -> assertFailure "Expected non-empty messages"
    , testCase "resetWindowBuffer restores initial seeded messages" $ do
        let initialMsgs = [systemMessage "Initial system prompt"]
        memory <- newWindowBufferMemory 3 initialMsgs
        res <- runExceptT $ do
          saveContext memory "User1" "AI1"
          saveContext memory "User2" "AI2"
          resetWindowBuffer memory
          messages memory
        case res of
          Left err -> assertFailure $ "Failed: " ++ show err
          Right msgs -> msgs @?= initialMsgs
    ]

conversationBufferMemoryTests :: TestTree
conversationBufferMemoryTests =
  testGroup
    "ConversationBufferMemory Tests"
    [ testCase "retains unbounded conversation turns" $ do
        mem <- newConversationBufferMemory [systemMessage "System prompt"]
        res <- runExceptT $ do
          saveContext mem "User Turn 1" "AI Turn 1"
          saveContext mem "User Turn 2" "AI Turn 2"
          saveContext mem "User Turn 3" "AI Turn 3"
          messages mem
        case res of
          Left err -> assertFailure $ "Failed: " ++ show err
          Right msgs -> length msgs @?= 7 -- 1 system + 6 turns
    , testCase "loadMemoryVariables produces formatted history map" $ do
        mem <- newConversationBufferMemory []
        res <- runExceptT $ do
          saveContext mem "Hello" "Hi there!"
          loadMemoryVariables mem
        case res of
          Left err -> assertFailure $ "Failed: " ++ show err
          Right vars -> Map.lookup "history" vars @?= Just "Human: Hello\nAI: Hi there!"
    , testCase "resetConversationBuffer restores initial seeded messages" $ do
        let initialMsgs = [systemMessage "Base prompt"]
        mem <- newConversationBufferMemory initialMsgs
        res <- runExceptT $ do
          saveContext mem "Hello" "World"
          resetConversationBuffer mem
          messages mem
        case res of
          Left err -> assertFailure $ "Failed: " ++ show err
          Right msgs -> msgs @?= initialMsgs
    ]

persistenceTests :: TestTree
persistenceTests =
  testGroup
    "Persistence Tests"
    [ testCase "saveMemoryToFile and loadMemoryFromFile preserves full conversation history" $ do
        withSystemTempDirectory "memory-test" $ \tmpDir -> do
          let filePath = tmpDir </> "conversation.json"
          mem1 <- newConversationBufferMemory [systemMessage "Assistant system"]
          res1 <- runExceptT $ do
            saveContext mem1 "What is Haskell?" "A purely functional programming language."
            saveMemoryToFile filePath mem1

          case res1 of
            Left err -> assertFailure $ "Save failed: " ++ show err
            Right () -> do
              mem2 <- newConversationBufferMemory []
              res2 <- runExceptT $ do
                loadMemoryFromFile filePath mem2
                messages mem2
              case res2 of
                Left err -> assertFailure $ "Load failed: " ++ show err
                Right msgs -> do
                  length msgs @?= 3
                  msgs
                    @?= [ systemMessage "Assistant system"
                        , userMessage "What is Haskell?"
                        , assistantMessage "A purely functional programming language."
                        ]
    ]

sessionMemoryTests :: TestTree
sessionMemoryTests =
  testGroup
    "SessionMemoryStore Tests"
    [ testCase "maintains isolated memory per session ID" $ do
        store <- newSessionMemoryStore (\_ -> newConversationBufferMemory [])
        res <- runExceptT $ do
          memAlice <- getSessionMemory store "user-alice"
          memBob <- getSessionMemory store "user-bob"
          saveContext memAlice "Alice question" "Alice answer"
          saveContext memBob "Bob question" "Bob answer"

          msgsAlice <- messages memAlice
          msgsBob <- messages memBob
          pure (msgsAlice, msgsBob)

        case res of
          Left err -> assertFailure $ "Session test failed: " ++ show err
          Right (aliceMsgs, bobMsgs) -> do
            length aliceMsgs @?= 2
            length bobMsgs @?= 2
            aliceMsgs @?= [userMessage "Alice question", assistantMessage "Alice answer"]
            bobMsgs @?= [userMessage "Bob question", assistantMessage "Bob answer"]
    , testCase "session management: list, count, delete, and clear" $ do
        store <- newSessionMemoryStore (\_ -> newWindowBufferMemory 5 [])
        _ <- getSessionMemory store "s1"
        _ <- getSessionMemory store "s2"
        _ <- getSessionMemory store "s3"

        cnt <- sessionCount store
        cnt @?= 3

        sList <- listSessions store
        length sList @?= 3

        deleteSession store "s2"
        cnt2 <- sessionCount store
        cnt2 @?= 2

        clearAllSessions store
        cnt3 <- sessionCount store
        cnt3 @?= 0
    ]

entityMemoryTests :: TestTree
entityMemoryTests =
  testGroup
    "EntityMemory Tests"
    [ testCase "tracks entity facts and formats them into system message" $ do
        mem <- newEntityMemory (Map.fromList [("user_name", "Alice")])
        setEntity mem "favorite_language" "Haskell"

        val <- getEntity mem "favorite_language"
        val @?= Just "Haskell"

        res <- runExceptT $ messages mem
        case res of
          Left err -> assertFailure $ "Failed: " ++ show err
          Right msgs -> do
            length msgs @?= 1
            case msgs of
              (m : _) -> assertBool "Contains entities" ("favorite_language: Haskell" `T.isInfixOf` extractMessageText m)
              [] -> assertFailure "Expected non-empty message"
    , testCase "resetEntitiesToInitial restores original seeded facts" $ do
        mem <- newEntityMemory (Map.fromList [("role", "Engineer")])
        setEntity mem "role" "Manager"
        setEntity mem "location" "Remote"
        resetEntitiesToInitial mem
        val <- getEntity mem "role"
        val @?= Just "Engineer"
        loc <- getEntity mem "location"
        loc @?= Nothing
    , testCase "deleteEntity and clearEntities remove attributes" $ do
        mem <- newEntityMemory (Map.fromList [("a", "1"), ("b", "2")])
        deleteEntity mem "a"
        valA <- getEntity mem "a"
        valA @?= Nothing
        clearEntities mem
        allEnts <- getAllEntities mem
        assertBool "All entities cleared" (Map.null allEnts)
    ]

combinedMemoryTests :: TestTree
combinedMemoryTests =
  testGroup
    "CombinedMemory Tests"
    [ testCase "aggregates messages from multiple memory stores" $ do
        bufMem <- newWindowBufferMemory 5 [systemMessage "System"]
        entMem <- newEntityMemory (Map.fromList [("role", "Developer")])
        let combined = newCombinedMemory [SomeMemory bufMem, SomeMemory entMem]

        res <- runExceptT $ do
          saveContext combined "Hello" "World"
          messages combined

        case res of
          Left err -> assertFailure $ "Combined failed: " ++ show err
          Right msgs -> do
            -- bufMem has 3 messages (system + user + ai), entMem has 1 entity message
            length msgs @?= 4
    ]

concurrencyTests :: TestTree
concurrencyTests =
  testGroup
    "Concurrency Tests"
    [ testCase "100 concurrent writes produce consistent window size" $ do
        let initialMsgs = [systemMessage "System"]
            maxSize = 200
        memory <- newWindowBufferMemory maxSize initialMsgs
        forConcurrently_ [1 .. 100 :: Int] $ \i -> do
          _ <- runExceptT $ addMessage memory (userMessage $ "Msg " <> T.pack (show i))
          pure ()
        result <- runExceptT $ messages memory
        case result of
          Left err -> assertFailure $ "Expected Right but got Left: " ++ show err
          Right msgs -> length msgs @?= 101
    ]