packages feed

llm-simple-0.4.2.0: test/LLM/RecordedConversationSpec.hs

{-# LANGUAGE MultiWayIf #-}

module LLM.RecordedConversationSpec (spec) where

import Data.Aeson (Value (Object, String), encode)
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy qualified as BSL
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Map qualified as Map
import Heptapod (generate)
import LLM.Agent.Events (noEventObserver)
import LLM.Agent.Generate (generateText, toolBudgetUsedUpPrompt)
import LLM.Agent.ToolUtils (toTool)
import LLM.Agent.Types (Agent (..), RuntimeArgs (..))
import LLM.Core.LLMProvider (LLMProvider (..), toGateway)
import LLM.Core.Types (pattern UserTurn)
import LLM.Core.Usage (defaultPricingInfo)
import LLM.GenericConversationTest (GenericConversationTextOps (..), createSpec)
import LLM.Generate.GenerateUtils (llmHooks)
import LLM.Generate.Logger (noHooks)
import LLM.Generate.ModelConfig (ModelConfig (..), ModelWithFallbacks (..), defaultModelCapabilities)
import LLM.Generate.Types (GenerateTextResult (..))
import LLM.Providers.Claude (claudeProvider)
import LLM.Providers.DeepSeek (deepSeekProvider)
import LLM.Providers.Gemini (geminiProvider)
import LLM.Providers.Ollama (ollamaProvider)
import LLM.Providers.OpenAI (openAIProvider)
import LLM.TestKit
  ( MockRequestResponse (..),
    readMockRequestResponse,
    recordedConversationSystemPrompt,
    recordedDeepSeekThinking,
  )
import LLM.WeatherTool (weatherToolTyped)
import Test.Hspec (SpecWith, describe, expectationFailure, it, shouldBe)

ollamaConversationGeneratedFilePath :: String
ollamaConversationGeneratedFilePath = "./test/fixtures/ollama-conversation-generated.json"

ollamaConversationStreamedFilePath :: String
ollamaConversationStreamedFilePath = "./test/fixtures/ollama-conversation-streamed.json"

claudeConversationGeneratedFilePath :: String
claudeConversationGeneratedFilePath = "./test/fixtures/claude-conversation-generated.json"

claudeConversationStreamedFilePath :: String
claudeConversationStreamedFilePath = "./test/fixtures/claude-conversation-streamed.json"

geminiConversationGeneratedFilePath :: String
geminiConversationGeneratedFilePath = "./test/fixtures/gemini-conversation-generated.json"

geminiConversationStreamedFilePath :: String
geminiConversationStreamedFilePath = "./test/fixtures/gemini-conversation-streamed.json"

openAIConversationGeneratedFilePath :: String
openAIConversationGeneratedFilePath = "./test/fixtures/openai-conversation-generated.json"

openAIConversationStreamedFilePath :: String
openAIConversationStreamedFilePath = "./test/fixtures/openai-conversation-streamed.json"

deepSeekConversationGeneratedFilePath :: String
deepSeekConversationGeneratedFilePath = "./test/fixtures/deepseek-conversation-generated.json"

deepSeekConversationStreamedFilePath :: String
deepSeekConversationStreamedFilePath = "./test/fixtures/deepseek-conversation-streamed.json"

spec :: SpecWith ()
spec =
  describe "recorded conversation" $ do
    createSpec $
      GenericConversationTextOps
        "Ollama"
        ollamaProvider
        "llama3.2:latest"
        Nothing
        ollamaConversationGeneratedFilePath
        ollamaConversationStreamedFilePath
    createSpec $
      GenericConversationTextOps
        "Claude"
        (claudeProvider "")
        "claude-haiku-4-5-20251001"
        Nothing
        claudeConversationGeneratedFilePath
        claudeConversationStreamedFilePath
    createSpec $
      GenericConversationTextOps
        "Gemini"
        (geminiProvider "")
        "gemini-2.5-flash"
        Nothing
        geminiConversationGeneratedFilePath
        geminiConversationStreamedFilePath
    createSpec $
      GenericConversationTextOps
        "OpenAI"
        (openAIProvider "")
        "gpt-4.1-2025-04-14"
        Nothing
        openAIConversationGeneratedFilePath
        openAIConversationStreamedFilePath
    createSpec $
      GenericConversationTextOps
        "DeepSeek"
        (deepSeekProvider "")
        "deepseek-v4-flash"
        (Just recordedDeepSeekThinking)
        deepSeekConversationGeneratedFilePath
        deepSeekConversationStreamedFilePath

    describe "Claude final call after the tool round limit" $
      it "is the recorded follow-up request with the extra system-prompt line" $
        claudeFinalCallSpec

-- | With @agMaxToolRounds = 1@, the call after the first tool round is the
-- final call (1.16). It must be exactly the recorded second request, which
-- Claude accepted (tools declared, tool blocks in the history), except for
-- the system prompt, which gets 'toolBudgetUsedUpPrompt' appended.
claudeFinalCallSpec :: IO ()
claudeFinalCallSpec = do
  recorded <- readMockRequestResponse claudeConversationGeneratedFilePath
  case recorded of
    Just (first : second : _) -> do
      let withFinalLine = \case
            Object o
              | Just (String sys) <- KM.lookup "system" o ->
                  Object (KM.insert "system" (String (sys <> "\n\n" <> toolBudgetUsedUpPrompt)) o)
            v -> v
          expectedFinal = withFinalLine second.request
      sent <- newIORef []
      let provider =
            (claudeProvider "")
              { sendRequest = \body -> do
                  modifyIORef' sent (++ [body])
                  if
                    | body == first.request -> pure (200, BSL.toStrict (encode first.response))
                    | body == expectedFinal -> pure (200, BSL.toStrict (encode second.response))
                    | otherwise -> pure (400, "unexpected request")
              }
          model =
            ModelConfig
              { mcGateway = toGateway provider,
                mcModel = "claude-haiku-4-5-20251001",
                mcPricing = defaultPricingInfo 0 0,
                mcMaxTokens = 1024,
                mcTemperature = Nothing,
                mcThinking = Nothing,
                mcCapabilities = defaultModelCapabilities,
                mcRequestTimeout = Nothing,
                mcThrottleDelay = Nothing,
                mcRetryCount = 0,
                mcJitterBackoff = 0
              }
          agent =
            Agent
              { agName = "test",
                agSystemPrompt = Just recordedConversationSystemPrompt,
                agTools = ["get_weather"],
                agMaxToolRounds = 1,
                agContextWindow = Nothing
              }
      genId <- generate
      let rt =
            RuntimeArgs
              { rtGenerationId = genId,
                rtAbortSignal = Nothing,
                rtLLMHooks = llmHooks noHooks,
                rtHooks = noHooks,
                rtOnEvent = noEventObserver,
                rtReadonly = False
              }
          toolMap = Map.fromList [("get_weather", toTool weatherToolTyped)]
      result <- generateText agent (ModelWithFallbacks model []) toolMap rt [UserTurn "how's the weather in london?"]
      bodies <- readIORef sent
      bodies `shouldBe` [first.request, expectedFinal]
      case result of
        Right r -> length r.gtrNewMessages `shouldBe` 3
        Left err -> expectationFailure (show err)
    _ -> expectationFailure "fixture needs at least two recorded calls"