baikai-claude-0.7.0.0: test/FableContractsSpec.hs
{-# LANGUAGE OverloadedRecordDot #-}
module FableContractsSpec (tests) where
import Baikai hiding (messages, model)
import Baikai.Models.Generated (anthropic_claude_fable_5_1)
import Baikai.Provider.Claude.Internal.Request qualified as R
import Baikai.Provider.Claude.Internal.Stream (SseDriver, claudeMessagesStreamWith)
import Claude.V1.Messages qualified as C
import Contract (assertErrorContract)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (forM_)
import Data.Aeson (Value (..), object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Key (Key)
import Data.Aeson.KeyMap qualified as KM
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Streamly.Data.Stream qualified as Stream
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
tests :: TestTree
tests =
testGroup
"Fable contracts"
[ summaryTests,
testCase "forced choices fail before the driver for complete and stream, including renamed models" $
forM_ [model, model & #modelId .~ "renamed-generation"] $ \m ->
forM_ [ToolChoiceRequired, ToolChoiceSpecific "lookup"] $ \choice -> do
sent <- newIORef False
let never _ _ _ = writeIORef sent True
opts = options & #toolChoice .~ Just choice
events <- Stream.toList (claudeMessagesStreamWith never m context opts)
assertErrorContract events
response <- streamingComplete (claudeMessagesStreamWith never) m context opts
fmap (.category) (responseError response) @?= Just InvalidRequest
assertBool "useful correction" (maybe False (T.isInfixOf "ToolChoiceAuto" . (.message)) (responseError response))
readIORef sent >>= (@?= False),
testCase "auto and none keep their exact wire meaning" $
forM_ [(ToolChoiceAuto, "auto"), (ToolChoiceNone, "none")] $ \(choice, expected) -> do
body <- newIORef Null
let capture call _ emit = writeIORef body (call ^. #requestBody) >> send finalTurn emit
_ <- streamingComplete (claudeMessagesStreamWith capture) model context (options & #toolChoice .~ Just choice)
raw <- readIORef body
(field "tool_choice" raw >>= field "type") @?= Just (String expected),
testCase "supporting compatibility retains required and named choice" $
forM_ [(ToolChoiceRequired, "any"), (ToolChoiceSpecific "lookup", "tool")] $ \(choice, expected) -> do
let m = model & #modelId .~ "supporting-generation" & #compat .~ CompatAnthropicMessages (anthropicMessagesCompatFor model & #supportsForcedToolChoice .~ True)
(req, _) <- either (\e -> assertFailure (T.unpack e) >> fail "map") pure (R.mapRequest m context (options & #toolChoice .~ Just choice))
(field "tool_choice" (Aeson.toJSON req) >>= field "type") @?= Just (String expected),
testCase "unset effort is no preference and small caps never create a manual budget" $
forM_ [Nothing, Just ThinkingMinimal, Just ThinkingLow, Just ThinkingMedium, Just ThinkingHigh, Just ThinkingXHigh, Just ThinkingMax] $ \level -> do
let opts = options & #thinking .~ level & #maxTokens .~ Just 1
(req, translation) <- either (\e -> assertFailure (T.unpack e) >> fail "map") pure (R.mapRequest model context opts)
let raw = Aeson.toJSON req
field "max_tokens" raw @?= Just (Number 1)
translation @?= R.describeThinkingFor model opts
case level of
Nothing -> do
field "thinking" raw @?= Nothing
translation @?= noThinkingRequested
Just _ -> do
(field "thinking" raw >>= field "type") @?= Just (String "adaptive")
(field "thinking" raw >>= field "budget_tokens") @?= Nothing,
testCase "legacy serialized compat defaults to supporting forced choice" $ do
let raw = case Aeson.toJSON defaultAnthropicMessagesCompat of Object o -> Object (KM.delete "supportsForcedToolChoice" o); v -> v
case Aeson.fromJSON raw of
Aeson.Success c -> (c :: AnthropicMessagesCompat).supportsForcedToolChoice @?= True
Aeson.Error err -> assertFailure err,
testCase "two consecutive tool rounds preserve signed empty, visible and redacted thinking" $ do
requests <- newIORef ([] :: [Value])
executed <- newIORef ([] :: [Text])
let scripted call _ emit = do
previous <- readIORef requests
writeIORef requests (previous <> [call ^. #requestBody])
send (case length previous of 0 -> toolTurn "" "sig-one" "toolu_1" True; 1 -> toolTurn "visible summary" "sig-two" "toolu_2" False; _ -> finalTurn) emit
reg <- newProviderRegistryFrom [apiProvider AnthropicMessages (claudeMessagesStreamWith scripted)]
(history, response) <- runToolLoopWith reg 4 (\tc -> do xs <- readIORef executed; writeIORef executed (xs <> [tc.id_]); pure (toolResultText "found")) model context summaryOptions
response.message.content @?= V.singleton (AssistantText (TextContent "done"))
readIORef executed >>= (@?= ["toolu_1", "toolu_2"])
bodies <- readIORef requests
length bodies @?= 3
case bodies of
[first, second, third] -> do
let a = messages first; b = messages second; c = messages third
V.take (V.length a) b @?= a
V.take (V.length b) c @?= b
field "system" first @?= field "system" third
field "tools" first @?= field "tools" third
forM_ bodies $ \body -> (field "thinking" body >>= field "display") @?= Just (String "summarized")
contentAt 1 b @?= V.fromList [signed "" "sig-one", redactedItem, toolItem "toolu_1"]
contentAt 3 c @?= V.fromList [signed "visible summary" "sig-two", toolItem "toolu_2"]
field "tool_use_id" (contentAt 2 b V.! 0) @?= Just (String "toolu_1")
field "tool_use_id" (contentAt 4 c V.! 0) @?= Just (String "toolu_2")
persisted <- V.mapM persistThinking (history ^. #messages)
(req, _) <- either (\e -> assertFailure (T.unpack e) >> fail "map") pure (R.mapRequest model (history & #messages .~ persisted) summaryOptions)
messages (Aeson.toJSON req) @?= c
_ -> assertFailure "expected three requests",
testCase "error cleanup retains completed signed thinking for persistence" $ do
let partial _ _ emit = send (take 4 (toolTurn "" "sig-one" "toolu_1" False)) emit >> emit (Left (providerError "interrupted"))
response <- streamingComplete (claudeMessagesStreamWith partial) model context options
assertBool "failed response" (responseError response /= Nothing)
case V.toList response.message.content of
[AssistantThinking t] -> do
t.thinking @?= ""
t.signature @?= Just "sig-one"
t.replayState @?= Nothing
_ -> assertFailure "signed state was discarded on error"
]
model :: Model
model = anthropic_claude_fable_5_1
options :: Options
options = emptyOptions & #apiKey .~ Just (ApiKeyLiteral "offline-key")
context :: Context
context = systemUser "unchanged system" "look twice" & #tools .~ V.singleton (mkTool "lookup" "lookup" (object ["type" .= ("object" :: Text)]))
send :: [Value] -> (Either BaikaiError C.MessageStreamEvent -> IO ()) -> IO ()
send frames emit = forM_ frames $ \raw -> case Aeson.fromJSON raw of
Aeson.Error err -> assertFailure err
Aeson.Success ev -> emit (Right ev)
start :: Value
start = object ["type" .= ("message_start" :: Text), "message" .= object ["id" .= ("msg" :: Text), "type" .= ("message" :: Text), "role" .= ("assistant" :: Text), "model" .= ("claude-fable-5-1" :: Text), "content" .= ([] :: [Value]), "usage" .= object ["input_tokens" .= (5 :: Int), "output_tokens" .= (0 :: Int)]]]
block :: Int -> Value -> Value
block n b = object ["type" .= ("content_block_start" :: Text), "index" .= n, "content_block" .= b]
stopBlock :: Int -> Value
stopBlock n = object ["type" .= ("content_block_stop" :: Text), "index" .= n]
end :: Text -> [Value]
end reason = [object ["type" .= ("message_delta" :: Text), "delta" .= object ["stop_reason" .= reason], "usage" .= object ["output_tokens" .= (5 :: Int)]], object ["type" .= ("message_stop" :: Text)]]
signed :: Text -> Text -> Value
signed text sig = object ["type" .= ("thinking" :: Text), "thinking" .= text, "signature" .= sig]
redactedItem :: Value
redactedItem = object ["type" .= ("redacted_thinking" :: Text), "data" .= ("encrypted-redacted" :: Text)]
toolItem :: Text -> Value
toolItem ident = object ["type" .= ("tool_use" :: Text), "id" .= ident, "name" .= ("lookup" :: Text), "input" .= object []]
toolTurn :: Text -> Text -> Text -> Bool -> [Value]
toolTurn text sig ident redacted =
[start, block 0 (signed "" "")]
<> (if T.null text then [] else [object ["type" .= ("content_block_delta" :: Text), "index" .= (0 :: Int), "delta" .= object ["type" .= ("thinking_delta" :: Text), "thinking" .= text]]])
<> [object ["type" .= ("content_block_delta" :: Text), "index" .= (0 :: Int), "delta" .= object ["type" .= ("signature_delta" :: Text), "signature" .= sig]], stopBlock 0]
<> (if redacted then [block 1 redactedItem, stopBlock 1] else [])
<> [block (if redacted then 2 else 1) (toolItem ident), stopBlock (if redacted then 2 else 1)]
<> end "tool_use"
finalTurn :: [Value]
finalTurn = [start, block 0 (object ["type" .= ("text" :: Text), "text" .= ("" :: Text)]), object ["type" .= ("content_block_delta" :: Text), "index" .= (0 :: Int), "delta" .= object ["type" .= ("text_delta" :: Text), "text" .= ("done" :: Text)]], stopBlock 0] <> end "end_turn"
field :: Key -> Value -> Maybe Value
field k (Object o) = KM.lookup k o
field _ _ = Nothing
messages :: Value -> V.Vector Value
messages raw = case field "messages" raw of Just (Array xs) -> xs; _ -> V.empty
contentAt :: Int -> V.Vector Value -> V.Vector Value
contentAt n xs = case xs V.!? n >>= field "content" of Just (Array cs) -> cs; _ -> V.empty
-- Applications own the surrounding history store; ThinkingContent supplies
-- its JSON round trip. Exercise that payload without inventing a Context decoder.
persistThinking :: Message -> IO Message
persistThinking (AssistantMessage payload) = do
blocks <- V.mapM persist (payload ^. #content)
pure (AssistantMessage (payload & #content .~ blocks))
where
persist (AssistantThinking t) = AssistantThinking <$> either assertFailure pure (Aeson.eitherDecode (Aeson.encode t))
persist block = pure block
persistThinking other = pure other
summaryOptions :: Options
summaryOptions = options & #thinking .~ Just ThinkingLow & #evidence .~ Just (evidenceRequest "summary-replay")
summaryTests :: TestTree
summaryTests =
testGroup
"summary visibility"
[ testCase "summarized reasoning assembles into non-empty thinking content" $ do
response <- completeFrames (toolTurn "visible summary" "signed-summary" "toolu_summary" False) summaryOptions
let blocks = [t | AssistantThinking t <- V.toList (response ^. #message . #content)]
map (^. #thinking) blocks @?= ["visible summary"]
map (^. #signature) blocks @?= [Just "signed-summary"]
ev <- evidenceOf response
ev ^. #thinking . #displayText @?= Just "summarized"
ev ^. #thinking . #adjustments @?= []
ev ^. #observedThinking @?= Unobserved,
testCase "reasoning requested but returned empty is recorded in the evidence" $ do
forM_ [[signed "" "empty-signature"], [redactedItem], [signed "" "empty-signature", redactedItem]] $ \blocks -> do
response <- completeFrames (blocksTurn blocks) summaryOptions
ev <- evidenceOf response
ev ^. #thinking . #adjustments @?= [ThinkingSummaryUnavailable]
ev ^. #thinking . #displayText @?= Just "summarized"
ev ^. #observedThinking @?= Unobserved
ev ^. #status @?= CallSucceeded
responseError response @?= Nothing
weakensThinking ThinkingSummaryUnavailable @?= False
let actual = [t | AssistantThinking t <- V.toList (response ^. #message . #content)]
length actual @?= length blocks
-- Encrypted payloads and signed empty blocks remain usable history.
(req, _) <- either (\e -> assertFailure (T.unpack e) >> fail "map") pure (R.mapRequest model (emptyContext & #messages .~ V.singleton (AssistantMessage (response ^. #message))) summaryOptions)
contentAt 0 (messages (Aeson.toJSON req)) @?= V.fromList blocks,
testCase "one visible block among empty and redacted blocks is a readable summary" $ do
response <- completeFrames (blocksTurn [signed "" "empty", redactedItem, signed "visible" "visible-signature"]) summaryOptions
ev <- evidenceOf response
ev ^. #thinking . #adjustments @?= [],
testCase "no thinking block and no thinking preference do not invent missing summaries" $ do
noBlocks <- completeFrames finalTurn summaryOptions
ev <- evidenceOf noBlocks
ev ^. #thinking . #adjustments @?= []
noPreference <- completeFrames (blocksTurn [signed "" "sig"]) (options & #evidence .~ Just (evidenceRequest "no-thinking"))
ev2 <- evidenceOf noPreference
ev2 ^. #thinking @?= noThinkingRequested,
testCase "failed streams do not label partial thinking as a missing completed summary" $ do
let driver _ _ emit = send [start, block 0 (signed "" "sig"), stopBlock 0] emit >> emit (Left (providerError "interrupted"))
response <- streamingComplete (claudeMessagesStreamWith driver) model emptyContext summaryOptions
ev <- evidenceOf response
ev ^. #status @?= CallFailed
ev ^. #thinking . #adjustments @?= []
assertBool "failure retained" (responseError response /= Nothing)
]
where
completeFrames frames opts = streamingComplete (claudeMessagesStreamWith (\_ _ emit -> send frames emit)) model emptyContext opts
blocksTurn blocks = [start] <> concat [blockFrames i b | (i, b) <- zip [0 ..] blocks] <> end "end_turn"
blockFrames i b = case (field "type" b, field "thinking" b, field "signature" b) of
(Just (String "thinking"), Just (String text), Just (String sig)) ->
[ block i (signed "" ""),
object ["type" .= ("content_block_delta" :: Text), "index" .= i, "delta" .= object ["type" .= ("thinking_delta" :: Text), "thinking" .= text]],
object ["type" .= ("content_block_delta" :: Text), "index" .= i, "delta" .= object ["type" .= ("signature_delta" :: Text), "signature" .= sig]],
stopBlock i
]
_ -> [block i b, stopBlock i]
evidenceOf response = maybe (assertFailure "missing evidence" >> fail "evidence") pure (response ^. #evidence)