packages feed

langchain-hs-core-0.0.6.2: test/Test/Langchain/Tool.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Test.Langchain.Tool (tests) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async, cancel, wait)
import Data.Aeson
import Data.Aeson.Types (parseEither)
import Data.Conduit (fuseUpstream, runConduit, yield)
import qualified Data.Conduit.List as CL
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (diffUTCTime, getCurrentTime)
import Langchain.Error
import Langchain.Stream
  ( StreamEvent (ToolChunk, ToolEnd, ToolErrorEvent, ToolStart)
  , newCancelToken
  , triggerCancel
  )
import Langchain.Tool
import Langchain.Tool.Security (readOnlySecurityPolicy, unrestrictedSecurityPolicy)
import Langchain.Tool.Shell (streamingShellTool, streamingShellToolWithCancel)
import Test.Tasty
import Test.Tasty.HUnit

calcExec :: Value -> IO (Either LangchainError Text)
calcExec (Object _) = pure $ Right "42"
calcExec _ = pure $ Left $ toolError "Invalid arguments" (Just "calc") Nothing

tests :: TestTree
tests =
  testGroup
    "Langchain.Tool"
    [ testCase "createTool initializes tool attributes" $ do
        let t = createTool "calculator" "Performs math" (object []) calcExec
        toolName t @?= "calculator"
        toolDescription t @?= "Performs math"
    , testCase "toolToValue generates correct JSON schema structure" $ do
        let t = createTool "calculator" "Performs math" (object ["type" .= ("object" :: Text)]) calcExec
            val = toolToValue t
        case val of
          Object o -> do
            case parseEither (.: "type") o of
              Right ("function" :: Text) -> pure ()
              res -> assertFailure $ "Expected type = function, got: " ++ show res
          _ -> assertFailure "Expected JSON Object"
    , testCase "toolExecute runs successfully on valid input" $ do
        let t = createTool "calculator" "Performs math" (object []) calcExec
        res <- toolExecute t (object [])
        res @?= Right "42"
    , testCase "streamToolCall emits ToolStart and ToolEnd on success" $ do
        let t = createTool "calculator" "Performs math" (object []) calcExec
        eventsRef <- newIORef ([] :: [StreamEvent])
        res <-
          runConduit $
            streamToolCall "run-1" t (object [])
              `fuseUpstream` CL.mapM_ (\ev -> modifyIORef' eventsRef (++ [ev]))
        res @?= Right "42"
        events <- readIORef eventsRef
        events
          @?= [ ToolStart "run-1" "calculator" (object [])
              , ToolEnd "run-1" "calculator" (toJSON ("42" :: Text))
              ]
    , testCase "streamToolCall emits ToolStart and ToolErrorEvent on failure" $ do
        let t = createTool "calculator" "Performs math" (object []) calcExec
        eventsRef <- newIORef ([] :: [StreamEvent])
        res <-
          runConduit $
            streamToolCall "run-2" t (Number 1)
              `fuseUpstream` CL.mapM_ (\ev -> modifyIORef' eventsRef (++ [ev]))
        case res of
          Left _ -> pure ()
          Right out -> assertFailure $ "Expected error, got: " ++ show out
        events <- readIORef eventsRef
        case events of
          [ToolStart "run-2" "calculator" (Number 1), ToolErrorEvent "run-2" "calculator" _] -> pure ()
          _ -> assertFailure $ "Unexpected events: " ++ show events
    , testCase "streamStreamingToolCall emits incremental ToolChunk events" $ do
        let sTool =
              createStreamingTool "streamer" "Streams chunks" (object []) $ \_ -> do
                yield "chunk-1 "
                yield "chunk-2"
                pure (Right "chunk-1 chunk-2")
        eventsRef <- newIORef ([] :: [StreamEvent])
        res <-
          runConduit $
            streamStreamingToolCall "run-3" sTool (object [])
              `fuseUpstream` CL.mapM_ (\ev -> modifyIORef' eventsRef (++ [ev]))
        res @?= Right "chunk-1 chunk-2"
        events <- readIORef eventsRef
        events
          @?= [ ToolStart "run-3" "streamer" (object [])
              , ToolChunk "run-3" "streamer" "chunk-1 "
              , ToolChunk "run-3" "streamer" "chunk-2"
              , ToolEnd "run-3" "streamer" (toJSON ("chunk-1 chunk-2" :: Text))
              ]
    , testCase "streamingShellTool streams stdout lines and enforces SecurityPolicy" $ do
        let sShell = streamingShellTool unrestrictedSecurityPolicy
        eventsRef <- newIORef ([] :: [StreamEvent])
        res <-
          runConduit $
            streamStreamingToolCall
              "run-sh"
              sShell
              (object ["command" .= ("printf 'line1\\nline2\\n'" :: Text)])
              `fuseUpstream` CL.mapM_ (\ev -> modifyIORef' eventsRef (++ [ev]))
        res @?= Right "line1\nline2"
        events <- readIORef eventsRef
        let chunks = [txt | ToolChunk _ _ txt <- events]
        chunks @?= ["line1\n", "line2\n"]

        let roShell = streamingShellTool readOnlySecurityPolicy
        roRes <- toolExecute (streamingToolDef roShell) (object ["command" .= ("echo hi" :: Text)])
        case roRes of
          Left err -> assertBool "Mentions permission" ("permission" `T.isInfixOf` T.toLower (errorMessage err))
          Right ok -> assertFailure $ "Expected security denial, got: " ++ show ok
    , testCase "cancelling streamingShellTool via Async.cancel terminates sleep child promptly" $ do
        let sShell = streamingShellTool unrestrictedSecurityPolicy
        t0 <- getCurrentTime
        a <-
          async $
            runConduit $
              streamStreamingToolCall
                "run-cancel-async"
                sShell
                (object ["command" .= ("sleep 10" :: Text)])
                `fuseUpstream` CL.sinkNull
        threadDelay 100000
        cancel a
        t1 <- getCurrentTime
        let elapsed = realToFrac (diffUTCTime t1 t0) :: Double
        assertBool ("Expected prompt Async cancellation (< 2s), got: " ++ show elapsed) (elapsed < 2.0)
    , testCase "cancelling streamingShellToolWithCancel via CancelToken terminates promptly" $ do
        tok <- newCancelToken
        let sShell = streamingShellToolWithCancel tok unrestrictedSecurityPolicy
        t0 <- getCurrentTime
        a <-
          async $
            runConduit $
              streamStreamingToolCall
                "run-cancel-tok"
                sShell
                (object ["command" .= ("sleep 10" :: Text)])
                `fuseUpstream` CL.sinkNull
        threadDelay 100000
        triggerCancel tok
        _ <- wait a
        t1 <- getCurrentTime
        let elapsed = realToFrac (diffUTCTime t1 t0) :: Double
        assertBool ("Expected prompt CancelToken termination (< 2s), got: " ++ show elapsed) (elapsed < 2.0)
    ]