packages feed

seihou-cli-0.6.0.0: test/Seihou/CLI/AgentCompletionSpec.hs

module Seihou.CLI.AgentCompletionSpec (tests) where

import Baikai qualified
import Baikai.Model qualified as BaikaiModel
import Baikai.Response qualified as BaikaiResponse
import Baikai.Trace.Sink (TraceSink, silent)
import Control.Exception (throwIO)
import Control.Lens ((^.))
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.Generics.Labels ()
import Data.Text qualified as Text
import Data.Time (UTCTime)
import Data.Vector qualified as V
import Seihou.CLI.AgentCompletion
import Seihou.CLI.AgentTrace (traceSinkFor)
import System.Directory (doesFileExist)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty (TestTree)
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.CLI.AgentCompletion" $ do
  describe "provider text helpers" $ do
    it "parses known providers case-insensitively" $ do
      providerFromText "claude-cli" `shouldBe` Right AgentProviderClaudeCli
      providerFromText "CODEX-CLI" `shouldBe` Right AgentProviderCodexCli
      providerFromText " anthropic " `shouldBe` Right AgentProviderAnthropic
      providerFromText "openai" `shouldBe` Right AgentProviderOpenAI

    it "renders providers to canonical config text" $ do
      providerToText AgentProviderClaudeCli `shouldBe` "claude-cli"
      providerToText AgentProviderCodexCli `shouldBe` "codex-cli"
      providerToText AgentProviderAnthropic `shouldBe` "anthropic"
      providerToText AgentProviderOpenAI `shouldBe` "openai"

    it "returns a useful error for unknown providers" $
      providerFromText "llama" `shouldSatisfy` \case
        Left err ->
          "claude-cli" `Text.isInfixOf` err
            && "codex-cli" `Text.isInfixOf` err
            && "anthropic" `Text.isInfixOf` err
            && "openai" `Text.isInfixOf` err
        Right _ -> False

  describe "trace setting text helpers" $ do
    it "parses every accepted setting case-insensitively" $ do
      traceFromText "off" `shouldBe` Right TraceOff
      traceFromText "FILE" `shouldBe` Right TraceFile
      traceFromText "  stdout  " `shouldBe` Right TraceStdout
      traceFromText "StdErr" `shouldBe` Right TraceStderr

    it "names every accepted setting in the failure message" $
      traceFromText "syslog" `shouldSatisfy` \case
        Left err ->
          "off" `Text.isInfixOf` err
            && "file" `Text.isInfixOf` err
            && "stdout" `Text.isInfixOf` err
            && "stderr" `Text.isInfixOf` err
        Right _ -> False

    it "round-trips through traceToText" $
      traverse (traceFromText . traceToText) [TraceOff, TraceFile, TraceStdout, TraceStderr]
        `shouldBe` Right [TraceOff, TraceFile, TraceStdout, TraceStderr]

  describe "model construction" $ do
    it "defaults to the Claude CLI provider with no explicit model" $
      defaultAgentModelConfig
        `shouldBe` AgentModelConfig
          { provider = AgentProviderClaudeCli,
            model = Nothing,
            effort = Nothing,
            trace = TraceOff,
            tracePath = Nothing
          }

    it "builds a Claude CLI model using the CLI API tag" $ do
      let model =
            buildBaikaiModel
              AgentModelConfig
                { provider = AgentProviderClaudeCli,
                  model = Just "sonnet",
                  effort = Nothing,
                  trace = TraceOff,
                  tracePath = Nothing
                }
      BaikaiModel.api model `shouldBe` Baikai.AnthropicMessagesCli
      BaikaiModel.provider model `shouldBe` "anthropic"
      BaikaiModel.modelId model `shouldBe` "sonnet"

    it "builds a Codex CLI model using the CLI API tag" $ do
      let model =
            buildBaikaiModel
              AgentModelConfig
                { provider = AgentProviderCodexCli,
                  model = Just "gpt-5",
                  effort = Nothing,
                  trace = TraceOff,
                  tracePath = Nothing
                }
      BaikaiModel.api model `shouldBe` Baikai.OpenAICompletionsCli
      BaikaiModel.provider model `shouldBe` "openai"
      BaikaiModel.modelId model `shouldBe` "gpt-5"

  describe "buildAgentCompletionRequest" $ do
    it "preserves rendered prompts and resolved model configuration" $ do
      let config =
            AgentModelConfig
              { provider = AgentProviderCodexCli,
                model = Just "gpt-5",
                effort = Nothing,
                trace = TraceOff,
                tracePath = Nothing
              }
          req = buildAgentCompletionRequest config "system" (Just "user")
      -- AgentCompletionRequest has no Eq: it carries a TraceSink, which wraps a
      -- streamly fold. Compare the inspectable fields instead.
      (req ^. #systemPrompt) `shouldBe` "system"
      (req ^. #initialPrompt) `shouldBe` Just "user"
      (req ^. #modelConfig) `shouldBe` config

  -- These drive the real Baikai.Trace.withTrace path against a stub provider
  -- registered under the anthropic-messages tag. They exist because withTrace
  -- reports provider failures as an error-shaped Response rather than by
  -- throwing, the way completeRequest did: without the responseError branch in
  -- runAgentCompletionWith, every one of these failures would be reported as
  -- "Provider returned no assistant text." and the real message would be lost.
  describe "runAgentCompletionWith" $ do
    it "returns the assistant text of a successful call" $ do
      result <- runStub (const (pure (okResponse "hello from the stub"))) Nothing
      result `shouldBe` Right "hello from the stub"

    -- The regression test for the whole swap. Delete the responseError branch
    -- in runAgentCompletionWith and this fails with the empty-text message.
    it "reports the provider's message when the response is error-shaped" $ do
      result <- runStub (\m -> pure (failedResponse m "invalid x-api-key")) Nothing
      result `shouldSatisfy` \case
        Left err -> "invalid x-api-key" `Text.isInfixOf` err
        Right _ -> False

    it "does not mistake a provider error for missing assistant text" $ do
      result <- runStub (\m -> pure (failedResponse m "model not found")) Nothing
      result `shouldSatisfy` \case
        Left err -> not ("Provider returned no assistant text." `Text.isInfixOf` err)
        Right _ -> False

    -- ...and the empty-text guard must still fire for a genuinely empty
    -- success, rather than being shadowed by the new branch.
    it "still reports a successful but empty response as missing text" $ do
      result <- runStub (const (pure (okResponse ""))) Nothing
      result `shouldBe` Left "Provider returned no assistant text."

    it "reports a thrown provider exception, which withTrace still propagates" $ do
      result <- runStub (const (throwIO (Baikai.providerError "connection reset"))) Nothing
      result `shouldSatisfy` \case
        Left err -> "connection reset" `Text.isInfixOf` err
        Right _ -> False

    it "writes a correlated start/finish pair to a file sink" $
      withSystemTempDirectory "seihou-completion-trace" $ \root -> do
        let path = root </> "trace.jsonl"
        sink <- traceSinkFor TraceFile (Just path)
        _ <- runStub (const (pure (okResponse "traced"))) (Just sink)
        events <- traceEvents path
        map fst events `shouldBe` ["call_started", "call_finished"]
        case map snd events of
          [a, b] -> a `shouldBe` b
          other -> expectationFailure ("expected two events, got " <> show (length other))

    it "writes a start/fail pair when the call fails" $
      withSystemTempDirectory "seihou-completion-trace-fail" $ \root -> do
        let path = root </> "trace.jsonl"
        sink <- traceSinkFor TraceFile (Just path)
        _ <- runStub (\m -> pure (failedResponse m "rate limited")) (Just sink)
        events <- traceEvents path
        map fst events `shouldBe` ["call_started", "call_failed"]

    it "writes nothing when tracing is off" $
      withSystemTempDirectory "seihou-completion-trace-off" $ \root -> do
        let path = root </> "trace.jsonl"
        _ <- runStub (const (pure (okResponse "untraced"))) Nothing
        doesFileExist path `shouldReturn` False

  describe "responseText" $ do
    it "extracts and joins assistant text blocks only" $ do
      let resp =
            BaikaiResponse.emptyResponse
              { BaikaiResponse.message =
                  Baikai.AssistantPayload
                    { Baikai.content =
                        V.fromList
                          [ Baikai.AssistantText (Baikai.TextContent "hello"),
                            Baikai.AssistantThinking Baikai.emptyThinkingContent,
                            Baikai.AssistantText (Baikai.TextContent "world")
                          ],
                      Baikai.usage = Baikai.zeroUsage,
                      Baikai.stopReason = Baikai.Stop,
                      Baikai.errorMessage = Nothing,
                      Baikai.timestamp = Just (read "2026-05-23 00:00:00 UTC" :: UTCTime)
                    }
              }
      responseText resp `shouldBe` "hello\nworld"

-- | Run a completion against a stub provider that returns whatever the given
-- action produces, optionally reporting to a trace sink.
--
-- The stub registers under the @anthropic-messages@ tag, which is what
-- 'buildBaikaiModel' selects for 'AgentProviderAnthropic'. It supplies both
-- provider fields the way the real CLI providers do — a direct @complete@ and
-- a @stream@ lifted from it — because 'Baikai.Trace.withTrace' dispatches
-- through @stream@, not @complete@.
--
-- Registration mutates Baikai's process-global registry. That is safe here
-- because the test binary is not built with @-threaded@, so tasty runs these
-- sequentially; a stub is always registered immediately before the call that
-- uses it.
runStub ::
  (Baikai.Model -> IO BaikaiResponse.Response) ->
  Maybe TraceSink ->
  IO (Either Text.Text Text.Text)
runStub respond sink =
  runAgentCompletionWith registerStub request
  where
    registerStub =
      Baikai.registerApiProvider
        Baikai.ApiProvider
          { Baikai.apiTag = Baikai.AnthropicMessages,
            Baikai.complete = \m _ _ -> respond m,
            Baikai.stream = Baikai.liftCompleteToStream (\m _ _ -> respond m)
          }
    request =
      buildAgentCompletionRequestWith
        (maybe silent id sink)
        AgentModelConfig
          { provider = AgentProviderAnthropic,
            model = Just "stub-model",
            effort = Nothing,
            trace = maybe TraceOff (const TraceFile) sink,
            tracePath = Nothing
          }
        "system"
        (Just "user")

-- | A successful response carrying one assistant text block.
okResponse :: Text.Text -> BaikaiResponse.Response
okResponse body =
  BaikaiResponse.emptyResponse
    { BaikaiResponse.message =
        Baikai.AssistantPayload
          { Baikai.content = V.singleton (Baikai.AssistantText (Baikai.TextContent body)),
            Baikai.usage = Baikai.zeroUsage,
            Baikai.stopReason = Baikai.Stop,
            Baikai.errorMessage = Nothing,
            Baikai.timestamp = Just epoch
          }
    }

-- | An error-shaped response, the way a conforming provider reports an in-band
-- failure: @stopReason = ErrorReason@ plus the provider's message.
failedResponse :: Baikai.Model -> Text.Text -> BaikaiResponse.Response
failedResponse m message =
  BaikaiResponse.errorResponse m epoch 12 (Baikai.providerError message)

epoch :: UTCTime
epoch = read "2026-07-27 00:00:00 UTC"

-- | The @(kind, eventId)@ of every event in a JSONL trace file, in order.
traceEvents :: FilePath -> IO [(String, String)]
traceEvents path = do
  contents <- BL8.readFile path
  pure
    [ (Text.unpack kind, Text.unpack eventId)
    | line <- BL8.lines contents,
      not (BL8.null line),
      Just (Aeson.Object o) <- [Aeson.decode line],
      Just (Aeson.String kind) <- [KeyMap.lookup (Key.fromString "kind") o],
      Just (Aeson.String eventId) <- [KeyMap.lookup (Key.fromString "eventId") o]
    ]