shikumi-0.4.1.0: test/CliSchemaSpec.hs
{-# LANGUAGE DataKinds #-}
-- | EP-67: a typed program whose output is a list of records decodes through a
-- subscription-CLI provider that enforces the derived schema, and fails the way
-- Mina's @codex-cli@ judges did when the same provider sits under a fallback tag.
--
-- The scripted provider stands in for baikai's CLI providers: it returns
-- schema-conforming JSON only when the request carries a 'B.JsonSchema'
-- @responseFormat@ (what @claude -p --json-schema@ / @codex exec --output-schema@
-- enforce), and otherwise replies like an unconstrained model — marker sections
-- whose list-of-records field holds bare strings. Registering the identical
-- provider under a @Custom@ tag is the control: only the routing decision differs.
module CliSchemaSpec
( tests,
Severity (..),
Concern (..),
Assessment (..),
Proposal (..),
)
where
import Baikai qualified as B
import Control.Lens ((&), (.~), (^.))
import Data.Aeson (Value)
import Data.Generics.Labels ()
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Effectful (runEff)
import Effectful.Error.Static (runErrorNoCallStack)
import GHC.Generics (Generic)
import Shikumi.Adapter (ToPrompt)
import Shikumi.Error (ShikumiError, renderShikumiError)
import Shikumi.LLM (runLLMWith)
import Shikumi.Program (Program (Predict), emptyParams, runProgram)
import Shikumi.Routing (routeLLM, runRouting)
import Shikumi.Schema (FromModel, ToSchema, Validatable, deriveSchema)
import Shikumi.Schema.Types (Field (..), field)
import Shikumi.Signature (Signature, mkSignature)
import Streamly.Data.Stream qualified as Stream
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase, (@?=))
data Severity = Blocker | Major | Minor
deriving stock (Generic, Show, Eq)
instance ToSchema Severity
instance FromModel Severity
data Concern = Concern
{ statement :: !(Field "What is wrong" Text),
severity :: !Severity
}
deriving stock (Generic, Show, Eq)
instance ToSchema Concern
instance FromModel Concern
data Assessment = Assessment
{ concerns :: !(Field "Readiness concerns" [Concern]),
verdict :: !(Field "Overall verdict" Text)
}
deriving stock (Generic, Show, Eq)
instance ToSchema Assessment
instance FromModel Assessment
instance ToPrompt Assessment
instance Validatable Assessment
newtype Proposal = Proposal {plan :: Field "The release plan to assess" Text}
deriving stock (Generic, Show, Eq)
instance ToSchema Proposal
instance FromModel Proposal
instance ToPrompt Proposal
instance Validatable Proposal
assessSig :: Signature Proposal Assessment
assessSig = mkSignature "Assess whether the release plan is ready."
proposal :: Proposal
proposal = Proposal {plan = field "Ship on Friday."}
expectedAssessment :: Assessment
expectedAssessment =
Assessment
{ concerns = field [Concern {statement = field "No rollback path", severity = Blocker}],
verdict = field "not ready"
}
-- | The reply a schema-enforcing CLI returns.
conformingJson :: Text
conformingJson =
"{\"concerns\":[{\"statement\":\"No rollback path\",\"severity\":\"Blocker\"}],\"verdict\":\"not ready\"}"
-- | The reply an unconstrained model wrote in Mina: strings where records belong.
unconstrainedMarkers :: Text
unconstrainedMarkers =
T.intercalate
"\n"
[ "[[ ## concerns ## ]]",
"[\"No rollback path\"]",
"[[ ## verdict ## ]]",
"not ready",
"[[ ## completed ## ]]"
]
textResponse :: B.Model -> Text -> B.Response
textResponse m t =
B.emptyResponse
& #message
. #content
.~ V.singleton (B.AssistantText (B.emptyTextContent & #text .~ t))
& #model
.~ m
-- | A registry whose single provider serves @api@ and enforces the schema only
-- when one is requested, recording every 'B.Options' it receives.
cliRegistry :: B.Api -> IO (B.ProviderRegistry, IORef [B.Options])
cliRegistry api = do
seen <- newIORef []
reg <- B.newProviderRegistry
let reply m o = case o ^. #responseFormat of
Just (B.JsonSchema _) -> textResponse m conformingJson
_ -> textResponse m unconstrainedMarkers
B.registerApiProviderWith
reg
( B.apiProviderWith
api
(\_ _ _ -> Stream.nil)
(\m _ o -> modifyIORef' seen (<> [o]) >> pure (reply m o))
)
{ B.describeThinking = \_ _ -> B.noThinkingRequested
}
pure (reg, seen)
runOn :: B.Model -> B.ProviderRegistry -> IO (Either ShikumiError Assessment)
runOn model reg =
runEff
. runErrorNoCallStack @ShikumiError
. runRouting model
. runLLMWith reg
. routeLLM
$ runProgram (Predict assessSig emptyParams) proposal
schemaOf :: B.Options -> Maybe Value
schemaOf o = case o ^. #responseFormat of
Just (B.JsonSchema fmt) -> Just (fmt ^. #schema)
_ -> Nothing
tests :: TestTree
tests =
testGroup
"CliSchema"
[ testCase "claude CLI model: routed with the strict derived schema and decodes a list of records" $ do
let model = B.mkModel B.AnthropicMessagesCli "fixture" "https://example.invalid"
(reg, seen) <- cliRegistry B.AnthropicMessagesCli
result <- runOn model reg
result @?= Right expectedAssessment
recorded <- readIORef seen
map (^. #responseFormat) recorded
@?= [Just (B.JsonSchema (B.jsonSchemaFormat "output" (deriveSchema @Assessment) & #strict .~ True))]
map schemaOf recorded @?= [Just (deriveSchema @Assessment)]
-- Private shikumi metadata never reaches the transport.
map (Map.keys . (^. #metadata)) recorded @?= [[]],
testCase "codex CLI model: routed with the derived schema" $ do
let model = B.mkModel B.OpenAICompletionsCli "fixture" "https://example.invalid"
(reg, seen) <- cliRegistry B.OpenAICompletionsCli
result <- runOn model reg
result @?= Right expectedAssessment
readIORef seen >>= (@?= [Just (deriveSchema @Assessment)]) . map schemaOf,
testCase "same provider under a fallback tag: no schema, strings where records belong" $ do
let api = B.Custom "no-schema"
model = B.mkModel api "fixture" "https://example.invalid"
(reg, seen) <- cliRegistry api
result <- runOn model reg
readIORef seen >>= (@?= [Nothing]) . map schemaOf
case result of
Left e
| "expected object, got string" `T.isInfixOf` renderShikumiError e -> pure ()
| otherwise -> assertFailure ("unexpected error: " <> T.unpack (renderShikumiError e))
Right a -> assertFailure ("expected a decode failure, got " <> show a)
]