packages feed

shikumi-0.1.0.0: test/Shikumi/LLM/Mock.hs

-- | A scripted, deterministic interpreter of the @LLM@ effect for the combinator
-- tests (EP-5). Each 'Shikumi.LLM.Complete' pops the next 'MockReply': a 'MockOk'
-- yields a canned 'Response' (build them with @ProgramFixtures.mkResponse@ /
-- @markerBody@), a 'MockFail' throws a 'ShikumiError' through the error channel —
-- which is exactly what 'Shikumi.Program.Retry' / 'Shikumi.Program.Validate'
-- react to. 'runMockLLMCounting' additionally bumps a caller-supplied counter on
-- every completion, so a retry test can assert the precise attempt count.
--
-- The counter is caller-owned (an 'IORef' in 'IO') rather than returned as a
-- tuple deliberately: a 'MockFail' short-circuits the @Error ShikumiError@
-- channel, so a tuple result would be discarded on the all-fail path — but the
-- 'IORef', mutated before the throw, survives and is read after the run resolves.
--
-- This is the richer sibling of @ProgramFixtures.runScriptedLLM@ (which only
-- scripts successes); it lives in the test tree, not the library.
module Shikumi.LLM.Mock
  ( MockReply (..),
    runMockLLM,
    runMockLLMCounting,
  )
where

import Data.IORef (IORef, atomicModifyIORef', newIORef)
import Effectful (Eff, IOE, liftIO, type (:>))
import Effectful.Dispatch.Dynamic (interpret)
import Effectful.Error.Static (Error, throwError)
import Shikumi.Error (ShikumiError (..))
import Shikumi.LLM (LLM (..), Response)

-- | One scripted reply to a single completion: a canned success or a typed
-- failure.
data MockReply
  = MockOk Response
  | MockFail ShikumiError

-- | Interpret @LLM@ by popping scripted replies in order. A 'MockFail' is thrown
-- through @Error ShikumiError@; the streaming op is unused (returns @[]@). An
-- exhausted script throws a clear 'ProviderFailure' rather than blocking.
runMockLLM ::
  (IOE :> es, Error ShikumiError :> es) =>
  [MockReply] ->
  Eff (LLM : es) a ->
  Eff es a
runMockLLM replies act = do
  ref <- liftIO (newIORef replies)
  interpret
    ( \_ -> \case
        Complete {} -> popReply ref
        Stream {} -> pure []
    )
    act

-- | Like 'runMockLLM', but bumps the caller-supplied counter on every completion
-- (counting every attempt, including those that fail). The bump happens before
-- the reply is consumed, so a thrown 'MockFail' still counts.
runMockLLMCounting ::
  (IOE :> es, Error ShikumiError :> es) =>
  IORef Int ->
  [MockReply] ->
  Eff (LLM : es) a ->
  Eff es a
runMockLLMCounting cnt replies act = do
  ref <- liftIO (newIORef replies)
  interpret
    ( \_ -> \case
        Complete {} -> do
          liftIO (atomicModifyIORef' cnt (\n -> (n + 1, ())))
          popReply ref
        Stream {} -> pure []
    )
    act

-- | Pop and act on the next reply, throwing on failure or an exhausted script.
popReply :: (IOE :> es, Error ShikumiError :> es) => IORef [MockReply] -> Eff es Response
popReply ref = do
  r <- liftIO (atomicModifyIORef' ref step)
  case r of
    MockOk resp -> pure resp
    MockFail e -> throwError e
  where
    step (x : xs) = (xs, x)
    step [] = ([], MockFail (ProviderFailure "mock: script exhausted"))