packages feed

baikai-effectful-0.5.0.0: test/TracedStreamSpec.hs

module TracedStreamSpec (tests) where

import Baikai hiding (CallFailed, requested)
import Baikai.Effectful (runBaikai, runBaikaiWith, streamCollectTraced)
import Baikai.Evidence qualified as Ev
import Baikai.Prelude
import Baikai.Trace (TraceEvent (..), TraceSink (..))
import Control.Concurrent (forkIO, throwTo)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar, takeMVar, tryPutMVar, tryReadMVar)
import Control.Exception (Exception (..), SomeException, asyncExceptionFromException, asyncExceptionToException, finally, fromException, throwIO, try)
import Control.Monad (void, when)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Maybe (isJust)
import Effectful (runEff)
import GHC.Clock (getMonotonicTimeNSec)
import Streamly.Data.Fold qualified as Fold
import StubProvider
import System.Timeout (timeout)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

newtype Cancelled = Cancelled Int
  deriving stock (Eq, Show)

instance Exception Cancelled where
  toException = asyncExceptionToException
  fromException = asyncExceptionFromException

requested :: Options
requested = stubOptions & #evidence .~ Just (evidenceRequest "effectful-abort")

strictOptions :: Options
strictOptions = requested & #evidence . _Just . #strictness .~ EvidenceRequired EvidenceRequestedOnly

memorySink :: IO (IORef [TraceEvent], TraceSink)
memorySink = do
  cell <- newIORef []
  pure (cell, TraceSink (Fold.drainMapM (\e -> modifyIORef' cell (e :))))

records :: [TraceEvent] -> [ModelCallEvidence]
records events = [ev | CallEvidence {evidence = ev} <- events]

tests :: TestTree
tests =
  testGroup
    "TracedStreamSpec"
    [ terminalTest False,
      terminalTest True,
      testCase "strict refusal precedes dispatch and preserves terminal evidence" $ do
        (reg, count) <- evidenceRegistry False
        (cell, sink) <- memorySink
        let opts = requested & #evidence . _Just . #strictness .~ EvidenceRequired EvidenceCorrelated
        events <- runEff . runBaikaiWith reg $ streamCollectTraced sink stubModel stubContext opts
        count >>= (@?= 0)
        traces <- records <$> readIORef cell
        let terminalRecords = [ev | EventError p <- events, Just ev <- [p ^. #evidence]]
        traces @?= terminalRecords
        length traces @?= 1
        map (^. #status) traces @?= [Ev.CallFailed]
        assertBool "refusal error recorded" (all (isJust . (^. #errorInfo)) traces),
      testCase "opt-out emits no evidence" $ do
        (reg, _) <- evidenceRegistry False
        (cell, sink) <- memorySink
        _ <- runEff . runBaikaiWith reg $ streamCollectTraced sink stubModel stubContext stubOptions
        readIORef cell >>= ((@?= []) . records),
      testCase "a provider without evidence keeps strict missing-record enforcement" $ do
        reg <- stubRegistry "legacy"
        (cell, sink) <- memorySink
        events <- runEff . runBaikaiWith reg $ streamCollectTraced sink stubModel stubContext strictOptions
        assertBool "missing record fails" (any (\case EventError {} -> True; _ -> False) events)
        readIORef cell >>= ((@?= []) . records),
      testCase "global interpreter delegates traced collection" $ do
        let model = stubModel & #api .~ Custom "effectful-traced-global-102"
            handler _ _ _ = pure emptyResponse
        registerApiProvider (apiProviderWith (model ^. #api) (liftCompleteToStream handler) handler)
        (cell, sink) <- memorySink
        events <- runEff . runBaikai $ streamCollectTraced sink model stubContext stubOptions
        assertBool "terminal returned" (any (\case EventDone {} -> True; _ -> False) events)
        traces <- readIORef cell
        length traces @?= 2,
      testCase "best-effort cancellation acknowledges abort before handler" $ cancellationTest requested Healthy,
      testCase "strict cancellation preserves the original exception" $ cancellationTest strictOptions Healthy,
      testCase "blocked sink bounds cancellation and may deliver later once" $ cancellationTest strictOptions Blocked,
      testCase "blocked sink also bounds best-effort cancellation" $ cancellationTest requested Blocked,
      testCase "throwing sink cannot replace strict cancellation" $ cancellationTest strictOptions Throwing,
      testCase "throwing sink cannot replace best-effort cancellation" $ cancellationTest requested Throwing
    ]

terminalTest :: Bool -> TestTree
terminalTest failCall = testCase (if failCall then "provider failure keeps evidence identity" else "success keeps evidence identity") $ do
  (reg, count) <- evidenceRegistry failCall
  (cell, sink) <- memorySink
  events <- runEff . runBaikaiWith reg $ streamCollectTraced sink stubModel stubContext requested
  count >>= (@?= 1)
  traces <- records <$> readIORef cell
  let terminalRecords =
        [ ev
        | event <- events,
          p <- case event of EventDone t -> [t]; EventError t -> [t]; _ -> [],
          Just ev <- [p ^. #evidence]
        ]
  traces @?= terminalRecords
  length traces @?= 1
  map (^. #status) traces @?= [if failCall then Ev.CallFailed else CallSucceeded]

data SinkMode = Healthy | Blocked | Throwing
  deriving stock (Eq)

cancellationTest :: Options -> SinkMode -> IO ()
cancellationTest opts mode = do
  (reg, entered, released) <- blockingRegistry
  cell <- newIORef []
  sinkEntered <- newEmptyMVar
  sinkReleased <- newEmptyMVar
  ended <- newEmptyMVar
  let step e = do
        case e of
          CallStarted {} -> do
            putMVar sinkEntered ()
            case mode of
              Blocked -> readMVar sinkReleased
              Throwing -> throwIO (userError "fixture sink failure")
              Healthy -> pure ()
          _ -> pure ()
        modifyIORef' cell (e :)
      sink = TraceSink (Fold.rmapM (\() -> putMVar ended ()) (Fold.drainMapM step))
  outcome <- newEmptyMVar
  tid <- forkIO $ do
    result <- try (runEff . runBaikaiWith reg $ streamCollectTraced sink stubModel stubContext opts)
    -- All observations belong to the caller's exception handler: no waiting
    -- for evidence, forced collection, or post-cancellation polling.
    snapshot <- readIORef cell
    providerDone <- tryReadMVar released
    sinkDone <- tryReadMVar ended
    putMVar outcome (result :: Either SomeException [AssistantMessageEvent], snapshot, providerDone, sinkDone)
  let run = do
        timeout 2000000 (readMVar entered) >>= (@?= Just ())
        timeout 2000000 (readMVar sinkEntered) >>= (@?= Just ())
        start <- getMonotonicTimeNSec
        throwTo tid (Cancelled 73102)
        observed <- timeout 3000000 (takeMVar outcome)
        stop <- getMonotonicTimeNSec
        case observed of
          Nothing -> assertFailure "cancelled collection hung"
          Just (result, snapshot, providerDone, sinkDone) -> do
            case result of
              Left ex -> fromException ex @?= Just (Cancelled 73102)
              Right _ -> assertFailure "cancellation disappeared"
            providerDone @?= Just ()
            case mode of
              Healthy -> do
                map (^. #status) (records snapshot) @?= [Ev.CallAborted]
                sinkDone @?= Just ()
              _ -> records snapshot @?= []
            assertBool "cleanup stays bounded" (stop - start < 2500000000)
            when (mode == Blocked) $ do
              assertBool "production drain budget was exercised" (stop - start >= 800000000)
              putMVar sinkReleased ()
              timeout 2000000 (readMVar ended) >>= (@?= Just ())
              eventual <- records <$> readIORef cell
              map (^. #status) eventual @?= [Ev.CallAborted]
  run `finally` (void (tryPutMVar sinkReleased ()) >> throwTo tid (Cancelled 73102))