shikumi-tools-0.2.0.0: test/BuiltinAcceptanceSpec.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE OverloadedStrings #-}
module BuiltinAcceptanceSpec (tests) where
import Baikai (Response)
import Control.Exception (bracket)
import Control.Lens ((&), (.~))
import Data.Aeson (object, (.=))
import Data.Generics.Labels ()
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Vector qualified as V
import GHC.Generics (Generic)
import MockLLM (mkTextResponse, mkToolCallResponse, runAgent)
import Shikumi.Adapter (ToPrompt)
import Shikumi.Agent.ReAct
( Action (..),
Step (..),
Termination (..),
ToolProtocol (..),
Trajectory (..),
defaultReActConfig,
reactWithTrajectory,
)
import Shikumi.Schema (FromModel, ToSchema)
import Shikumi.Signature (Signature, mkSignature)
import Shikumi.Tool.Builtin (builtinRegistry)
import Shikumi.Tool.Env (localToolEnv)
import Shikumi.Tool.Web (FetchResult (..), SearchResult (..), WebClient (..))
import System.Directory qualified as Dir
import System.FilePath ((</>))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
data WorkTask = WorkTask {task :: !Text}
deriving stock (Generic, Show, Eq)
deriving anyclass (ToPrompt)
data WorkAnswer = WorkAnswer {done :: !Bool}
deriving stock (Generic, Show, Eq)
deriving anyclass (ToSchema, FromModel, ToPrompt)
tests :: TestTree
tests =
testGroup
"BuiltinAcceptance"
[ testCase "builtinRegistry drives a scripted work cycle" $
withTempDir "builtin" $ \root -> do
let file = T.pack (root </> "README.md")
TIO.writeFile (T.unpack file) "# Old Title\n\nbody\n"
res <-
runAgent
(script (T.pack root) file)
(reactWithTrajectory workSignature (builtinRegistry localToolEnv stubWebClient) (defaultReActConfig & #protocol .~ ProtocolNative))
WorkTask {task = "Update the title and verify it."}
finalContent <- TIO.readFile (T.unpack file)
case res of
Left err -> assertFailure ("builtin agent failed: " <> show err)
Right (answer, traj) -> do
answer @?= WorkAnswer {done = True}
termination traj @?= TerminatedFinish
finalContent @?= "# New Title\n\nbody\n"
let stepList = V.toList (steps traj)
V.length (steps traj) @?= 6
toolNames stepList @?= ["read", "edit", "bash", "grep", "web_fetch"]
case stepList of
[readStep, editStep, bashStep, grepStep, fetchStep, finishStep] -> do
assertObservation "read observes original content" "Old Title" readStep
assertObservation "edit reports replacement" "replacements" editStep
assertObservation "bash sees new title" "New Title" bashStep
assertObservation "grep sees new title" "New Title" grepStep
assertObservation "web_fetch sees stub body" "stub body" fetchStep
action finishStep @?= Finish
_ -> assertFailure "expected five tool steps and one finish step"
]
workSignature :: Signature WorkTask WorkAnswer
workSignature = mkSignature "Use the builtin tools to complete the requested repository work."
script :: Text -> Text -> [Response]
script root file =
[ mkToolCallResponse "call_read" "read" (object ["path" .= file]),
mkToolCallResponse
"call_edit"
"edit"
( object
[ "path" .= file,
"oldString" .= ("# Old Title" :: Text),
"newString" .= ("# New Title" :: Text)
]
),
mkToolCallResponse
"call_bash"
"bash"
(object ["command" .= ("cat " <> shellQuote file), "timeoutMs" .= (5000 :: Int)]),
mkToolCallResponse
"call_grep"
"grep"
( object
[ "pattern" .= ("New Title" :: Text),
"path" .= root,
"glob" .= ("**/*.md" :: Text)
]
),
mkToolCallResponse "call_fetch" "web_fetch" (object ["url" .= ("https://example.test/docs" :: Text)]),
mkTextResponse "The requested work is complete.",
mkTextResponse "{\"done\": true}"
]
stubWebClient :: WebClient
stubWebClient =
WebClient
{ webFetch = \_ _ ->
pure FetchResult {status = 200, contentType = "text/plain", body = "stub body", truncated = False},
webSearch = \_ _ -> pure SearchResult {hits = []}
}
toolNames :: [Step] -> [Text]
toolNames = foldMap $ \step -> case action step of
CallTool name _ -> [name]
Finish -> []
assertObservation :: String -> Text -> Step -> IO ()
assertObservation label needle step =
assertBool label (needle `T.isInfixOf` fromMaybe "" (observation step))
withTempDir :: FilePath -> (FilePath -> IO a) -> IO a
withTempDir suffix action = do
tmp <- Dir.getTemporaryDirectory
let root = tmp </> ("shikumi-tools-" <> suffix)
bracket
(Dir.removePathForcibly root >> Dir.createDirectoryIfMissing True root >> pure root)
Dir.removePathForcibly
action
shellQuote :: Text -> Text
shellQuote t = "'" <> T.replace "'" "'\\''" t <> "'"