baikai-0.6.0.0: test/StreamSpec.hs
module StreamSpec (tests) where
import Baikai
import Baikai.Prelude
import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar, threadDelay, throwTo)
import Control.Exception qualified as Exception
import Data.Aeson qualified as Aeson
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Time (UTCTime)
import Data.Vector qualified as Vector
import Streamly.Data.Stream qualified as Stream
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
streamApi :: Api
streamApi = Custom "baikai-stream-spec"
streamModel :: Model
streamModel =
emptyModel
& #modelId
.~ "stream-spec-model"
& #api
.~ streamApi
& #provider
.~ "stream-spec"
streamContext :: Context
streamContext = emptyContext
streamOptions :: Options
streamOptions = emptyOptions
epoch :: UTCTime
epoch = read "2000-01-01 00:00:00 UTC"
assistantPayload :: Vector AssistantContent -> StopReason -> Maybe Text -> UTCTime -> AssistantPayload
assistantPayload blocks sr err ts =
AssistantPayload
{ content = blocks,
usage = zeroUsage,
stopReason = sr,
errorMessage = err,
timestamp = Just ts
}
assistantMessage :: [AssistantContent] -> Message
assistantMessage blocks =
AssistantMessage (assistantPayload (Vector.fromList blocks) Stop Nothing epoch)
responseWith :: Maybe Text -> [AssistantContent] -> Response
responseWith rid blocks =
emptyResponse
& #message
.~ assistantPayload (Vector.fromList blocks) Stop Nothing epoch
& #model
.~ streamModel
& #api
.~ streamApi
& #provider
.~ "stream-spec"
& #responseId
.~ rid
runEvents :: [AssistantMessageEvent] -> IO Response
runEvents events =
Stream.fold (reassembleResponse streamModel) (Stream.fromList events)
startEvent :: Maybe Text -> AssistantMessageEvent
startEvent rid =
EventStart StartPayload {partial = assistantMessage [], responseId = rid}
doneEvent :: Maybe Text -> [AssistantContent] -> AssistantMessageEvent
doneEvent rid blocks =
EventDone (doneTerminal Nothing rid Stop (assistantMessage blocks))
signedThinking :: ThinkingContent
signedThinking =
ThinkingContent {thinking = "t", signature = Just "sig-abc", redacted = True}
tests :: TestTree
tests =
testGroup
"Baikai.Stream reassembly"
[ testCase "thinking signature and redacted flag survive lift + reassembly" $ do
let blocks =
[ AssistantThinking signedThinking,
AssistantText (TextContent "answer")
]
handler _ _ _ = pure (responseWith (Just "lifted-id") blocks)
resp <- streamingComplete (liftCompleteToStream handler) streamModel streamContext streamOptions
resp ^. #message ^. #content @?= Vector.fromList blocks,
testCase "ThinkingEnd carries the full ThinkingContent" $ do
resp <-
runEvents
[ startEvent Nothing,
ThinkingStart IndexPayload {contentIndex = 0},
ThinkingDelta DeltaPayload {contentIndex = 0, delta = "t"},
ThinkingEnd ThinkingEndPayload {contentIndex = 0, content = signedThinking},
doneEvent Nothing []
]
resp ^. #message ^. #content @?= Vector.singleton (AssistantThinking signedThinking),
testCase "terminal message content is authoritative" $ do
resp <-
runEvents
[ startEvent Nothing,
TextStart IndexPayload {contentIndex = 0},
TextDelta DeltaPayload {contentIndex = 0, delta = "partial"},
TextEnd BlockEndPayload {contentIndex = 0, content = "partial"},
doneEvent Nothing [AssistantText (TextContent "the real full text")]
]
resp ^. #message ^. #content @?= Vector.singleton (AssistantText (TextContent "the real full text")),
testCase "responseId flows from events to Response" $ do
fromStart <- runEvents [startEvent (Just "msg_123"), doneEvent Nothing []]
fromStart ^. #responseId @?= Just "msg_123"
fromTerminal <- runEvents [startEvent (Just "msg_123"), doneEvent (Just "msg_456") []]
fromTerminal ^. #responseId @?= Just "msg_456",
testCase "dangling buffers keep contentIndex order; tool args flushed" $ do
resp <-
runEvents
[ startEvent Nothing,
TextStart IndexPayload {contentIndex = 0},
TextDelta DeltaPayload {contentIndex = 0, delta = "first"},
TextEnd BlockEndPayload {contentIndex = 0, content = "first"},
ThinkingStart IndexPayload {contentIndex = 1},
ThinkingDelta DeltaPayload {contentIndex = 1, delta = "partial-think"},
TextStart IndexPayload {contentIndex = 2},
TextDelta DeltaPayload {contentIndex = 2, delta = "last"},
TextEnd BlockEndPayload {contentIndex = 2, content = "last"},
ToolCallStart IndexPayload {contentIndex = 3},
ToolCallDelta DeltaPayload {contentIndex = 3, delta = "{\"a\":1"}
]
let expected =
Vector.fromList
[ AssistantText (TextContent "first"),
AssistantThinking ThinkingContent {thinking = "partial-think", signature = Nothing, redacted = False},
AssistantText (TextContent "last"),
AssistantToolCall ToolCall {id_ = "", name = "", arguments = Aeson.String "{\"a\":1"}
]
resp ^. #message ^. #content @?= expected
resp ^. #message ^. #stopReason @?= Stop
resp ^. #message ^. #errorMessage @?= Just "stream ended without terminal event"
-- The recovered call is the same shape the two provider
-- assemblers now produce for a cut-off call, and it says so.
[tc | AssistantToolCall tc <- Vector.toList (resp ^. #message ^. #content)]
@?= [ToolCall {id_ = "", name = "", arguments = Aeson.String "{\"a\":1"}]
assertBool
"a flushed dangling tool call is marked cut off"
(all isCutOffToolCall [tc | AssistantToolCall tc <- Vector.toList (resp ^. #message ^. #content)]),
cutOffToolCallIsNeverDispatchedTest,
testCase "latencyMs is clamped at zero" $ do
let oldResponse =
responseWith Nothing [AssistantText (TextContent "old")]
& #message
%~ (#timestamp .~ Just epoch)
handler _ _ _ = pure oldResponse
resp <- streamingComplete (liftCompleteToStream handler) streamModel streamContext streamOptions
assertBool "latencyMs should be non-negative" (resp ^. #latencyMs >= 0),
duplicateStartTest,
eventsAfterTerminalTest,
failedTerminalAppendsDanglingTest,
emptySuccessfulTerminalFallsBackTest,
wallClockLatencyTest,
testCase "async exceptions pass through liftCompleteToStream" $ do
done <- newEmptyMVar
let blocked _ _ _ = threadDelay (10 * 1000 * 1000) *> pure (responseWith Nothing [])
tid <-
forkIO $ do
outcome <- Exception.try (Stream.toList (liftCompleteToStream blocked streamModel streamContext streamOptions))
putMVar done (outcome :: Either Exception.SomeException [AssistantMessageEvent])
threadDelay 100000
throwTo tid Exception.ThreadKilled
outcome <- takeMVar done
case outcome of
Left e ->
(Exception.fromException e :: Maybe Exception.AsyncException)
@?= Just Exception.ThreadKilled
Right events -> assertFailure ("expected ThreadKilled, got events: " <> show events),
testCase "error-only streams begin with EventStart" $ do
reg <- newProviderRegistry
noProviderEvents <- Stream.toList (streamRequestWith reg streamModel streamContext streamOptions)
case noProviderEvents of
[ EventStart StartPayload {},
EventError TerminalPayload {errorInfo = Just be}
] ->
be ^. #category @?= ProviderUnavailable
other -> assertFailure ("expected EventStart then provider-unavailable EventError, got: " <> show other)
let throwing _ _ _ = Exception.throwIO (rateLimited (Just 5) "slow down")
liftedEvents <- Stream.toList (liftCompleteToStream throwing streamModel streamContext streamOptions)
case liftedEvents of
[EventStart StartPayload {}, EventError {}] -> pure ()
other -> assertFailure ("expected EventStart then EventError, got: " <> show other)
resp <- runEvents liftedEvents
case resp ^. #errorInfo of
Just be -> do
be ^. #category @?= RateLimited
be ^. #retryAfterSeconds @?= Just 5
Nothing -> assertFailure "expected lifted BaikaiError to survive reassembly"
]
-- | A tool call the model never finished asking for is not executed.
--
-- Both halves: 'runToolLoopWith' stops with the response intact rather
-- than dispatching, and 'appendToolResult' -- the documented direct
-- round-trip, which a caller drives by hand -- appends an error result
-- without calling the dispatcher either.
cutOffToolCallIsNeverDispatchedTest :: TestTree
cutOffToolCallIsNeverDispatchedTest =
testCase "a cut-off tool call is never dispatched" $ do
let cutOffCall = ToolCall {id_ = "call_1", name = "search", arguments = Aeson.String "{\"a\":1"}
-- 'Length' is what a real cut-off carries; the guard does not
-- rely on it, because a compatible host can report
-- @finish_reason: tool_calls@ for truncated arguments.
cutOffResponse =
emptyResponse
& #message
.~ assistantPayload (Vector.singleton (AssistantToolCall cutOffCall)) Length Nothing epoch
& #model
.~ cutOffModel
& #api
.~ cutOffApi
& #provider
.~ "stream-spec"
reg <- newProviderRegistry
registerApiProviderWith
reg
( apiProviderWith
cutOffApi
(liftCompleteToStream (\_ _ _ -> pure cutOffResponse))
(\_ _ _ -> pure cutOffResponse)
)
dispatched <- newIORef ([] :: [ToolCall])
let dispatcher tc = modifyIORef' dispatched (<> [tc]) >> pure (toolResultText "never")
(_, looped) <- runToolLoopWith reg 4 dispatcher cutOffModel streamContext streamOptions
looped ^. #message ^. #content @?= Vector.singleton (AssistantToolCall cutOffCall)
looped ^. #message ^. #stopReason @?= Length
readIORef dispatched >>= \calls -> calls @?= []
ctx' <- appendToolResult streamContext cutOffResponse dispatcher
readIORef dispatched >>= \calls -> calls @?= []
case Vector.toList (ctx' ^. #messages) of
[_assistant, ToolResultMessage p] -> do
p ^. #isError @?= True
p ^. #toolCallId @?= "call_1"
other -> assertFailure ("expected the assistant message then one tool result, got: " <> show (length other))
-- | Its own tag, so this case cannot collide with the module's other
-- registrations when the suite runs in one process.
cutOffApi :: Api
cutOffApi = Custom "baikai-stream-spec-cutoff"
cutOffModel :: Model
cutOffModel =
emptyModel
& #modelId
.~ "stream-spec-cutoff-model"
& #api
.~ cutOffApi
& #provider
.~ "stream-spec"
-- | A duplicated start does not rewrite the assembly.
--
-- First skeleton wins, so the latency window is measured from the first
-- event the provider actually sent; @responseId@ merges, so a later
-- 'Nothing' cannot erase an id an earlier event supplied.
duplicateStartTest :: TestTree
duplicateStartTest =
testCase "a duplicate EventStart keeps the first skeleton and merges responseId" $ do
let firstSkeleton = AssistantMessage (assistantPayload Vector.empty Stop Nothing later)
staleSkeleton = AssistantMessage (assistantPayload Vector.empty Stop Nothing epoch)
resp <-
runEvents
[ EventStart StartPayload {partial = firstSkeleton, responseId = Just "msg_1"},
EventStart StartPayload {partial = staleSkeleton, responseId = Nothing},
EventDone
( doneTerminal
Nothing
Nothing
Stop
(AssistantMessage (assistantPayload (Vector.singleton (AssistantText (TextContent "hi"))) Stop Nothing muchLater))
)
]
resp ^. #responseId @?= Just "msg_1"
-- Measured from the first skeleton's timestamp, not the stale one:
-- the stale skeleton is at the epoch, which would give a latency of
-- decades.
resp ^. #latencyMs @?= 2000
-- | The first terminal wins. A producer that keeps talking afterwards
-- cannot rewrite the answer a consumer has already been handed.
eventsAfterTerminalTest :: TestTree
eventsAfterTerminalTest =
testCase "events after the terminal are ignored" $ do
resp <-
runEvents
[ startEvent Nothing,
doneEvent Nothing [AssistantText (TextContent "final")],
TextStart IndexPayload {contentIndex = 5},
TextDelta DeltaPayload {contentIndex = 5, delta = "late"},
EventError
( errorTerminal
Nothing
Nothing
ErrorReason
(AssistantMessage (assistantPayload Vector.empty ErrorReason (Just "too late") epoch))
(providerError "too late")
)
]
resp ^. #message ^. #content @?= Vector.singleton (AssistantText (TextContent "final"))
resp ^. #message ^. #stopReason @?= Stop
resp ^. #errorInfo @?= Nothing
-- | A failed terminal's own content comes first and the blocks that were
-- still open are appended after it. Safe because an open index is always
-- greater than every closed one.
failedTerminalAppendsDanglingTest :: TestTree
failedTerminalAppendsDanglingTest =
testCase "a failed terminal appends dangling blocks after its content" $ do
resp <-
runEvents
[ startEvent Nothing,
TextStart IndexPayload {contentIndex = 0},
TextDelta DeltaPayload {contentIndex = 0, delta = "closed"},
TextEnd BlockEndPayload {contentIndex = 0, content = "closed"},
ThinkingStart IndexPayload {contentIndex = 1},
ThinkingDelta DeltaPayload {contentIndex = 1, delta = "half a thought"},
EventError
( errorTerminal
Nothing
Nothing
ErrorReason
(AssistantMessage (assistantPayload (Vector.singleton (AssistantText (TextContent "closed"))) ErrorReason (Just "boom") epoch))
(providerError "boom")
)
]
resp ^. #message ^. #content
@?= Vector.fromList
[ AssistantText (TextContent "closed"),
AssistantThinking ThinkingContent {thinking = "half a thought", signature = Nothing, redacted = False}
]
-- | A terminal that carries no content is not authoritative about
-- content: the blocks the stream assembled are.
emptySuccessfulTerminalFallsBackTest :: TestTree
emptySuccessfulTerminalFallsBackTest =
testCase "a successful terminal with empty content falls back to the assembled blocks" $ do
resp <-
runEvents
[ startEvent Nothing,
TextStart IndexPayload {contentIndex = 0},
TextDelta DeltaPayload {contentIndex = 0, delta = "assembled"},
TextEnd BlockEndPayload {contentIndex = 0, content = "assembled"},
doneEvent Nothing []
]
resp ^. #message ^. #content @?= Vector.singleton (AssistantText (TextContent "assembled"))
-- | With no provider timestamps, latency is the window this fold saw
-- rather than a zero that reads as "instant".
wallClockLatencyTest :: TestTree
wallClockLatencyTest =
testCase "latencyMs falls back to the wall clock when timestamps are absent" $ do
let untimed sr err blocks =
AssistantMessage
AssistantPayload
{ content = Vector.fromList blocks,
usage = zeroUsage,
stopReason = sr,
errorMessage = err,
timestamp = Nothing
}
events =
[ EventStart StartPayload {partial = untimed Stop Nothing [], responseId = Nothing},
EventDone (doneTerminal Nothing Nothing Stop (untimed Stop Nothing [AssistantText (TextContent "slow")]))
]
resp <-
Stream.fold
(reassembleResponse streamModel)
(Stream.mapM (\e -> threadDelay 20000 >> pure e) (Stream.fromList events))
assertBool
("expected a wall-clock latency of at least 20ms, got: " <> show (resp ^. #latencyMs))
(resp ^. #latencyMs >= 20)
later :: UTCTime
later = read "2000-01-01 00:00:01 UTC"
muchLater :: UTCTime
muchLater = read "2000-01-01 00:00:03 UTC"