packages feed

praxo-core-0.3.0: tests/Main.hs

module Main (main) where

import Control.Monad (forM_, unless)
import Data.Either (isLeft)
import Data.List (isInfixOf)
import Data.Ratio ((%))
import Data.IORef
import qualified Praxo.Agent as A
import Praxo.Json
import Praxo.JsonSchema
import Praxo.Middleware
import qualified Praxo.OpenAICompatible as O
import qualified Praxo.Anthropic as An
import qualified Praxo.Gemini as G
import qualified Praxo.Harmovela as H
import Praxo.Pattern
import Praxo.Plugin
import Praxo.Runtime
import Praxo.ToolCall
import Praxo.Wire

main :: IO ()
main = do
    agentTests
    runtimeTests
    middlewareTests
    adapterTests
    providerTests
    harmovelaTests
    assert "contract version" (specVersion == "0.2.0" && sdkVersion == "0.3.0")
    forM_ ["", "01", "1.", "+1", "NaN", "1e", "[1,]", "{\"a\":1,}", "{}{}", "\"\\q\"", "\"\\ud800\"", "\"\\udc00\"", "\"\\ud800\\u0041\"", "\"line\nbreak\"", "true garbage"] $ \raw ->
        assert ("invalid JSON " ++ show raw) (isLeft (parseJson raw))
    assert "unicode escape" (parseJson "\"\\u00e9\\ud83d\\ude00\"" == Right (JString "\x00e9\x1f600"))
    assert "raw Unicode" (parseJson "\"\x00e9\"" == Right (JString "\x00e9"))
    assert "exact numeric equality" (equalJson (json "1.00e2") (JNumber 100))
    assert "object ordering" (equalJson (json "{\"b\":2,\"a\":1}") (json "{\"a\":1,\"b\":2}"))
    assert "duplicate keys use last value" (equalJson (json "{\"a\":1,\"a\":2}") (json "{\"a\":2}"))
    assert "JSON round trip" ((encodeJson (JString "quote\" slash\\ null\0 \x1f600") >>= parseJson) == Right (JString "quote\" slash\\ null\0 \x1f600"))
    assert "non-decimal rational" (isLeft (encodeJson (JNumber (1 % 3))))
    assert "surrogate output rejected" (isLeft (encodeJson (JString ['\xD800'])))
    assert "JSON depth bounded" (isLeft (parseJson (replicate 66 '[' ++ "0" ++ replicate 66 ']')))
    assert "numeric exponent bounded" (isLeft (parseJson "1e99999"))
    assert "input size bounded" (isLeft (parseJson (replicate 1048577 ' ')))
    assert "toolcall governance metadata" (case parseToolCall "{\"name\":\"echo\",\"input\":{},\"idempotency_key\":\"k\"}" of
        Just call -> lookup "idempotency_key" (callMetadata call) == Just "k"
        _ -> False)
    assert "toolcall rejects array input" (parseToolCall "{\"name\":\"echo\",\"input\":[]}" == Nothing)
    assert "toolcall plain line must be dotted" (parseToolCall "echo {}" == Nothing)
    assert "toolcall empty name rejected" (parseToolCall "{\"name\":\"\",\"input\":{}}" == Nothing)
    assert "toolcall input bounded" (parseToolCall (replicate 262145 '<') == Nothing)
    assert "unterminated markup is preserved" (stripToolCallMarkup "abc<not a block" == "abc<not a block")
    assert "toolcall last block wins" (fmap callName (parseToolCall "<a.one>{}</a.one><b.two>{}</b.two>") == Just "b.two")
    assert "invalid final toolcall cannot invoke an earlier call" (parseToolCall "<a.one>{}</a.one><b.two>{bad}</b.two>" == Nothing)
    assert "typed governance metadata rejected" (parseToolCall "{\"name\":\"echo\",\"input\":{},\"risk\":42}" == Nothing)
    assert "invalid markup is not stripped" (stripToolCallMarkup "before<tool_call bogus>{}</tool_call>after" == "before<tool_call bogus>{}</tool_call>after")
    assert "markup text preserved" (stripToolCallMarkup "before<a.one>{}</a.one>after" == "beforeafter")
    assert "integer rejects fraction" (not (null (validation "{\"type\":\"integer\"}" "1.5")))
    assert "array item path" (map errorPath (validation "{\"type\":\"array\",\"items\":{\"type\":\"string\"}}" "[1]") == ["$[0]"])
    assert "numeric minimum" (not (null (validation "{\"type\":\"number\",\"minimum\":2}" "1")))
    assert "zero bound compatibility" (null (validation "{\"type\":\"number\",\"minimum\":0}" "-1"))
    assert "enum numeric equality" (null (validation "{\"enum\":[1]}" "1.0"))
    assert "optional missing property" (null (validation "{\"properties\":{\"a\":{\"type\":\"string\"}}}" "{}"))
    assert "pattern range" (matchPattern "^[a-z]+[0-9]?$" "abc2" == Right True)
    assert "pattern substring" (matchPattern "ab" "xxabyy" == Right True)
    assert "pattern start anchor" (matchPattern "^ab" "xxab" == Right False)
    assert "pattern negated class" (matchPattern "^[^0-9]+$" "abc" == Right True)
    assert "pattern escaped digit" (matchPattern "^\\d+$" "123" == Right True)
    assert "pattern rejects unsupported groups" (isLeft (matchPattern "(a|b)" "a"))
    assert "pattern bounded work" (isLeft (matchPattern "a*" (replicate 10000 'a')))
    assert "schema surfaces unsupported pattern" (isLeft (validateSchema (json "{\"pattern\":\"(a)\"}") (JString "a")))
    assert "schema rejects non-object" (isLeft (validateSchema JNull JNull))
    forM_ ["{\"type\":42}", "{\"required\":true}", "{\"properties\":[]}", "{\"items\":false}", "{\"minimum\":\"x\"}", "{\"minLength\":-1}", "{\"properties\":{\"absent\":{\"type\":42}}}"] $ \definition ->
        assert "schema rejects malformed declarations" (isLeft (validateSchema (json definition) JNull))
    let base = json "{\"schemaVersion\":1,\"id\":\"com.example.echo\",\"version\":\"1.0.0\",\"kind\":\"tool.provider.v1\",\"entrypoint\":{\"type\":\"process\",\"command\":\"echo\"},\"protocol\":\"jsonrpc-2\",\"capabilities\":[],\"permissions\":{},\"limits\":{},\"health\":{},\"isolation\":\"process\"}"
    assert "valid process manifest" (validateManifest base == Right ())
    assert "duplicate manifest fields rejected" (isLeft (validateManifest (JObject (("id", JString "com.example.echo") : asObject base))))
    forM_ [("extra", JBool True), ("id", JString "Bad.ID"), ("version", JString "01.0.0"), ("protocol", JString "v1"),
           ("capabilities", JArray [JString "x", JString "x"]), ("capabilities", JArray [JString ""]),
           ("limits", JObject [("memoryMB", JNumber (-1))]), ("limits", JObject [("unknown", JNumber 1)]),
           ("entrypoint", JObject [("type", JString "process")]), ("isolation", JString "wasm"),
           ("health", JObject [("intervalMs", JNumber 10)]), ("sbom", JString "../secret"),
           ("signature", JObject [("algorithm", JString "ed25519")]),
           ("permissions", JObject [("network", JArray [JObject [("host", JString "example.com"), ("port", JNumber 65536)]])])] $ \(key, value) ->
        assert ("invalid manifest " ++ key) (isLeft (validateManifest (set key value base)))
    assert "manifest trailing data" (isLeft (parseManifest "{} {}"))
    putStrLn "Haskell Core regression tests passed"

agentTests :: IO ()
agentTests = do
    events <- newIORef []
    requests <- newIORef []
    calls <- newIORef (0 :: Int)
    let call = A.ToolCall "id" "echo" (JObject [("x", JNumber 1)])
        sink name = modifyIORef' events (++ [name]) >> pure (Right ())
        model messages = do
            modifyIORef' requests (++ [messages])
            n <- readIORef calls
            pure (Right (if n == 0 then A.Response "" [call] 2 1 else A.Response "done" [] 1 1))
        tool _ = modifyIORef' calls (+1) >> pure (Right JNull)
        options = A.AgentOptions model tool sink 2 5
    result <- A.runAgent options [] "hello"
    assert "agent success and usage" (case result of Right r -> A.runSteps r == 2 && A.runInputTokens r == 3 && A.runOutputTokens r == 2; _ -> False)
    history <- readIORef requests
    assert "agent tool history" (case history of [_, [user, assistant, toolMessage]] -> A.messageRole user == "user" && A.messageToolCalls assistant == [call] && A.messageToolCallId toolMessage == "id" && A.messageContent toolMessage == "null"; _ -> False)
    recorded <- readIORef events
    assert "agent event order" (recorded == ["run.started", "tool.call", "run.completed"])
    let failure = A.AgentError "CUSTOM" "failure"
        checkFailure label opts expectedEvents = do
            writeIORef events []
            actual <- A.runAgent opts [] "x"
            recordedEvents <- readIORef events
            assert label (actual == Left failure && recordedEvents == expectedEvents)
    checkFailure "model error" (options { A.generate = \_ -> pure (Left failure) }) ["run.started", "run.failed"]
    checkFailure "tool error" (options { A.generate = \_ -> pure (Right (A.Response "" [call] 0 0)), A.invoke = \_ -> pure (Left failure) }) ["run.started", "tool.call", "run.failed"]
    let badSink name = sink name >> pure (if name == "tool.call" then Left failure else Right ())
    checkFailure "event error" (options { A.generate = \_ -> pure (Right (A.Response "" [call] 0 0)), A.emit = badSink }) ["run.started", "tool.call", "run.failed"]
    writeIORef events []
    started <- A.runAgent (options { A.emit = \name -> sink name >> pure (Left failure) }) [] "x"
    startedEvents <- readIORef events
    assert "started event error" (started == Left failure && startedEvents == ["run.started"])
    let completedSink name = sink name >> pure (if name == "run.completed" || name == "run.failed" then Left failure else Right ())
    checkFailure "completion event error" (options { A.generate = \_ -> pure (Right (A.Response "done" [] 0 0)), A.emit = completedSink }) ["run.started", "run.completed", "run.failed"]
    let failedSink name = sink name >> pure (if name == "run.failed" then Left (A.AgentError "SINK" "sink failure") else Right ())
    checkFailure "failure event preserves original error" (options { A.generate = \_ -> pure (Left failure), A.emit = failedSink }) ["run.started", "run.failed"]
    writeIORef events []
    budget <- A.runAgent (options { A.generate = \_ -> pure (Right (A.Response "x" [] 3 3)) }) [] "x"
    assert "input and output token budget" (case budget of Left err -> A.errorCode err == "AGENT_MAX_TOKENS"; _ -> False)
    let invalidUsage = Left (A.AgentError "AGENT_INVALID_USAGE" "agent: token usage must be non-negative")
    forM_ [("negative input", -1, 2), ("negative output", 2, -1)] $ \(label, input, output) -> do
        writeIORef events []
        writeIORef calls 0
        invalid <- A.runAgent (options { A.maxTokens = 0, A.generate = \_ -> pure (Right (A.Response "" [call] input output)) }) [] "x"
        invalidEvents <- readIORef events
        invalidCalls <- readIORef calls
        assert label (invalid == invalidUsage && invalidEvents == ["run.started", "run.failed"] && invalidCalls == 0)
    writeIORef events []
    writeIORef calls 0
    offset <- A.runAgent (options { A.generate = \_ -> do
        n <- readIORef calls
        pure (Right (if n == 0 then A.Response "" [call] 4 1 else A.Response "" [call] (-5) 2)) }) [] "x"
    offsetEvents <- readIORef events
    offsetCalls <- readIORef calls
    assert "negative usage cannot offset prior steps" (offset == invalidUsage && offsetEvents == ["run.started", "tool.call", "run.failed"] && offsetCalls == 1)
    encoding <- A.runAgent (options { A.generate = \_ -> pure (Right (A.Response "" [call] 0 0)), A.invoke = \_ -> pure (Right (JNumber (1 % 3))) }) [] "x"
    assert "tool result encoding error" (case encoding of Left err -> A.errorCode err == "TOOL_RESULT"; _ -> False)
    writeIORef calls 0
    limited <- A.runAgent (options { A.maxSteps = 0, A.generate = \_ -> pure (Right (A.Response "" [call] 0 0)) }) [] "x"
    count <- readIORef calls
    assert "default step budget" (count == 8 && case limited of Left err -> A.errorCode err == "AGENT_MAX_STEPS"; _ -> False)

runtimeTests :: IO ()
runtimeTests = do
    let (store1, _) = memoryPut newMemoryStore "s1" (MemoryRecord "m1" "event" "Alpha" "2026-01-01")
        (store2, _) = memoryPut store1 "s1" (MemoryRecord "m2" "event" "beta" "2026-01-02")
        (store3, _) = memoryPut store2 "s1" (MemoryRecord "m3" "decision" "ALPHABET" "2026-01-03")
    assert "memory ordering" (map recordId (memorySearch store3 "s1" "alpha" 10) == ["m3", "m1"])
    assert "memory limit" (map recordId (memorySearch store3 "s1" "alpha" 1) == ["m3"])
    assert "memory isolation" (null (memorySearch store3 "s2" "alpha" 10))
    let events = [Event "a" "s1" Nothing JNull, Event "b" "s1" Nothing JNull]
        filtered = foldl emitSink (filterSink newSink ["b"]) events
        multi = foldl emitSink (multiSink [newSink, newSink]) events
    assert "event filter" (sinkKinds filtered == ["b"])
    assert "event multi" (sinkKindLists multi == [["a", "b"], ["a", "b"]])
    assert "stream kinds" (streamKinds == ["values", "updates", "messages", "tasks", "debug", "checkpoint"])
    let allow = either error id (registerCheckpoint newCheckpoint "spec.payload")
    case checkpointMarshal allow "spec.payload" (json "{\"value\":\"x\"}") of
        Left detail -> fail detail
        Right data' -> case checkpointUnmarshal allow data' of
            Left detail -> fail detail
            Right decoded -> assert "checkpoint roundtrip" (equalJson decoded (json "{\"value\":\"x\"}"))
    assert "checkpoint allowlist" (checkpointMarshal allow "missing" JNull == Left "CHECKPOINT_TYPE_NOT_ALLOWED")
    let checker = either error id (pathChecker [PathGrant "allowed" "read"])
    assert "policy traversal" (not (pathAllowed checker "../allowed/secret" "read"))
    assert "policy inside" (pathAllowed checker "allowed/sub/../secret" "read")
    assert "policy parent segments" (normalizePath "../../a/../b" == "../../b")
    let messages = [ContextMessage "system" "sys" [] [] Nothing, ContextMessage "user" "message 0" [] [] Nothing, ContextMessage "user" "message 1" [] [] Nothing]
    assert "context estimate" (estimateTokens messages == 16)
    assert "context disabled" (let (output, ok) = compactContext (ContextBudget 0 0) messages in ok && length output == 3)

middlewareTests :: IO ()
middlewareTests = do
    assert "pii email" (piiRedact "email a@b.com" == "email [REDACTED:email]")
    assert "pii ssn" (piiRedact "ssn 123-45-6789" == "ssn [REDACTED:ssn]")
    assert "pii card" (piiRedact "card 4111 1111 1111 1111" == "card [REDACTED:credit_card]")
    assert "pii phone" (piiRedact "call 415-555-2671" == "call [REDACTED:phone]")
    assert "pii boundary" (piiRedact "version 1.2.3" == "version 1.2.3")
    let schema = json "{\"type\":\"object\",\"required\":[\"name\"],\"properties\":{\"name\":{\"type\":\"string\"}}}"
        repaired = structuredOutput schema 1 (Just (\_ -> Right "{\"name\":\"fixed\"}"))
        failing = structuredOutput schema 0 Nothing
    assert "structured repair" (applyStructured repaired "bad" == Right "{\"name\":\"fixed\"}")
    assert "structured failure" (applyStructured failing "bad" == Left "AGENT_STRUCTURED_OUTPUT")
    assert "tool limit" (case callLimitTool (callLimit 0 1) >>= callLimitTool of Left code -> code == "AGENT_TOOL_CALL_LIMIT"; _ -> False)
    assert "model limit" (case callLimitModel (callLimit 1 0) >>= callLimitModel of Left code -> code == "AGENT_MODEL_CALL_LIMIT"; _ -> False)
    assert "tool selection" (selectTools (toolSelection ["echo"]) ["echo", "other"] == ["echo"])
    assert "tool selection reject" (checkToolSelected (toolSelection []) "echo" == Left "AGENT_TOOL_NOT_SELECTED")
    assert "fallback" (fallbackEither (Left "MODEL_ERROR" :: Either String String) (Right "ok") == Right "ok")

adapterTests :: IO ()
adapterTests = do
    captured <- newIORef Nothing
    let responseBody = "{\"choices\":[{\"message\":{\"content\":\"done\",\"tool_calls\":[{\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"echo\",\"arguments\":\"{\\\"text\\\":\\\"hi\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2}}"
        transport request = do
            writeIORef captured (Just request)
            pure (Right (O.HttpResponse 200 responseBody))
        config = O.OpenAIConfig
            { O.openAIBaseURL = "https://api.example.com/v1/"
            , O.openAIModel = "test-model"
            , O.openAIKey = Just "secret"
            , O.openAIToolChoice = Just "auto"
            , O.openAITools = [O.OpenAITool "echo" "Echo" (JObject [("type", JString "object")])]
            , O.openAITransport = O.Transport transport
            }
        messages = [A.Message "user" "hi" [] "", A.Message "tool" "{}" [] "call-1"]
    result <- O.generate config messages
    case result of
        Left err -> fail ("openai adapter: " ++ A.errorMessage err)
        Right response -> do
            assert "openai content" (A.responseContent response == "done")
            assert "openai usage" (A.inputTokens response == 3 && A.outputTokens response == 2)
            case A.responseToolCalls response of
                [call] -> do
                    assert "openai call id" (A.toolCallId call == "call-1")
                    assert "openai call name" (A.toolCallName call == "echo")
                    assert "openai call arguments" (A.toolCallArguments call == JObject [("text", JString "hi")])
                _ -> fail "openai call"
    request <- readIORef captured
    case request of
        Nothing -> fail "openai transport not called"
        Just http -> do
            assert "openai method" (O.httpMethod http == "POST")
            assert "openai url" (O.httpURL http == "https://api.example.com/v1/chat/completions")
            assert "openai auth" (lookup "Authorization" (O.httpHeaders http) == Just "Bearer secret")
            let payload = json (O.httpBody http)
            assert "openai model" (equalJson (field "model" payload) (JString "test-model"))
            assert "openai tool choice" (equalJson (field "tool_choice" payload) (JString "auto"))
            let payloadMessages = asArray (field "messages" payload)
            assert "openai messages" (length payloadMessages == 2 && equalJson (field "tool_call_id" (payloadMessages !! 1)) (JString "call-1"))
            let payloadTools = asArray (field "tools" payload)
            assert "openai tools" (length payloadTools == 1 && equalJson (field "name" (field "function" (head payloadTools))) (JString "echo"))
    let failing = O.Transport (\_ -> pure (Right (O.HttpResponse 503 "down")))
    failed <- O.generate (config { O.openAITransport = failing }) messages
    assert "openai provider error" (case failed of Left err -> A.errorCode err == "PROVIDER_HTTP"; _ -> False)
    let missing = config
            { O.openAIBaseURL = ""
            , O.openAITransport = O.Transport (\_ -> fail "transport called for missing base URL")
            }
    missingResult <- O.generate missing messages
    assert "openai missing base url" (case missingResult of Left err -> A.errorCode err == "PROVIDER_HTTP"; _ -> False)
    let invalid = O.Transport (\_ -> pure (Right (O.HttpResponse 200 "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"c\",\"function\":{\"name\":\"x\",\"arguments\":\"{bad\"}}]}}]}")))
    invalidResult <- O.generate (config { O.openAITransport = invalid }) [A.Message "user" "hi" [] ""]
    assert "openai invalid arguments" (case invalidResult of Left err -> A.errorCode err == "PROVIDER_RESPONSE"; _ -> False)

providerTests :: IO ()
providerTests = do
    capturedAnthropic <- newIORef Nothing
    let anthropicTransport = O.Transport (\request -> do
            writeIORef capturedAnthropic (Just request)
            pure (Right (O.HttpResponse 200 "{\"content\":[{\"type\":\"text\",\"text\":\"hello\"},{\"type\":\"tool_use\",\"id\":\"tool-1\",\"name\":\"echo\",\"input\":{\"text\":\"hi\"}}],\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":3,\"output_tokens\":4,\"cache_read_input_tokens\":2}}")))
        anthropicConfig = An.AnthropicConfig
            { An.anthropicBaseURL = "https://api.anthropic.com"
            , An.anthropicModel = "claude-test"
            , An.anthropicKey = Just "ak"
            , An.anthropicVersion = "2023-06-01"
            , An.anthropicMaxTokens = 1024
            , An.anthropicToolChoice = Just "auto"
            , An.anthropicTools = [An.AnthropicTool "echo" "Echo" (JObject [("type", JString "object")])]
            , An.anthropicTransport = anthropicTransport
            }
        messages = [A.Message "system" "be brief" [] "", A.Message "user" "hi" [] ""]
    anthropicResult <- An.generateAnthropic anthropicConfig messages
    case anthropicResult of
        Left err -> fail ("anthropic adapter: " ++ A.errorMessage err)
        Right response -> do
            assert "anthropic content" (A.responseContent response == "hello")
            assert "anthropic usage" (A.inputTokens response == 3 && A.outputTokens response == 4)
            case A.responseToolCalls response of
                [call] -> do
                    assert "anthropic call id" (A.toolCallId call == "tool-1")
                    assert "anthropic call name" (A.toolCallName call == "echo")
                    assert "anthropic arguments" (A.toolCallArguments call == JObject [("text", JString "hi")])
                _ -> fail "anthropic call"
    request <- readIORef capturedAnthropic
    case request of
        Nothing -> fail "anthropic transport not called"
        Just http -> do
            assert "anthropic url" (O.httpURL http == "https://api.anthropic.com/v1/messages")
            assert "anthropic key" (lookup "x-api-key" (O.httpHeaders http) == Just "ak")
            let payload = json (O.httpBody http)
            assert "anthropic model" (field "model" payload == JString "claude-test")
            assert "anthropic system" (field "system" payload == JString "be brief")
            assert "anthropic tools" (length (asArray (field "tools" payload)) == 1)

    capturedGemini <- newIORef Nothing
    let geminiTransport = O.Transport (\request -> do
            writeIORef capturedGemini (Just request)
            pure (Right (O.HttpResponse 200 "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello\"},{\"functionCall\":{\"name\":\"echo\",\"args\":{\"text\":\"hi\"}}}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":3,\"candidatesTokenCount\":4}}")))
        geminiConfig = G.GeminiConfig
            { G.geminiBaseURL = "https://generativelanguage.googleapis.com"
            , G.geminiModel = "gemini-test"
            , G.geminiKey = Just "gk"
            , G.geminiMaxOutputTokens = 0
            , G.geminiToolChoice = Just "auto"
            , G.geminiTools = [G.GeminiTool "echo" "Echo" (JObject [("type", JString "object")])]
            , G.geminiTransport = geminiTransport
            }
    geminiResult <- G.generateGemini geminiConfig messages
    case geminiResult of
        Left err -> fail ("gemini adapter: " ++ A.errorMessage err)
        Right response -> do
            assert "gemini content" (A.responseContent response == "hello")
            assert "gemini usage" (A.inputTokens response == 3 && A.outputTokens response == 4)
            case A.responseToolCalls response of
                [call] -> assert "gemini call name" (A.toolCallName call == "echo")
                _ -> fail "gemini call"
    geminiRequest <- readIORef capturedGemini
    case geminiRequest of
        Nothing -> fail "gemini transport not called"
        Just http -> do
            assert "gemini url" (":generateContent" `isInfixOf` O.httpURL http)
            assert "gemini key" (lookup "x-goog-api-key" (O.httpHeaders http) == Just "gk")
            let payload = json (O.httpBody http)
            assert "gemini system" (length (asArray (field "parts" (field "system_instruction" payload))) == 1)
            assert "gemini tools" (length (asArray (field "functionDeclarations" (head (asArray (field "tools" payload))))) == 1)

harmovelaTests :: IO ()
harmovelaTests = do
    captured <- newIORef ([] :: [H.HarmovelaRequest])
    let transport = H.HarmovelaTransport (\request -> do
            modifyIORef' captured (++ [request])
            pure (Right (H.HarmovelaResponse 201 "")))
        sink = H.newHarmovelaSink "http://harmovela.test/aep/api/" transport (pure 1794294000000000000)
        event = Event "tool.call" "s1" (Just "run-123") (JObject [("name", JString "echo")])
    result <- H.emitHarmovelaEvent sink event
    assert "harmovela success" (result == Right ())
    requests <- readIORef captured
    case requests of
        [request] -> do
            assert "harmovela method" (H.harmovelaMethod request == "POST")
            assert "harmovela path" (H.harmovelaURL request == "http://harmovela.test/aep/api/events")
            assert "harmovela content type" (lookup "content-type" (H.harmovelaHeaders request) == Just "application/json")
            let envelope = json (H.harmovelaBody request)
            assert "harmovela spec" (field "spec_version" envelope == JString "0.2")
            assert "harmovela id" (field "id" envelope == JString "run-123-1794294000000000000")
            assert "harmovela type" (field "type" envelope == JString "tool.call")
            assert "harmovela source" (field "source" envelope == JString "praxo-core")
            assert "harmovela created at" (field "created_at" envelope == JString "2026-11-10T07:00:00.000000000Z")
            assert "harmovela payload" (equalJson (field "payload" envelope)
                (json "{\"name\":\"echo\",\"session_id\":\"s1\",\"run_id\":\"run-123\"}"))
        _ -> fail "harmovela request count"
    customCaptured <- newIORef ([] :: [H.HarmovelaRequest])
    let customTransport = H.HarmovelaTransport (\request -> do
            modifyIORef' customCaptured (++ [request])
            pure (Right (H.HarmovelaResponse 204 "")))
        custom = (H.newHarmovelaSink "http://harmovela.test" customTransport (pure 1794294000000000000))
            { H.harmovelaSource = "custom", H.harmovelaSpecVersion = "0.3" }
        mergedEvent = Event "tool.call" "s1" Nothing
            (JObject [("name", JString "echo"), ("session_id", JString "event-side"), ("run_id", JString "old")])
    customResult <- H.emitHarmovelaEvent custom mergedEvent
    assert "harmovela custom success" (customResult == Right ())
    customRequests <- readIORef customCaptured
    case customRequests of
        [request] -> do
            let envelope = json (H.harmovelaBody request)
            assert "harmovela custom source" (field "source" envelope == JString "custom")
            assert "harmovela custom spec" (field "spec_version" envelope == JString "0.3")
            assert "harmovela session id prefix" (take 3 (asString (field "id" envelope)) == "s1-")
            assert "harmovela merged payload" (equalJson (field "payload" envelope)
                (json "{\"name\":\"echo\",\"session_id\":\"s1\",\"run_id\":\"old\"}"))
        _ -> fail "harmovela custom request count"
    forM_ [200, 201, 204, 299] $ \status -> do
        let accepted = H.newHarmovelaSink "http://harmovela.test"
                (H.HarmovelaTransport (\_ -> pure (Right (H.HarmovelaResponse status ""))))
                (pure 1)
        acceptedResult <- H.emitHarmovelaEvent accepted event
        assert ("harmovela accepted status " ++ show status) (acceptedResult == Right ())
    let failing = H.newHarmovelaSink "http://harmovela.test"
            (H.HarmovelaTransport (\_ -> pure (Right (H.HarmovelaResponse 503 (concat (replicate 100 "service down "))))))
            (pure 1)
    failed <- H.emitHarmovelaEvent failing event
    case failed of
        Left err -> do
            assert "harmovela error code" (A.errorCode err == "HARMOVELA_HTTP")
            assert "harmovela error status" ("503" `isInfixOf` A.errorMessage err)
            assert "harmovela error body" ("service down" `isInfixOf` A.errorMessage err)
            assert "harmovela error bounded" (length (A.errorMessage err) <= 600)
        Right () -> fail "harmovela expected non-2xx error"
    let missing = H.newHarmovelaSink "" (H.HarmovelaTransport (\_ -> fail "harmovela transport called for missing base URL")) (pure 1)
    missingResult <- H.emitHarmovelaEvent missing event
    assert "harmovela missing base url" (case missingResult of Left err -> A.errorCode err == "HARMOVELA_URL"; _ -> False)

assert :: String -> Bool -> IO ()
assert label condition = unless condition (fail label)

json :: String -> Json
json = either error id . parseJson

validation :: String -> String -> [ValidationError]
validation schema value = either error id (validateSchema (json schema) (json value))

set :: String -> Json -> Json -> Json
set key value object = JObject ((key, value) : filter ((/= key) . fst) (asObject object))