packages feed

baikai-0.7.0.0: test/ContextSpec.hs

module ContextSpec (tests) where

import Baikai
import Control.Lens ((&), (.~), (^.))
import Data.Aeson qualified as Aeson
import Data.Text qualified as Text
import Data.Time (UTCTime)
import Data.Vector qualified as V
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Context helpers"
    [ replayStateTests,
      monoidTests,
      constructorTests,
      timestampTests,
      flattenTextTests,
      toolResultTests
    ]

-- | A failed call has no assistant turn worth replaying and no tool
-- calls to answer, so 'appendToolResult' appends nothing and runs
-- nothing. 'runToolLoop' has always stopped on such a response; the
-- documented direct round trip reaches here instead.
toolResultTests :: TestTree
toolResultTests =
  testGroup
    "appendToolResult"
    [ testCase "an error-shaped response leaves the context unchanged and never dispatches" $ do
        let ctx = contextOf [user "go"]
            failed =
              errorResponse
                emptyModel
                (read "2026-06-05 01:02:03 UTC" :: UTCTime)
                12
                (providerError "upstream died")
            explode _ = error "the dispatcher must not run for an error-shaped response"
        after <- appendToolResult ctx failed explode
        after @?= ctx
    ]

monoidTests :: TestTree
monoidTests =
  testGroup
    "Monoid"
    [ testCase "has left and right identity" $ do
        let ctx =
              emptyContext
                { systemPrompt = Just "sys",
                  messages = V.fromList [user "one"],
                  tools = V.singleton sampleTool
                }
        mempty <> ctx @?= ctx
        ctx <> mempty @?= ctx,
      testCase "is associative and keeps the first system prompt" $ do
        let a = systemUser "a-system" "a"
            b = addUser "b" emptyContext
            c =
              emptyContext
                { systemPrompt = Just "c-system",
                  messages = V.fromList [assistant "c"],
                  tools = V.singleton sampleTool
                }
        (a <> b) <> c @?= a <> (b <> c)
        (a <> b <> c) ^. #systemPrompt @?= Just "a-system"
        (b <> c) ^. #systemPrompt @?= Just "c-system"
    ]

constructorTests :: TestTree
constructorTests =
  testGroup
    "constructors"
    [ testCase "contextOf preserves message order" $ do
        contextOf [user "one", assistant "two"] ^. #messages
          @?= V.fromList [user "one", assistant "two"],
      testCase "systemUser creates a system prompt and one user message" $ do
        let ctx = systemUser "system" "prompt"
        ctx ^. #systemPrompt @?= Just "system"
        ctx ^. #messages @?= V.singleton (user "prompt"),
      testCase "addMessage, addUser, and addResponse append in order" $ do
        let resp =
              emptyResponse
                & #message
                  .~ AssistantPayload
                    { content = V.singleton (AssistantText (TextContent "response")),
                      usage = zeroUsage,
                      stopReason = Stop,
                      errorMessage = Nothing,
                      timestamp = Nothing
                    }
            ctx =
              emptyContext
                |> addUser "first"
                |> addMessage (assistant "second")
                |> addResponse resp
        ctx ^. #messages
          @?= V.fromList
            [ user "first",
              assistant "second",
              responseMessage resp
            ]
    ]

timestampTests :: TestTree
timestampTests =
  testGroup
    "timestamps"
    [ testCase "pure constructors do not invent timestamps" $ do
        payloadTimestamp (user "plain") @?= Nothing
        payloadTimestamp (assistant "plain") @?= Nothing
        payloadTimestamp (toolResult "call" "tool" "ok" False) @?= Nothing,
      testCase "explicit constructors preserve timestamps" $ do
        let ts = read "2026-06-05 01:02:03 UTC"
        payloadTimestamp (userAt ts "plain") @?= Just ts
        payloadTimestamp (assistantAt ts "plain") @?= Just ts
        payloadTimestamp (toolResultAt ts "call" "tool" "ok" False) @?= Just ts
    ]

flattenTextTests :: TestTree
flattenTextTests =
  testGroup
    "flattenAssistantText"
    [ testCase "concatenates only text blocks" $ do
        flattenAssistantText
          ( V.fromList
              [ AssistantText (TextContent "hello"),
                AssistantThinking
                  ThinkingContent
                    { thinking = "hidden",
                      signature = Nothing,
                      redacted = False,
                      replayState = Nothing
                    },
                AssistantToolCall emptyToolCall {name = "lookup", arguments = Aeson.object []},
                AssistantText (TextContent " world")
              ]
          )
          @?= "hello world"
    ]

sampleTool :: Tool
sampleTool =
  emptyTool
    { name = "lookup",
      description = "Lookup a value",
      parameters = Aeson.object []
    }

payloadTimestamp :: Message -> Maybe UTCTime
payloadTimestamp (UserMessage UserPayload {timestamp = ts}) = ts
payloadTimestamp (AssistantMessage AssistantPayload {timestamp = ts}) = ts
payloadTimestamp (ToolResultMessage ToolResultPayload {timestamp = ts}) = ts

(|>) :: a -> (a -> b) -> b
(|>) x f = f x

replayStateTests :: TestTree
replayStateTests =
  testGroup
    "provider-scoped reasoning replay"
    [ testCase "legacy JSON remains valid and byte-compatible" $ do
        let old = Aeson.object ["thinking" Aeson..= ("" :: Text.Text), "signature" Aeson..= Aeson.Null, "redacted" Aeson..= False]
        Aeson.fromJSON old @?= Aeson.Success emptyThinkingContent
        Aeson.toJSON emptyThinkingContent @?= old,
      testCase "empty summary and ordered encrypted items survive content persistence and context appending" $ do
        let saved = Aeson.eitherDecode (Aeson.encode thought)
        saved @?= Right thought
        let resp = emptyResponse & #message . #content .~ V.singleton (AssistantThinking thought)
            context = addResponse resp (contextOf [user "go"])
        context ^. #messages @?= V.fromList [user "go", responseMessage resp]
        flattenAssistantText (resp ^. #message . #content) @?= ""
        assertBool "Show omits encrypted content" (not ("encrypted-secret" `Text.isInfixOf` Text.pack (show resp))),
      testCase "response content commitment binds replay scope, identity, payload and order" $ do
        let digest t = commitmentDigest (Aeson.object ["content" Aeson..= V.singleton (AssistantThinking t)])
            changed r = thought & #replayState .~ Just r
        mapM_
          (\r -> assertBool "replay mutation must change commitment" (digest thought /= digest (changed r)))
          [ state & #replayApi .~ AnthropicMessages,
            state & #replayModel .~ "other-model",
            state & #replayItems .~ V.reverse items,
            state & #replayItems .~ V.singleton (Aeson.object ["id" Aeson..= ("different" :: Text.Text)])
          ]
    ]
  where
    items = V.fromList [Aeson.object ["type" Aeson..= ("reasoning" :: Text.Text), "id" Aeson..= ("rs_1" :: Text.Text), "summary" Aeson..= ([] :: [Aeson.Value]), "encrypted_content" Aeson..= ("encrypted-secret" :: Text.Text)], Aeson.object ["id" Aeson..= ("rs_2" :: Text.Text)]]
    state = ThinkingReplay OpenAIResponses "gpt-6-astra" items
    thought = emptyThinkingContent & #replayState .~ Just state