baikai-0.6.0.0: test/TraceSpec.hs
module TraceSpec (tests) where
import Baikai.Api (Api (..))
import Baikai.Content (AssistantContent (..), TextContent (..))
import Baikai.Context (Context (..), emptyContext)
import Baikai.Error (BaikaiError, ErrorCategory (..), providerError)
import Baikai.Evidence
( ModelCallEvidence,
TransportKind (..),
evidenceRequest,
noThinkingRequested,
)
import Baikai.Evidence qualified as Ev
import Baikai.Evidence.Build qualified as Build
import Baikai.Message (AssistantPayload (..), user)
import Baikai.Model (Model (..), emptyModel)
import Baikai.Options (Options, emptyOptions)
import Baikai.Prelude
import Baikai.Provider (apiProviderWith, registerApiProvider)
import Baikai.Response (Response (..), responseError)
import Baikai.StopReason (StopReason (..))
import Baikai.Stream (liftCompleteToStream)
import Baikai.Stream.Event (AssistantMessageEvent (..))
import Baikai.ThinkingLevel (ThinkingLevel (..))
import Baikai.Trace (withTrace, withTraceStream)
import Baikai.Trace.Event (TraceEvent (..))
import Baikai.Trace.Sink (TraceSink (..), multiSink, silent)
import Baikai.Usage (Usage, zeroUsage)
import Control.Concurrent (forkIO, threadDelay, throwTo)
import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, readMVar, takeMVar)
import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO)
import Control.Exception (AsyncException (ThreadKilled), SomeException, throwIO, try)
import Control.Monad (forM_, replicateM)
import Data.Aeson (Value (..))
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Lazy.Char8 qualified as BL8
import Data.Either (isLeft)
import Data.List (findIndex)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TextEncoding
import Data.Text.IO qualified as Text.IO
import Data.Time (UTCTime, getCurrentTime)
import Data.Vector qualified as V
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream qualified as Stream
import System.Mem (performMajorGC)
import System.Timeout (timeout)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
tests :: TestTree
tests =
testGroup
"Baikai.Trace"
[ silentTest,
memoryFinishTest,
memoryFailTest,
throwingSinkTest,
blockingSinkTest,
blockingSinkStrictTest,
multiSinkThrowingMemberTest,
multiSinkBlockingMemberTest,
multiSinkStrictNamesMemberTest,
terminalPathAtomicityTest,
throwToAroundTerminalTest,
eventIdUniquenessTest,
earlyAbortTest,
fidelityTest,
evidenceTests,
requestedLevelTests,
encodingTests
]
-- | Each test uses its own private 'Api' tag so tasty's parallel
-- test scheduler cannot race the (process-global) registry between
-- tests.
stubModel :: Api -> Model
stubModel a =
emptyModel
& #modelId
.~ "stub-1"
& #api
.~ a
& #provider
.~ "stub.trace"
& #maxOutputTokens
.~ 16
stubContext :: Context
stubContext = emptyContext & #messages .~ V.fromList [user "hello"]
stubOptions :: Options
stubOptions = emptyOptions & #maxTokens .~ Just 16
stubResponse :: Api -> Response
stubResponse a =
Response
{ message =
AssistantPayload
{ content = V.singleton (AssistantText (TextContent "hi")),
usage = zeroUsage,
stopReason = Stop,
errorMessage = Nothing,
timestamp = Just (read "2026-05-14 00:00:00 UTC")
},
model = stubModel a,
api = a,
provider = "stub.trace",
responseId = Nothing,
latencyMs = 0,
errorInfo = Nothing,
evidence = Nothing
}
registerOk :: Api -> IO ()
registerOk a =
let handler _m _ctx _opts = pure (stubResponse a)
in registerApiProvider
( apiProviderWith
a
(liftCompleteToStream handler)
(handler)
)
registerFail :: Api -> BaikaiError -> IO ()
registerFail a e =
let handler _m _ctx _opts = throwIO e
in registerApiProvider
( apiProviderWith
a
(liftCompleteToStream handler)
(handler)
)
memorySink :: IO (TVar [TraceEvent], TraceSink)
memorySink = do
ref <- newTVarIO []
let step () e = atomically (modifyTVar' ref (e :))
sink = TraceSink (Fold.foldlM' step (pure ()))
pure (ref, sink)
silentTest :: TestTree
silentTest =
testGroup
"silent sink"
[ testCase "returns the response on success" $ do
let a = Custom "baikai-trace-silent-ok"
registerOk a
_ <- withTrace silent (stubModel a) stubContext stubOptions
pure (),
testCase "encodes failure as ErrorReason in the response" $ do
let a = Custom "baikai-trace-silent-fail"
registerFail a (providerError "boom")
resp <- withTrace silent (stubModel a) stubContext stubOptions
let AssistantPayload {stopReason = sr, errorMessage = em} = resp ^. #message
sr @?= ErrorReason
assertBool
("expected errorMessage to mention boom, got: " <> show em)
(maybe False ("boom" `Text.isInfixOf`) em)
]
memoryFinishTest :: TestTree
memoryFinishTest =
testCase "memory sink records CallStarted then CallFinished" $ do
let a = Custom "baikai-trace-memory-ok"
registerOk a
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext stubOptions
rev <- readTVarIO ref
let events = reverse rev
length events @?= 2
case events of
[s@CallStarted {}, f@CallFinished {}] -> do
(s ^. #eventId :: Text) @?= (f ^. #eventId :: Text)
(s ^. #provider :: Text) @?= "stub.trace"
(f ^. #provider :: Text) @?= "stub.trace"
(s ^. #model :: Text) @?= "stub-1"
(f ^. #model :: Text) @?= "stub-1"
_ -> assertFailure ("unexpected event sequence: " <> show events)
memoryFailTest :: TestTree
memoryFailTest =
testCase "memory sink records CallStarted then CallFailed on stream error" $ do
let a = Custom "baikai-trace-memory-fail"
registerFail a (providerError "stub-failure")
(ref, sink) <- memorySink
resp <- withTrace sink (stubModel a) stubContext stubOptions
-- The producer-side failure surfaces as an ErrorReason on the
-- response (no throw) and as CallFailed on the trace sink.
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= ErrorReason
rev <- readTVarIO ref
let events = reverse rev
length events @?= 2
case events of
[s@CallStarted {}, f@CallFailed {errorMessage = msg}] -> do
(s ^. #eventId :: Text) @?= (f ^. #eventId :: Text)
assertBool
("expected error to mention stub-failure, got: " <> show msg)
("stub-failure" `Text.isInfixOf` msg)
_ -> assertFailure ("unexpected event sequence: " <> show events)
throwingSink :: TraceSink
throwingSink =
TraceSink (Fold.drainMapM (\_ -> throwIO (providerError "sink exploded")))
throwingSinkTest :: TestTree
throwingSinkTest =
testCase "a throwing sink cannot hang withTrace" $ do
let a = Custom "baikai-trace-throwing-sink"
registerOk a
result <- timeout 5000000 (withTrace throwingSink (stubModel a) stubContext stubOptions)
case result of
Nothing -> assertFailure "withTrace hung on a throwing sink"
Just resp -> do
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= Stop
-- | A sink that never returns from its first step until released.
blockingSink :: IO (MVar (), TraceSink)
blockingSink = do
release <- newEmptyMVar
pure (release, TraceSink (Fold.drainMapM (\_ -> readMVar release)))
-- | Unfixed, 'finalizeTrace' blocked on the worker forever and the
-- guard below reported the hang. The bound turns a pathological sink
-- into about one second and a stderr line.
blockingSinkTest :: TestTree
blockingSinkTest =
testCase "a sink that blocks forever cannot hold withTrace past the drain bound" $ do
let a = Custom "baikai-trace-blocking-sink"
registerOk a
(release, sink) <- blockingSink
result <- timeout 2000000 (withTrace sink (stubModel a) stubContext stubOptions)
case result of
Nothing -> assertFailure "withTrace hung on a blocking sink"
Just resp -> do
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= Stop
putMVar release ()
-- | A record whose delivery was never confirmed is not one a strict
-- caller can account for, so the stall fails the call through the same
-- path a throwing sink does.
blockingSinkStrictTest :: TestTree
blockingSinkStrictTest =
testCase "a strict call whose sink never confirms delivery fails" $ do
let a = Custom "baikai-trace-blocking-sink-strict"
-- The evidence-building fixture, so the sink is the only reason
-- this call can fail.
registerOkWithEvidence a
(release, sink) <- blockingSink
result <- timeout 2000000 (withTrace sink (stubModel a) stubContext strictOptions)
case result of
Nothing -> assertFailure "withTrace hung on a blocking sink"
Just resp -> do
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= ErrorReason
case responseError resp of
Nothing -> assertFailure "expected the stall to reach the response"
Just be ->
assertBool
("the error names the stall: " <> Text.unpack (be ^. #message))
("did not confirm delivery" `Text.isInfixOf` (be ^. #message))
putMVar release ()
-- | Under 'Fold.tee' the throwing member's exception stopped delivery
-- to the sibling for the rest of the call and skipped its end-of-stream
-- action, so this sibling was empty.
multiSinkThrowingMemberTest :: TestTree
multiSinkThrowingMemberTest =
testCase "a throwing multiSink member does not starve its sibling" $ do
let a = Custom "baikai-trace-multisink-throwing"
registerOk a
(ref, memory) <- memorySink
result <-
timeout
5000000
(withTrace (multiSink [throwingSink, memory]) (stubModel a) stubContext stubOptions)
case result of
Nothing -> assertFailure "withTrace hung on a throwing multiSink member"
Just resp -> do
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= Stop
events <- reverse <$> readTVarIO ref
case events of
[CallStarted {}, CallFinished {}] -> pure ()
other -> assertFailure ("the sibling missed events: " <> show other)
multiSinkBlockingMemberTest :: TestTree
multiSinkBlockingMemberTest =
testCase "a blocking multiSink member does not starve its sibling" $ do
let a = Custom "baikai-trace-multisink-blocking"
registerOk a
(release, blocking) <- blockingSink
(ref, memory) <- memorySink
result <-
timeout
2000000
(withTrace (multiSink [blocking, memory]) (stubModel a) stubContext stubOptions)
case result of
Nothing -> assertFailure "withTrace hung on a blocking multiSink member"
Just resp -> do
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= Stop
events <- reverse <$> readTVarIO ref
case events of
[CallStarted {}, CallFinished {}] -> pure ()
other -> assertFailure ("the sibling missed events: " <> show other)
putMVar release ()
-- | The aggregate failure has to say /which/ member failed, or an
-- operator with three sinks learns only that tracing broke.
multiSinkStrictNamesMemberTest :: TestTree
multiSinkStrictNamesMemberTest =
testCase "a strict call names the multiSink member that failed" $ do
let a = Custom "baikai-trace-multisink-strict"
registerOkWithEvidence a
(_ref, memory) <- memorySink
result <-
timeout
5000000
(withTrace (multiSink [throwingSink, memory]) (stubModel a) stubContext strictOptions)
case result of
Nothing -> assertFailure "withTrace hung on a throwing multiSink member"
Just resp -> do
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= ErrorReason
case responseError resp of
Nothing -> assertFailure "expected the member failure to reach the response"
Just be -> do
let msg = be ^. #message
assertBool
("the error names the member index: " <> Text.unpack msg)
("member 0" `Text.isInfixOf` msg)
assertBool
("the error carries the member's own message: " <> Text.unpack msg)
("sink exploded" `Text.isInfixOf` msg)
-- | A memory sink that parks on the terminal event until released.
--
-- The park is what makes the atomicity test deterministic: when
-- @parked@ is filled the consumer has already run 'commitTerminal' to
-- completion and is waiting for the worker, which is exactly the moment
-- an asynchronous exception used to leave a half-committed terminal
-- behind.
gatedSink :: IO (TVar [TraceEvent], MVar (), MVar (), TraceSink)
gatedSink = do
ref <- newTVarIO []
parked <- newEmptyMVar
release <- newEmptyMVar
let step () e = do
atomically (modifyTVar' ref (e :))
case e of
CallFinished {} -> putMVar parked () >> readMVar release
_ -> pure ()
sink = TraceSink (Fold.foldlM' step (pure ()))
pure (ref, parked, release, sink)
-- | Kill the consumer while it waits for a sink that has already taken
-- the terminal. The stream's exception path runs the trace finaliser a
-- second time, and it must find nothing left to do: one evidence
-- record, one terminal, and no synthetic @aborted@ 'CallFailed' on top
-- of the real 'CallFinished'.
terminalPathAtomicityTest :: TestTree
terminalPathAtomicityTest =
testCase "an async exception on the terminal path leaves one terminal and one evidence" $ do
let a = Custom "baikai-trace-terminal-atomicity"
registerOkWithEvidence a
(ref, parked, release, sink) <- gatedSink
outcome <- newEmptyMVar
consumer <- forkIO $ do
r <- try (withTrace sink (stubModel a) stubContext evidenceOptions)
putMVar outcome (r :: Either SomeException Response)
takeMVar parked
throwTo consumer ThreadKilled
r <- takeMVar outcome
assertBool "the consumer was killed" (isLeft r)
putMVar release ()
events <- awaitEvents ref 3
length [e | e@CallEvidence {} <- events] @?= 1
length [e | e@CallFinished {} <- events] @?= 1
length [e | e@CallFailed {} <- events] @?= 0
-- | Aim an asynchronous exception at the consumer the instant the
-- evidence event reaches the sink — while the consumer is pushing the
-- terminal and setting the flag. Fifty times, because the window is a
-- few instructions wide and no scheduling hook can hit it
-- deterministically; the plan's widened-window demonstration shows the
-- test detects the defect.
throwToAroundTerminalTest :: TestTree
throwToAroundTerminalTest =
testCase "fifty exceptions aimed at the terminal push never duplicate terminal or evidence" $
forM_ [1 .. 50 :: Int] $ \i -> do
let a = Custom ("baikai-trace-throwto-" <> Text.pack (show i))
registerOkWithEvidence a
ref <- newTVarIO []
consumerVar <- newEmptyMVar
let step () e = do
atomically (modifyTVar' ref (e :))
case e of
CallEvidence {} -> readMVar consumerVar >>= \tid -> throwTo tid ThreadKilled
_ -> pure ()
sink = TraceSink (Fold.foldlM' step (pure ()))
outcome <- newEmptyMVar
tid <- forkIO $ do
r <- try (withTrace sink (stubModel a) stubContext evidenceOptions)
putMVar outcome (r :: Either SomeException Response)
putMVar consumerVar tid
_ <- takeMVar outcome
_ <- awaitEvents ref 3
-- Let anything the finaliser might still push arrive before counting.
threadDelay 200000
performMajorGC
settled <- reverse <$> readTVarIO ref
length [e | e@CallEvidence {} <- settled] @?= 1
length [e | e@CallFinished {} <- settled] + length [e | e@CallFailed {} <- settled] @?= 1
-- | The length assertion here used to read
-- @assertBool "every id is 16 chars" (all ((== 16) . Text.length) ids)@.
-- It reads 32 because 'Baikai.Evidence.newCallId', which replaced the
-- removed @newEventId@, carries 128 bits rather than 64. The widening
-- is the point of the replacement: the old generator packed a
-- process-start /second/ into its high half and so repeated itself
-- across processes started in the same second.
eventIdUniquenessTest :: TestTree
eventIdUniquenessTest =
testCase "newCallId yields 70000 distinct 32-char ids" $ do
ids <- replicateM 70000 Ev.newCallId
Set.size (Set.fromList ids) @?= 70000
assertBool "every id is 32 chars" (all ((== 32) . Text.length) ids)
earlyAbortTest :: TestTree
earlyAbortTest =
testCase "early abort pushes a synthetic CallFailed" $ do
let a = Custom "baikai-trace-abort"
registerOk a
(ref, sink) <- memorySink
emitted <-
Stream.toList
(Stream.take 1 (withTraceStream sink (stubModel a) stubContext stubOptions))
length emitted @?= 1
events <- awaitEvents ref 2
case events of
[s@CallStarted {}, f@CallFailed {errorMessage = msg}] -> do
(s ^. #eventId :: Text) @?= (f ^. #eventId :: Text)
assertBool
("expected abort message, got: " <> show msg)
("aborted" `Text.isInfixOf` msg)
_ -> assertFailure ("unexpected event sequence: " <> show events)
-- The trace finalizer on an abandoned stream runs from streamly's GC hook.
awaitEvents :: TVar [TraceEvent] -> Int -> IO [TraceEvent]
awaitEvents ref n = go (100 :: Int)
where
go 0 = do
evs <- readTVarIO ref
assertFailure ("timed out waiting for trace events; got: " <> show (reverse evs))
go k = do
performMajorGC
evs <- readTVarIO ref
if length evs >= n
then pure (reverse evs)
else threadDelay 50000 >> go (k - 1)
-- ============================================================
-- Usage and cost fidelity
-- ============================================================
-- | Usage with every disjoint token class populated, so a trace event
-- that drops one is visible rather than merely zero.
richUsage :: Usage
richUsage =
zeroUsage
& #inputTokens
.~ 11
& #outputTokens
.~ 7
& #cacheReadTokens
.~ 5
& #cacheWriteTokens
.~ 3
& #reasoningTokens
.~ Just 4
& #totalTokens
.~ 26
registerWithUsage :: Api -> Usage -> IO ()
registerWithUsage a u =
let resp = stubResponse a & #message . #usage .~ u
handler _m _ctx _opts = pure resp
in registerApiProvider
( apiProviderWith
a
(liftCompleteToStream handler)
(handler)
)
fidelityTest :: TestTree
fidelityTest =
testGroup
"CallFinished fidelity"
[ testCase "carries the full disjoint token breakdown" $ do
let a = Custom "baikai-trace-usage-fidelity"
registerWithUsage a richUsage
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext stubOptions
events <- reverse <$> readTVarIO ref
-- Read through record patterns, not '#field' labels:
-- generic-lens only resolves a label present on every
-- constructor of the sum, and these five are on 'CallFinished'
-- alone.
case [f | f@CallFinished {} <- events] of
[ CallFinished
{ inputTokens,
outputTokens,
cachedInputTokens,
cacheWriteTokens,
reasoningTokens,
totalTokens
}
] -> do
inputTokens @?= Just 11
outputTokens @?= Just 7
cachedInputTokens @?= Just 5
cacheWriteTokens @?= Just 3
reasoningTokens @?= Just 4
totalTokens @?= Just 26
other -> assertFailure ("expected one CallFinished, got: " <> show other),
-- A zero cost used to be suppressed, which made "this call was
-- free" indistinguishable from "baikai could not price this
-- call". The CLI providers always price at zero, so that was the
-- common case rather than a corner.
testCase "reports a zero cost as zero rather than omitting it" $ do
let a = Custom "baikai-trace-zero-cost"
registerOk a
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext stubOptions
events <- reverse <$> readTVarIO ref
case [f | f@CallFinished {} <- events] of
[f@CallFinished {usd}] -> do
usd @?= Just 0
assertBool
"usd must be present in the encoded JSON, not dropped by omitNothingFields"
(KeyMap.member "usd" (asObject (Aeson.toJSON f)))
other -> assertFailure ("expected one CallFinished, got: " <> show other)
]
-- ============================================================
-- Evidence emission
-- ============================================================
-- | The same options every other test in this module uses, plus an
-- evidence request. A call that emits no evidence cannot prove an
-- exactly-once guarantee about evidence, so every emission case below
-- opts in.
evidenceOptions :: Options
evidenceOptions = stubOptions & #evidence .~ Just (evidenceRequest "run-52")
-- | A fixture provider that builds evidence the way a real adapter
-- does: it hands 'Build.minimalEvidence' the envelope it would have
-- sent and attaches the result to its 'Response', which
-- 'liftCompleteToStream' then carries onto the terminal event.
--
-- 'registerOk' deliberately does not, because most of this module's
-- tests are about the trace path rather than the evidence path, and a
-- provider that builds no evidence is the honest model of one that has
-- not been taught to.
registerOkWithEvidence :: Api -> IO ()
registerOkWithEvidence a =
let handler m _ctx opts = do
now <- getCurrentTime
ev <-
Build.minimalEvidence
m
opts
TransportHttpApi
noThinkingRequested
(Aeson.object ["model" Aeson..= (m ^. #modelId :: Text)])
now
now
Ev.CallSucceeded
Nothing
pure (stubResponse a & #evidence .~ ev)
in registerApiProvider
( apiProviderWith
a
(liftCompleteToStream handler)
(handler)
)
-- | 'registerOk' with an honest describer.
--
-- The other fixtures answer 'noThinkingRequested' whatever the caller
-- set, which is exactly what hid the defect these tests pin: a stub
-- that always says "nothing was asked" cannot tell a path that lost the
-- caller's level from one that kept it.
registerOkHonest :: Api -> IO ()
registerOkHonest a =
let handler _m _ctx _opts = pure (stubResponse a)
in registerApiProvider
( apiProviderWith
a
(liftCompleteToStream handler)
(handler)
& #describeThinking
.~ (\_ o -> Build.requestedTranslation o)
)
-- | A describer that answers with a wire shape of its own, so a test
-- can tell "the core asked the adapter" from "the core spelled
-- not_translated itself".
registerOkBudgetDescriber :: Api -> IO ()
registerOkBudgetDescriber a =
let handler _m _ctx _opts = pure (stubResponse a)
budgetTranslation o =
Ev.ThinkingTranslation
{ Ev.requested = o ^. #thinking,
Ev.mode = Ev.ThinkingModeBudget,
Ev.effortText = Nothing,
Ev.budgetTokens = Just 1024,
Ev.wireField = Just "thinking",
Ev.adjustments = []
}
in registerApiProvider
( apiProviderWith
a
(liftCompleteToStream handler)
(handler)
& #describeThinking
.~ (\_ o -> budgetTranslation o)
)
thinkingOptions :: Options
thinkingOptions = evidenceOptions & #thinking .~ Just ThinkingMax
-- | Read one key out of the encoded @thinking@ object.
thinkingField :: Text -> ModelCallEvidence -> Maybe Value
thinkingField k ev =
KeyMap.lookup (Key.fromText k) (asObject (maybe Null id (evidenceField "thinking" ev)))
requestedLevelTests :: TestTree
requestedLevelTests =
testGroup
"the caller's thinking level on every evidence path"
[ abortRecordsRequestedLevelTest,
abortUsesTheAdapterDescriberTest,
noProviderRecordsRequestedLevelTest,
throwingHandlerRecordsRequestedLevelTest
]
abortRecordsRequestedLevelTest :: TestTree
abortRecordsRequestedLevelTest =
testCase "an abandoned stream records the level the caller asked for" $ do
let a = Custom "baikai-trace-abort-thinking"
registerOkHonest a
(ref, sink) <- memorySink
emitted <-
Stream.toList
(Stream.take 1 (withTraceStream sink (stubModel a) stubContext thinkingOptions))
length emitted @?= 1
events <- awaitEvents ref 3
ev <- exactlyOneEvidence events
thinkingField "requested" ev @?= Just (String "max")
thinkingField "mode" ev @?= Just (String "not_translated")
abortUsesTheAdapterDescriberTest :: TestTree
abortUsesTheAdapterDescriberTest =
testCase "an abandoned stream asks the registered adapter to describe the translation" $ do
let a = Custom "baikai-trace-abort-describer"
registerOkBudgetDescriber a
(ref, sink) <- memorySink
emitted <-
Stream.toList
(Stream.take 1 (withTraceStream sink (stubModel a) stubContext thinkingOptions))
length emitted @?= 1
events <- awaitEvents ref 3
ev <- exactlyOneEvidence events
thinkingField "requested" ev @?= Just (String "max")
-- The proof that the core consulted the adapter rather than
-- spelling not_translated unconditionally.
thinkingField "mode" ev @?= Just (String "budget")
thinkingField "budget_tokens" ev @?= Just (Number 1024)
noProviderRecordsRequestedLevelTest :: TestTree
noProviderRecordsRequestedLevelTest =
testCase "an unregistered provider records the level the caller asked for" $ do
let a = Custom "baikai-trace-unregistered-thinking"
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext thinkingOptions
events <- awaitEvents ref 3
ev <- exactlyOneEvidence events
thinkingField "requested" ev @?= Just (String "max")
thinkingField "mode" ev @?= Just (String "not_translated")
throwingHandlerRecordsRequestedLevelTest :: TestTree
throwingHandlerRecordsRequestedLevelTest =
testCase "a handler that threw records the level the caller asked for" $ do
let a = Custom "baikai-trace-throwing-thinking"
registerFail a (providerError "stub-failure")
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext thinkingOptions
events <- awaitEvents ref 3
ev <- exactlyOneEvidence events
thinkingField "requested" ev @?= Just (String "max")
thinkingField "mode" ev @?= Just (String "not_translated")
evidenceField "status" ev @?= Just (String "failed")
evidencesIn :: [TraceEvent] -> [ModelCallEvidence]
evidencesIn events = [ev | CallEvidence {evidence = ev} <- events]
asObject :: Value -> Aeson.Object
asObject = \case
Object o -> o
_ -> KeyMap.empty
-- | Read one field out of an encoded evidence record.
--
-- Deliberately through the JSON rather than through a Haskell record
-- pattern: the encoded form is the contract other systems pin against,
-- and it is the thing that must not drift. It also spells fields in
-- snake_case, which a Haskell mirror would silently paper over.
evidenceField :: Text -> ModelCallEvidence -> Maybe Value
evidenceField k ev = KeyMap.lookup (Key.fromText k) (asObject (Aeson.toJSON ev))
evidenceTests :: TestTree
evidenceTests =
testGroup
"model-call evidence"
[ successEvidenceTest,
failureEvidenceTest,
abortEvidenceTest,
noProviderEvidenceTest,
sinkFailureEvidenceTest,
strictSinkFailureTest,
strictSinkFailureIsStillOneTerminalTest,
optOutSilentTest,
optOutGoldenTest,
envelopeNotForcedTest,
strictNoRecordFailsTest,
strictNoRecordIsOneTerminalTest,
strictWithRecordSucceedsTest,
strictNoRecordErrorPathKeepsProviderErrorTest,
bestEffortNoRecordStillSucceedsTest
]
-- | Assert the shape every record this plan produces must have: the
-- channel works, and nothing was backfilled from the request.
assertMinimalShape :: ModelCallEvidence -> IO ()
assertMinimalShape ev = do
evidenceField "schema_version" ev @?= Just (String Ev.evidenceSchemaVersion)
evidenceField "run_id" ev @?= Just (String "run-52")
evidenceField "requested_model" ev @?= Just (String "stub-1")
evidenceField "strength" ev @?= Just (String "requested_only")
evidenceField "observed_model" ev @?= Just (String "unobserved")
evidenceField "response_id" ev @?= Just (String "unobserved")
evidenceField "provider_request_id" ev @?= Just (String "unobserved")
assertDigest "request_commitment" ev
assertDigest "request_configuration" ev
where
assertDigest k e = case evidenceField k e of
Just (String d) ->
assertBool
(Text.unpack k <> " must be a sha256 digest, got: " <> show d)
("sha256:" `Text.isPrefixOf` d && Text.length d == 71)
other -> assertFailure (Text.unpack k <> " missing or not a string: " <> show other)
-- | Exactly one evidence record per call, joined to the rest of the
-- call's lines by the trace @eventId@.
-- | The record must reach the sink while the call is still open there.
--
-- "Baikai.Trace" pushes 'CallEvidence' before the terminal since commit
-- @1717694@, because the OpenTelemetry sink ends and removes its span on
-- the terminal and so could never attach evidence that arrived after it.
-- 'docs\/capabilities\/model-call-evidence.md' claimed an ordering
-- assertion existed; this is it, and every evidence case runs it on its
-- own path — success, failure, abort, unregistered provider.
assertEvidencePrecedesTerminal :: [TraceEvent] -> IO ()
assertEvidencePrecedesTerminal events =
case (findIndex isEvidence events, findIndex isTerminal events) of
(Just i, Just j) ->
assertBool
("CallEvidence at " <> show i <> " must precede the terminal at " <> show j)
(i < j)
(Just _, Nothing) -> assertFailure "an evidence event without a terminal"
_ -> assertFailure "no evidence event to order"
where
isEvidence = \case CallEvidence {} -> True; _ -> False
isTerminal = \case
CallFinished {} -> True
CallFailed {} -> True
_ -> False
exactlyOneEvidence :: [TraceEvent] -> IO ModelCallEvidence
exactlyOneEvidence events = case evidencesIn events of
[ev] -> do
let ids = Set.fromList [e ^. #eventId :: Text | e <- events]
Set.size ids @?= 1
assertMinimalShape ev
assertEvidencePrecedesTerminal events
pure ev
other ->
assertFailure
("expected exactly one CallEvidence, got " <> show (length other) <> ": " <> show events)
successEvidenceTest :: TestTree
successEvidenceTest =
testCase "a successful call emits one evidence record with status succeeded" $ do
let a = Custom "baikai-evidence-success"
registerOkWithEvidence a
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext evidenceOptions
events <- reverse <$> readTVarIO ref
ev <- exactlyOneEvidence events
evidenceField "status" ev @?= Just (String "succeeded")
evidenceField "error_info" ev @?= Just Null
-- Purely additive: the pre-existing contract is untouched.
length [e | e@CallStarted {} <- events] @?= 1
length [e | e@CallFinished {} <- events] @?= 1
length [e | e@CallFailed {} <- events] @?= 0
failureEvidenceTest :: TestTree
failureEvidenceTest =
testCase "a failed call emits one evidence record with status failed" $ do
let a = Custom "baikai-evidence-failure"
registerFail a (providerError "stub-failure")
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext evidenceOptions
events <- reverse <$> readTVarIO ref
ev <- exactlyOneEvidence events
evidenceField "status" ev @?= Just (String "failed")
case evidenceField "error_info" ev of
Just (Object o) ->
assertBool
("expected error_info to mention stub-failure, got: " <> show o)
(maybe False (Text.isInfixOf "stub-failure" . renderString) (KeyMap.lookup "message" o))
other -> assertFailure ("expected a populated error_info, got: " <> show other)
length [e | e@CallStarted {} <- events] @?= 1
length [e | e@CallFailed {} <- events] @?= 1
where
renderString = \case
String t -> t
v -> Text.pack (show v)
abortEvidenceTest :: TestTree
abortEvidenceTest =
testCase "an abandoned stream emits one evidence record with status aborted" $ do
let a = Custom "baikai-evidence-abort"
registerOk a
(ref, sink) <- memorySink
emitted <-
Stream.toList
(Stream.take 1 (withTraceStream sink (stubModel a) stubContext evidenceOptions))
length emitted @?= 1
events <- awaitEvents ref 3
ev <- exactlyOneEvidence events
-- 'aborted', not 'failed'. The consumer stopped reading; reporting
-- that as a provider failure would misattribute it.
evidenceField "status" ev @?= Just (String "aborted")
noProviderEvidenceTest :: TestTree
noProviderEvidenceTest =
testCase "an unregistered provider emits one evidence record with status failed" $ do
let a = Custom "baikai-evidence-no-provider"
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext evidenceOptions
events <- reverse <$> readTVarIO ref
ev <- exactlyOneEvidence events
evidenceField "status" ev @?= Just (String "failed")
sinkFailureEvidenceTest :: TestTree
sinkFailureEvidenceTest =
testCase "a throwing sink does not fail an opted-in best-effort call" $ do
let a = Custom "baikai-evidence-throwing-sink"
registerOk a
result <-
timeout 5000000 (withTrace throwingSink (stubModel a) stubContext evidenceOptions)
case result of
Nothing -> assertFailure "withTrace hung on a throwing sink"
Just resp -> do
-- Unchanged, and it is the guarantee every existing caller
-- depends on: the exception does not propagate and the call
-- succeeds. Only a strict caller gets the opposite; see
-- 'strictSinkFailureTest' below.
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= Stop
-- | The one place in baikai where a call that reached the provider and
-- came back is nevertheless reported as failed.
strictSinkFailureTest :: TestTree
strictSinkFailureTest =
testCase "A STRICT CALL WHOSE SINK THREW FAILS, RATHER THAN SUCCEEDING SILENTLY" $ do
-- A strict caller asked for a record of this call and the record did
-- not survive. Handing them the answer anyway would give them
-- something they cannot account for, with no way to notice: evidence
-- that can vanish without the caller noticing is not evidence.
let a = Custom "baikai-evidence-strict-throwing-sink"
-- The evidence-building fixture, so the sink is the only reason this
-- call can fail. With a provider that attaches no record, a strict
-- call now fails on that account before the sink is ever reached,
-- and this case would assert the sink rule against the record rule.
registerOkWithEvidence a
result <-
timeout 5000000 (withTrace throwingSink (stubModel a) stubContext strictOptions)
case result of
Nothing -> assertFailure "withTrace hung on a throwing sink"
Just resp -> do
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= ErrorReason
case responseError resp of
Nothing -> assertFailure "expected the sink failure to reach the response"
Just be ->
assertBool
("the error names the sink: " <> Text.unpack (be ^. #message))
("trace sink failed" `Text.isInfixOf` (be ^. #message))
-- | Strict mode guaranteed that a record which was built and then lost
-- fails the call. It did not guarantee that one was built: a provider
-- that attached nothing returned a successful response and wrote no
-- @call_evidence@ line, with no error anywhere.
strictNoRecordFailsTest :: TestTree
strictNoRecordFailsTest =
testCase "A STRICT CALL WHOSE PROVIDER ATTACHED NO RECORD FAILS, AND EMITS NO RECORD" $ do
let a = Custom "baikai-evidence-strict-no-record"
registerOk a
(ref, sink) <- memorySink
resp <- withTrace sink (stubModel a) stubContext strictOptions
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= ErrorReason
case responseError resp of
Nothing -> assertFailure "expected the missing record to fail the call"
Just be -> do
be ^. #category @?= OtherError
assertBool
("the error names the missing record: " <> Text.unpack (be ^. #message))
("attached no evidence record" `Text.isInfixOf` (be ^. #message))
events <- awaitEvents ref 2
length [e | e@CallStarted {} <- events] @?= 1
length [e | e@CallFailed {} <- events] @?= 1
length (evidencesIn events) @?= 0
-- | The rewrite produces one terminal, not two.
strictNoRecordIsOneTerminalTest :: TestTree
strictNoRecordIsOneTerminalTest =
testCase "a record-less strict stream yields one EventError and no EventDone" $ do
let a = Custom "baikai-evidence-strict-no-record-stream"
registerOk a
events <-
Stream.toList (withTraceStream silent (stubModel a) stubContext strictOptions)
length [e | e@(EventDone _) <- events] @?= 0
length [e | e@(EventError _) <- events] @?= 1
-- | The rewrite fires on the absence of a record, not on strictness
-- alone.
strictWithRecordSucceedsTest :: TestTree
strictWithRecordSucceedsTest =
testCase "a strict call whose provider attached a record still succeeds" $ do
let a = Custom "baikai-evidence-strict-with-record"
registerOkWithEvidence a
(ref, sink) <- memorySink
resp <- withTrace sink (stubModel a) stubContext strictOptions
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= Stop
responseError resp @?= Nothing
events <- awaitEvents ref 3
length (evidencesIn events) @?= 1
-- | On the error path the provider's own error is the more useful of
-- the two, and the strict contract already holds: the call failed.
strictNoRecordErrorPathKeepsProviderErrorTest :: TestTree
strictNoRecordErrorPathKeepsProviderErrorTest =
testCase "a failed strict call keeps the provider's own error" $ do
let a = Custom "baikai-evidence-strict-provider-error"
registerFail a (providerError "stub-failure")
resp <- withTrace silent (stubModel a) stubContext strictOptions
case responseError resp of
Nothing -> assertFailure "expected the provider's failure to reach the response"
Just be -> do
assertBool
("the provider's error survives: " <> Text.unpack (be ^. #message))
("stub-failure" `Text.isInfixOf` (be ^. #message))
assertBool
"the missing-record error must not overwrite it"
(not ("attached no evidence record" `Text.isInfixOf` (be ^. #message)))
-- | Best effort never refuses, here as everywhere.
bestEffortNoRecordStillSucceedsTest :: TestTree
bestEffortNoRecordStillSucceedsTest =
testCase "a best-effort call whose provider attached no record still succeeds" $ do
let a = Custom "baikai-evidence-best-effort-no-record"
registerOk a
resp <- withTrace silent (stubModel a) stubContext evidenceOptions
let AssistantPayload {stopReason = sr} = resp ^. #message
sr @?= Stop
responseError resp @?= Nothing
-- | The exactly-once guarantee still holds when the terminal is
-- rewritten.
strictSinkFailureIsStillOneTerminalTest :: TestTree
strictSinkFailureIsStillOneTerminalTest =
testCase "a rewritten terminal is still exactly one terminal event" $ do
let a = Custom "baikai-evidence-strict-sink-terminal"
-- The evidence-building fixture, so the "survives the rewrite"
-- assertion below has something to survive.
registerOkWithEvidence a
events <-
Stream.toList (withTraceStream throwingSink (stubModel a) stubContext strictOptions)
length [e | e@(EventDone _) <- events] @?= 0
length [e | e@(EventError _) <- events] @?= 1
-- The evidence the provider built survives the rewrite. It is
-- exactly what a caller investigating this failure wants to read.
case [p | EventError p <- events] of
[p] -> assertBool "the evidence survives" (p ^. #evidence /= Nothing)
other -> assertFailure ("expected one terminal, got: " <> show (length other))
strictOptions :: Options
strictOptions =
stubOptions
& #evidence
.~ Just
( evidenceRequest "run-57"
& #strictness
.~ Ev.EvidenceRequired Ev.EvidenceRequestedOnly
)
-- | The criterion that protects every existing user of this library.
optOutSilentTest :: TestTree
optOutSilentTest =
testCase "a call with no evidence request emits no evidence and traces identically" $ do
let a = Custom "baikai-evidence-opt-out"
registerOkWithEvidence a
(outRef, outSink) <- memorySink
_ <- withTrace outSink (stubModel a) stubContext stubOptions
optedOut <- reverse <$> readTVarIO outRef
(inRef, inSink) <- memorySink
_ <- withTrace inSink (stubModel a) stubContext evidenceOptions
optedIn <- reverse <$> readTVarIO inRef
evidencesIn optedOut @?= []
length (evidencesIn optedIn) @?= 1
-- Asking for evidence adds an event and changes nothing else.
map redact optedOut @?= map redact [e | e <- optedIn, notEvidence e]
where
notEvidence = \case
CallEvidence {} -> False
_ -> True
-- | Encode an event the way a sink does, then blank the fields that
-- legitimately differ between two runs of the same call.
--
-- Deliberately textual rather than a rewrite of the decoded
-- 'Aeson.Value': 'Aeson.toJSON' produces a 'KeyMap' whose re-encoding
-- sorts keys, and field /order/ is part of what an existing consumer
-- sees. Comparing sorted objects would hide exactly the drift this is
-- here to catch.
--
-- The three redacted values are a hex identifier, an ISO-8601
-- timestamp, and an integer; none can contain a @,@ or @}@, so scanning
-- to the next one is a safe way to find the end of the value.
redact :: TraceEvent -> Text
redact =
redactField "latencyMs" "0"
. redactField "timestamp" "\"<ts>\""
. redactField "eventId" "\"<id>\""
. TextEncoding.decodeUtf8
. BL8.toStrict
. Aeson.encode
redactField :: Text -> Text -> Text -> Text
redactField key replacement line
| Text.null rest = line
| otherwise = before <> needle <> replacement <> Text.dropWhile isValueChar after
where
needle = "\"" <> key <> "\":"
(before, rest) = Text.breakOn needle line
after = Text.drop (Text.length needle) rest
isValueChar c = c /= ',' && c /= '}'
-- | The golden fixture is the encoded event sequence an opted-out call
-- produces, recorded against
-- @baikai\/test\/fixtures\/trace-opt-out.jsonl@.
--
-- Its content was checked against the pre-plan code rather than
-- asserted from memory: the same fixture provider was run at commit
-- @0acbad8@ (the last commit before this plan touched the trace path)
-- and the two @call_started@ lines match exactly, while @call_finished@
-- differs only by the four token fields and the @usd@ field this plan
-- deliberately added. Nothing else moved, and no @call_evidence@ line
-- appears.
--
-- If this test fails, an opted-out caller's trace output changed. That
-- is a breaking change for every existing user of this library and
-- needs a changelog entry, not a new fixture pasted over the old one.
optOutGoldenTest :: TestTree
optOutGoldenTest =
testCase "an opted-out call's trace bytes match the golden fixture" $ do
let a = Custom "baikai-evidence-golden"
registerOk a
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext stubOptions
events <- reverse <$> readTVarIO ref
expected <- Text.lines <$> Text.IO.readFile "test/fixtures/trace-opt-out.jsonl"
map redact events @?= filter (not . Text.null) expected
-- | An opted-out call must do no work, not merely produce no output.
--
-- The fixture provider hands 'Build.minimalEvidence' an envelope that
-- throws when forced. If someone later adds a strictness annotation to
-- that parameter, or moves the opt-out check below the digest
-- computation, this test fails and says why.
envelopeNotForcedTest :: TestTree
envelopeNotForcedTest =
testCase "an opted-out call never forces the request envelope" $ do
let a = Custom "baikai-evidence-lazy-envelope"
handler m _ctx opts = do
now <- getCurrentTime
ev <-
Build.minimalEvidence
m
opts
TransportHttpApi
noThinkingRequested
(error "envelope forced on the opt-out path")
now
now
Ev.CallSucceeded
Nothing
pure (stubResponse a & #evidence .~ ev)
registerApiProvider
( apiProviderWith
a
(liftCompleteToStream handler)
(handler)
)
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext stubOptions
events <- reverse <$> readTVarIO ref
evidencesIn events @?= []
-- ============================================================
-- Wire encoding
-- ============================================================
-- | 'FromJSON' is hand-written and therefore can drift from the derived
-- 'ToJSON' without the compiler noticing. It already did once during
-- this plan: the decoder read a nested @data@ object, which aeson's
-- 'TaggedObject' does not produce for a record constructor, so it could
-- not have parsed a single line this package emits.
encodingTests :: TestTree
encodingTests =
testGroup
"TraceEvent JSON"
[ testCase "the three decodable kinds round-trip" $
mapM_ roundTrip [sampleStarted, sampleFinished, sampleFailed],
-- A trace line carries its fields alongside the discriminator,
-- not nested under one. Consumers filter on this shape.
testCase "fields sit alongside the kind discriminator" $ do
let o = asObject (Aeson.toJSON sampleFinished)
KeyMap.lookup "kind" o @?= Just (String "call_finished")
KeyMap.lookup "latencyMs" o @?= Just (Number 12)
assertBool "no data wrapper" (not (KeyMap.member "data" o)),
-- Not a limitation to route around: 'ModelCallEvidence' embeds a
-- cost whose exact Rational cannot survive the Scientific it
-- encodes through, so a decoder would return a different value
-- than was encoded. Failing loudly beats claiming a fidelity the
-- type does not have.
testCase "a call_evidence line refuses to decode, with an explanation" $ do
let a = Custom "baikai-evidence-decode"
registerOkWithEvidence a
(ref, sink) <- memorySink
_ <- withTrace sink (stubModel a) stubContext evidenceOptions
events <- reverse <$> readTVarIO ref
case [e | e@CallEvidence {} <- events] of
[e] -> case Aeson.eitherDecode (Aeson.encode e) :: Either String TraceEvent of
Right decoded -> assertFailure ("expected a decode failure, got: " <> show decoded)
Left err ->
assertBool
("expected the message to point at Data.Aeson.Value, got: " <> err)
("Data.Aeson.Value" `Text.isInfixOf` Text.pack err)
other -> assertFailure ("expected one CallEvidence, got: " <> show other)
]
where
roundTrip e = case Aeson.eitherDecode (Aeson.encode e) of
Right decoded -> decoded @?= e
Left err -> assertFailure ("failed to decode " <> show e <> ": " <> err)
fixedTime :: UTCTime
fixedTime = read "2026-05-14 00:00:00 UTC"
sampleStarted :: TraceEvent
sampleStarted =
CallStarted
{ eventId = "abc",
timestamp = fixedTime,
provider = "stub.trace",
model = "stub-1",
maxTokens = 16,
promptSummary = "hello"
}
sampleFinished :: TraceEvent
sampleFinished =
CallFinished
{ eventId = "abc",
timestamp = fixedTime,
provider = "stub.trace",
model = "stub-1",
latencyMs = 12,
inputTokens = Just 11,
outputTokens = Just 7,
cachedInputTokens = Just 5,
cacheWriteTokens = Just 3,
reasoningTokens = Just 4,
totalTokens = Just 26,
usd = Just 0
}
sampleFailed :: TraceEvent
sampleFailed =
CallFailed
{ eventId = "abc",
timestamp = fixedTime,
provider = "stub.trace",
model = "stub-1",
latencyMs = 12,
errorMessage = "boom"
}