packages feed

seihou-core-0.4.0.0: test/Seihou/Core/AgentPromptSpec.hs

module Seihou.Core.AgentPromptSpec (tests) where

import Data.Text qualified as T
import Seihou.Core.AgentPrompt (validateAgentPrompt)
import Seihou.Core.Module (DiscoveredRunnable (..), RunnableKind (..), discoverAllRunnables, discoverRunnable)
import Seihou.Core.Types
import Seihou.Dhall.Eval (evalAgentPromptFromFile)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Core.AgentPrompt" spec

goodAgentPrompt :: AgentPrompt
goodAgentPrompt =
  AgentPrompt
    "review-changes"
    (Just "0.1.0")
    (Just "Review local changes")
    "Review the current repository."
    [VarDecl "project.name" VTText Nothing Nothing True Nothing]
    [Prompt {var = "project.name", text = "Project?", condition = Nothing, choices = Nothing}]
    [CommandVar "git.branch" "git branch --show-current" Nothing Nothing True (Just 4096)]
    [PromptGuidance "Repository workflow" "Inspect first." Nothing]
    []
    Nothing
    ["review"]
    Nothing

withAgentPromptName :: ModuleName -> AgentPrompt -> AgentPrompt
withAgentPromptName n p =
  AgentPrompt n p.version p.description p.prompt p.vars p.prompts p.commandVars p.guidance p.files p.allowedTools p.tags p.launch

withAgentPromptPrompt :: T.Text -> AgentPrompt -> AgentPrompt
withAgentPromptPrompt body p =
  AgentPrompt p.name p.version p.description body p.vars p.prompts p.commandVars p.guidance p.files p.allowedTools p.tags p.launch

withAgentPromptVars :: [VarDecl] -> AgentPrompt -> AgentPrompt
withAgentPromptVars vars p =
  AgentPrompt p.name p.version p.description p.prompt vars p.prompts p.commandVars p.guidance p.files p.allowedTools p.tags p.launch

withAgentPromptPrompts :: [Prompt] -> AgentPrompt -> AgentPrompt
withAgentPromptPrompts prompts p =
  AgentPrompt p.name p.version p.description p.prompt p.vars prompts p.commandVars p.guidance p.files p.allowedTools p.tags p.launch

withAgentPromptCommandVars :: [CommandVar] -> AgentPrompt -> AgentPrompt
withAgentPromptCommandVars commandVars p =
  AgentPrompt p.name p.version p.description p.prompt p.vars p.prompts commandVars p.guidance p.files p.allowedTools p.tags p.launch

withAgentPromptGuidance :: [PromptGuidance] -> AgentPrompt -> AgentPrompt
withAgentPromptGuidance guidance p =
  AgentPrompt p.name p.version p.description p.prompt p.vars p.prompts p.commandVars guidance p.files p.allowedTools p.tags p.launch

withAgentPromptFiles :: [BlueprintFile] -> AgentPrompt -> AgentPrompt
withAgentPromptFiles files p =
  AgentPrompt p.name p.version p.description p.prompt p.vars p.prompts p.commandVars p.guidance files p.allowedTools p.tags p.launch

hasError :: T.Text -> [T.Text] -> Bool
hasError needle = any (T.isInfixOf needle)

spec :: Spec
spec = do
  describe "evalAgentPromptFromFile" $ do
    it "decodes prompt.dhall with command variables, guidance, and launch metadata" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let promptDir = tmpDir </> "review-changes"
        createDirectoryIfMissing True promptDir
        writeFile (promptDir </> "prompt.dhall") (samplePromptDhall "review-changes")
        result <- evalAgentPromptFromFile (promptDir </> "prompt.dhall")
        case result of
          Right p -> do
            p.name `shouldBe` "review-changes"
            p.description `shouldBe` Just "Review local changes"
            length p.commandVars `shouldBe` 1
            p.guidance
              `shouldBe` [ PromptGuidance
                             "Repository workflow"
                             "Prefer focused validation commands."
                             (Just (ExprEq "git.branch" (VText "main")))
                         ]
            fmap (.provider) p.launch `shouldBe` Just (Just "codex-cli")
          Left err -> expectationFailure ("Expected Right, got: " <> show err)

    it "decodes prompt.dhall without guidance as an empty list" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let promptDir = tmpDir </> "review-changes"
        createDirectoryIfMissing True promptDir
        writeFile (promptDir </> "prompt.dhall") (samplePromptDhallWithoutGuidance "review-changes")
        result <- evalAgentPromptFromFile (promptDir </> "prompt.dhall")
        case result of
          Right p -> p.guidance `shouldBe` []
          Left err -> expectationFailure ("Expected Right, got: " <> show err)

  describe "validateAgentPrompt" $ do
    it "accepts a well-formed prompt" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        result <- validateAgentPrompt tmpDir goodAgentPrompt
        case result of
          Right p -> p.name `shouldBe` "review-changes"
          Left err -> expectationFailure ("Expected Right, got: " <> show err)

    it "rejects an invalid prompt name" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        result <- validateAgentPrompt tmpDir (withAgentPromptName "Bad_Name" goodAgentPrompt)
        case result of
          Left (ValidationError _ errs) ->
            hasError "prompt name must match" errs `shouldBe` True
          other -> expectationFailure ("Expected ValidationError, got: " <> show other)

    it "rejects an empty prompt body" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        result <- validateAgentPrompt tmpDir (withAgentPromptPrompt "   \n " goodAgentPrompt)
        case result of
          Left (ValidationError _ errs) ->
            hasError "prompt body must not be empty" errs `shouldBe` True
          other -> expectationFailure ("Expected ValidationError, got: " <> show other)

    it "rejects duplicate typed variables" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let bad =
              withAgentPromptVars
                [ VarDecl "x" VTText Nothing Nothing True Nothing,
                  VarDecl "x" VTBool Nothing Nothing False Nothing
                ]
                goodAgentPrompt
        result <- validateAgentPrompt tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "duplicate variable name" errs `shouldBe` True
          other -> expectationFailure ("Expected ValidationError, got: " <> show other)

    it "rejects prompts that reference undeclared variables" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let bad =
              withAgentPromptPrompts
                [Prompt {var = "missing", text = "?", condition = Nothing, choices = Nothing}]
                goodAgentPrompt
        result <- validateAgentPrompt tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "prompt references undeclared variable" errs `shouldBe` True
          other -> expectationFailure ("Expected ValidationError, got: " <> show other)

    it "rejects unsafe command variables" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let bad =
              withAgentPromptCommandVars
                [ CommandVar "git.branch" "" (Just "../outside") Nothing True (Just 0),
                  CommandVar "git.branch" "git status" Nothing Nothing True Nothing
                ]
                goodAgentPrompt
        result <- validateAgentPrompt tmpDir bad
        case result of
          Left (ValidationError _ errs) -> do
            hasError "duplicate command variable name" errs `shouldBe` True
            hasError "run must not be empty" errs `shouldBe` True
            hasError "must not contain '..'" errs `shouldBe` True
            hasError "maxBytes must be greater than zero" errs `shouldBe` True
          other -> expectationFailure ("Expected ValidationError, got: " <> show other)

    it "accepts guidance that references declared typed and command variables" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let guided =
              withAgentPromptGuidance
                [ PromptGuidance "Project" "Use the declared project name." (Just (ExprIsSet "project.name")),
                  PromptGuidance "Branch" "Account for branch-specific workflow." (Just (ExprIsSet "git.branch"))
                ]
                goodAgentPrompt
        result <- validateAgentPrompt tmpDir guided
        case result of
          Right p -> length p.guidance `shouldBe` 2
          Left err -> expectationFailure ("Expected Right, got: " <> show err)

    it "rejects guidance with blank titles or bodies" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let bad =
              withAgentPromptGuidance
                [ PromptGuidance "  " "Body" Nothing,
                  PromptGuidance "Title" " \n " Nothing
                ]
                goodAgentPrompt
        result <- validateAgentPrompt tmpDir bad
        case result of
          Left (ValidationError _ errs) -> do
            hasError "guidance title must not be empty" errs `shouldBe` True
            hasError "guidance body must not be empty" errs `shouldBe` True
          other -> expectationFailure ("Expected ValidationError, got: " <> show other)

    it "rejects guidance conditions that reference undeclared variables" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let bad =
              withAgentPromptGuidance
                [PromptGuidance "Missing" "This references an unknown value." (Just (ExprIsSet "repo.kind"))]
                goodAgentPrompt
        result <- validateAgentPrompt tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "guidance 'Missing' references undeclared variable: repo.kind" errs `shouldBe` True
          other -> expectationFailure ("Expected ValidationError, got: " <> show other)

    it "checks referenced prompt files under files/" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let bad =
              withAgentPromptFiles
                [BlueprintFile {src = "missing.md", description = Nothing}]
                goodAgentPrompt
        result <- validateAgentPrompt tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "prompt file not found" errs `shouldBe` True
          other -> expectationFailure ("Expected ValidationError, got: " <> show other)

  describe "prompt discovery" $ do
    it "finds a prompt when only prompt.dhall is present" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let promptDir = tmpDir </> "review-changes"
        createDirectoryIfMissing True promptDir
        writeFile (promptDir </> "prompt.dhall") (samplePromptDhall "review-changes")
        result <- discoverRunnable [tmpDir] "review-changes"
        case result of
          Right (RunnableAgentPrompt p dir) -> do
            p.name `shouldBe` "review-changes"
            dir `shouldBe` promptDir
          other -> expectationFailure ("Expected RunnableAgentPrompt, got: " <> show other)

    it "tags prompts as KindPrompt during enumeration" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let promptDir = tmpDir </> "review-changes"
        createDirectoryIfMissing True promptDir
        writeFile (promptDir </> "prompt.dhall") (samplePromptDhall "review-changes")
        found <- discoverAllRunnables [tmpDir]
        case found of
          [DiscoveredRunnable {drKind = kind}] -> kind `shouldBe` KindPrompt
          other -> expectationFailure ("Expected one discovered prompt, got: " <> show other)

    it "prefers blueprint.dhall over prompt.dhall in the same directory" $ do
      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
        let entryDir = tmpDir </> "ambiguous"
        createDirectoryIfMissing True entryDir
        writeFile (entryDir </> "blueprint.dhall") (sampleBlueprintDhall "ambiguous")
        writeFile (entryDir </> "prompt.dhall") (samplePromptDhall "ambiguous")
        result <- discoverRunnable [tmpDir] "ambiguous"
        case result of
          Right (RunnableBlueprint _ _) -> pure ()
          other -> expectationFailure ("Expected RunnableBlueprint, got: " <> show other)

samplePromptDhall :: T.Text -> String
samplePromptDhall n =
  unlines
    [ "{ name = \"" ++ T.unpack n ++ "\"",
      ", version = Some \"0.1.0\"",
      ", description = Some \"Review local changes\"",
      ", prompt = \"Review the current repository.\"",
      ", vars =",
      "    [] : List",
      "          { name : Text",
      "          , type : Text",
      "          , default : Optional Text",
      "          , description : Optional Text",
      "          , required : Bool",
      "          , validation : Optional Text",
      "          }",
      ", prompts =",
      "    [] : List",
      "          { var : Text",
      "          , text : Text",
      "          , when : Optional Text",
      "          , choices : Optional (List Text)",
      "          }",
      ", commandVars =",
      "    [ { name = \"git.branch\"",
      "      , run = \"git branch --show-current\"",
      "      , workDir = None Text",
      "      , when = None Text",
      "      , trim = True",
      "      , maxBytes = Some 4096",
      "      }",
      "    ]",
      ", guidance =",
      "    [ { title = \"Repository workflow\"",
      "      , body = \"Prefer focused validation commands.\"",
      "      , when = Some \"Eq git.branch main\"",
      "      }",
      "    ]",
      ", files = [] : List { src : Text, description : Optional Text }",
      ", allowedTools = None (List Text)",
      ", tags = [ \"review\" ]",
      ", launch = Some { provider = Some \"codex-cli\", mode = None Text, model = None Text }",
      "}"
    ]

samplePromptDhallWithoutGuidance :: T.Text -> String
samplePromptDhallWithoutGuidance n =
  unlines
    [ "{ name = \"" ++ T.unpack n ++ "\"",
      ", version = Some \"0.1.0\"",
      ", description = Some \"Review local changes\"",
      ", prompt = \"Review the current repository.\"",
      ", vars =",
      "    [] : List",
      "          { name : Text",
      "          , type : Text",
      "          , default : Optional Text",
      "          , description : Optional Text",
      "          , required : Bool",
      "          , validation : Optional Text",
      "          }",
      ", prompts =",
      "    [] : List",
      "          { var : Text",
      "          , text : Text",
      "          , when : Optional Text",
      "          , choices : Optional (List Text)",
      "          }",
      ", commandVars =",
      "    [] : List",
      "          { name : Text",
      "          , run : Text",
      "          , workDir : Optional Text",
      "          , when : Optional Text",
      "          , trim : Bool",
      "          , maxBytes : Optional Natural",
      "          }",
      ", files = [] : List { src : Text, description : Optional Text }",
      ", allowedTools = None (List Text)",
      ", tags = [ \"review\" ]",
      ", launch = None { provider : Optional Text, mode : Optional Text, model : Optional Text }",
      "}"
    ]

sampleBlueprintDhall :: T.Text -> String
sampleBlueprintDhall n =
  unlines
    [ "{ name = \"" ++ T.unpack n ++ "\"",
      ", version = Some \"0.1.0\"",
      ", description = None Text",
      ", prompt = \"hello\"",
      ", vars =",
      "    [] : List",
      "          { name : Text",
      "          , type : Text",
      "          , default : Optional Text",
      "          , description : Optional Text",
      "          , required : Bool",
      "          , validation : Optional Text",
      "          }",
      ", prompts =",
      "    [] : List",
      "          { var : Text",
      "          , text : Text",
      "          , when : Optional Text",
      "          , choices : Optional (List Text)",
      "          }",
      ", baseModules =",
      "    [] : List { module : Text, vars : List { name : Text, value : Text } }",
      ", files =",
      "    [] : List { src : Text, description : Optional Text }",
      ", allowedTools = None (List Text)",
      ", tags = [] : List Text",
      "}"
    ]