kioku-core-0.1.0.0: test/Kioku/SessionInvariantsSpec.hs
-- | Invariants the session aggregate enforces itself, independent of any read-model
-- precheck. Every test here that matters drives 'runCommandWithProjections' directly, so a
-- passing assertion proves the state machine rejected (or accepted) the command — not that
-- a precheck happened to run first.
module Kioku.SessionInvariantsSpec (tests) where
import Control.Monad (void)
import Data.Aeson (Value, object, toJSON, (.=))
import Data.Text (Text)
import Data.Time (UTCTime (..), fromGregorian, getCurrentTime)
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, liftIO, (:>))
import Effectful.Error.Static (Error)
import Keiro.Command (CommandError (..), defaultRunCommandOptions)
import Keiro.Projection (runCommandWithProjections)
import Keiro.Stream qualified as Stream
import Kioku.Api.Scope (MemoryScope (..), Namespace (..))
import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)
import Kioku.Distill.Timer (l1TimerScheduleProjection)
import Kioku.Id (SessionId, genSessionId, idText)
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Session qualified as Session
import Kioku.Session.Domain
( AwaitInputData (..),
RecordTurnData (..),
ResumeSessionData (..),
SessionAwaitingData (..),
SessionCommand (..),
SessionEvent (..),
SessionResumedData (..),
SessionStartedData (..),
StartSessionData (..),
)
import Kioku.Session.EventStream (parseSessionEvent, sessionEventStream, sessionStream)
import Kioku.Session.ReadModel (SessionRow (..), sessionInlineProjection)
import Kiroku.Store.Append (appendToStream)
import Kiroku.Store.Connection (defaultConnectionSettings)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Read (readStreamForward)
import Kiroku.Store.Types
( EventData (..),
EventType (..),
ExpectedVersion (..),
RecordedEvent (..),
StreamVersion (..),
)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, assertFailure, testCase)
tests :: TestTree
tests =
testGroup
"Session invariants"
[ testCase "aggregate rejects a mismatched resume key" testAggregateRejectsMismatchedKey,
testCase "a stale resume after re-park is rejected" testStaleResumeAfterRepark,
testCase "forceResume waives the key explicitly" testForceResume,
testCase "a keyless wait resumes with no key" testKeylessWaitResumes,
testCase "a keyed resume of a keyless wait is rejected" testKeyedResumeOfKeylessWait,
testCase "re-park clears the previous resume input" testReparkClearsResumeInput,
testCase "pre-force keyless resume events still hydrate" testHydratesLegacyKeylessResume,
testCase "pre-force keyed resume events still hydrate" testHydratesLegacyKeyedResume,
testGroup
"turn identity"
[ testCase "an identical re-record is a duplicate" testTurnReRecordIsDuplicate,
testCase "the same index with different content is a conflict" testTurnSameIndexConflict,
testCase "the same turn id at a different index is a conflict" testTurnIdReuseConflict,
testCase "the aggregate rejects a non-increasing index" testAggregateRejectsNonIncreasingTurn
]
]
-- | The core proof: with the session parked on @k1@, a @ResumeSession@ carrying @k2@ is
-- rejected by the transducer itself. No read-model precheck runs on this path, so the
-- rejection can only have come from the aggregate's guard.
testAggregateRejectsMismatchedKey :: Assertion
testAggregateRejectsMismatchedKey =
withApp do
sid <- startFixture
parkFixture sid (Just "k1")
now <- liftIO getCurrentTime
result <-
runSessionCommandDirect
sid
( ResumeSession
ResumeSessionData
{ sessionId = sid,
correlationKey = Just "k2",
force = False,
input = "approved",
resumedAt = now
}
)
liftIO $ assertRejected "mismatched resume key" result
events <- readSessionEvents sid
liftIO $
assertEqual
"no SessionResumed event was appended"
["SessionStarted", "SessionAwaiting"]
(eventName <$> events)
-- | The race the plan closes: park k1, resume k1, re-park on k2. A caller still holding
-- k1 must not be able to answer the k2 wait.
testStaleResumeAfterRepark :: Assertion
testStaleResumeAfterRepark =
withApp do
sid <- startFixture
parkFixture sid (Just "k1")
resumeFixture sid (Just "k1") "first answer"
parkFixture sid (Just "k2")
now <- liftIO getCurrentTime
let staleResume =
ResumeSessionData
{ sessionId = sid,
correlationKey = Just "k1",
force = False,
input = "stale answer",
resumedAt = now
}
-- Through the public API the precheck catches it early ...
apiResult <- Session.resume staleResume
case apiResult of
Left Session.SessionCorrelationMismatch -> pure ()
other -> liftIO (assertFailure ("expected SessionCorrelationMismatch, got " <> show other))
-- ... and with the precheck bypassed, the aggregate catches it too.
directResult <- runSessionCommandDirect sid (ResumeSession staleResume)
liftIO $ assertRejected "stale resume key" directResult
-- The live wait still resumes.
resumeFixture sid (Just "k2") "second answer"
row <- getExisting sid
liftIO do
assertEqual "resumed with the live key" "running" row.status
assertEqual "the live answer won" (Just "second answer") row.resumeInput
testForceResume :: Assertion
testForceResume =
withApp do
sid <- startFixture
parkFixture sid (Just "k1")
now <- liftIO getCurrentTime
result <- Session.forceResume sid "forced answer" now
void (liftEither "Session.forceResume" result)
row <- getExisting sid
liftIO do
assertEqual "force resume ran" "running" row.status
assertEqual "force resume input" (Just "forced answer") row.resumeInput
events <- readSessionEvents sid
liftIO $
assertBool "the appended SessionResumed event carries force = True" $
case [d | SessionResumed d <- events] of
[d] -> d.force && isNothing' d.correlationKey
_ -> False
where
isNothing' = \case
Nothing -> True
Just (_ :: Text) -> False
testKeylessWaitResumes :: Assertion
testKeylessWaitResumes =
withApp do
sid <- startFixture
parkFixture sid Nothing
resumeFixture sid Nothing "approved"
row <- getExisting sid
liftIO do
assertEqual "keyless resume ran" "running" row.status
assertEqual "keyless resume input" (Just "approved") row.resumeInput
-- | Exact 'Maybe' equality: naming a key when the wait has none is a mismatch, not a
-- harmless extra.
testKeyedResumeOfKeylessWait :: Assertion
testKeyedResumeOfKeylessWait =
withApp do
sid <- startFixture
parkFixture sid Nothing
now <- liftIO getCurrentTime
result <-
runSessionCommandDirect
sid
( ResumeSession
ResumeSessionData
{ sessionId = sid,
correlationKey = Just "unexpected",
force = False,
input = "approved",
resumedAt = now
}
)
liftIO $ assertRejected "keyed resume of a keyless wait" result
testReparkClearsResumeInput :: Assertion
testReparkClearsResumeInput =
withApp do
sid <- startFixture
parkFixture sid (Just "k1")
resumeFixture sid (Just "k1") "first answer"
parkFixture sid (Just "k2")
row <- getExisting sid
liftIO do
assertEqual "re-parked" "awaiting" row.status
assertEqual "the new wait's key is visible" (Just "k2") row.awaitingCorrelationKey
assertEqual "the previous wait's answer is gone" Nothing row.resumeInput
-- | A stream written before the @force@ field existed, whose resume omitted the
-- correlation key. Under the old code that omission /was/ a bypass, so it must replay
-- through the force arm of the new guard.
testHydratesLegacyKeylessResume :: Assertion
testHydratesLegacyKeylessResume = hydrateLegacyStream Nothing
-- | The same, but the historical resume named the key it parked on: it must replay through
-- the matching arm.
testHydratesLegacyKeyedResume :: Assertion
testHydratesLegacyKeyedResume = hydrateLegacyStream (Just "k1")
-- | Hand-append a pre-@force@ event stream (no @force@ key in the @SessionResumed@ JSON),
-- then drive a real command against it. Success proves keiro rehydrated all three events
-- through the new guard: a rejected historical event would surface as
-- 'HydrationReplayFailed' instead.
--
-- The command runs through 'runCommandWithProjections' rather than 'Session.awaitInput'
-- because a hand-appended stream has no read-model row, and the public API's precheck
-- would fail with 'SessionNotFound' before hydration was ever attempted.
hydrateLegacyStream :: Maybe Text -> Assertion
hydrateLegacyStream resumedKey =
withApp do
sid <- liftIO genSessionId
now <- liftIO getCurrentTime
let started =
SessionStarted
SessionStartedData
{ sessionId = sid,
agentId = "test-agent",
focus = "legacy replay",
scope = testScope,
subjectRef = Nothing,
previousSessionId = Nothing,
parentSessionId = Nothing,
delegationDepth = 0,
startedAt = now
}
awaiting =
SessionAwaiting
SessionAwaitingData
{ sessionId = sid,
reason = "approval",
correlationKey = Just "k1",
deadline = Nothing,
awaitedAt = now
}
void $
appendToStream
(Stream.streamName (sessionStream sid))
NoStream
[ rawEvent "SessionStarted" (toJSON started),
rawEvent "SessionAwaiting" (toJSON awaiting),
rawEvent "SessionResumed" (legacyResumedPayload sid resumedKey now)
]
-- The stream now ends in Running. A fresh AwaitInput must hydrate it and be accepted.
result <-
runSessionCommandDirect
sid
( AwaitInput
AwaitInputData
{ sessionId = sid,
reason = "approval",
correlationKey = Just "k2",
deadline = Nothing,
awaitedAt = now
}
)
case result of
Right _ -> pure ()
Left err ->
liftIO (assertFailure ("a pre-force stream failed to hydrate: " <> show err))
events <- readSessionEvents sid
liftIO $
assertEqual
"the new event landed on top of the replayed history"
["SessionStarted", "SessionAwaiting", "SessionResumed", "SessionAwaiting"]
(eventName <$> events)
-- * Turn identity
testTurnReRecordIsDuplicate :: Assertion
testTurnReRecordIsDuplicate =
withApp do
sid <- startFixture
let turn = turnData sid "turn-a" 0 "hello"
void (liftEither "first recordTurn" =<< Session.recordTurn turn)
void (liftEither "duplicate recordTurn" =<< Session.recordTurn turn)
events <- readSessionEvents sid
liftIO $
assertEqual
"the duplicate appended no second TurnRecorded"
1
(length [() | TurnRecorded _ <- events])
turns <- liftEither "getTurns" =<< Session.getTurns sid
liftIO $ assertEqual "exactly one turn row" 1 (length turns)
testTurnSameIndexConflict :: Assertion
testTurnSameIndexConflict =
withApp do
sid <- startFixture
void (liftEither "first recordTurn" =<< Session.recordTurn (turnData sid "turn-a" 0 "hello"))
result <- Session.recordTurn (turnData sid "turn-a" 0 "something else")
liftIO $ assertConflict "same index, different content" result
events <- readSessionEvents sid
liftIO $
assertEqual "no second TurnRecorded" 1 (length [() | TurnRecorded _ <- events])
testTurnIdReuseConflict :: Assertion
testTurnIdReuseConflict =
withApp do
sid <- startFixture
void (liftEither "first recordTurn" =<< Session.recordTurn (turnData sid "turn-a" 0 "hello"))
result <- Session.recordTurn (turnData sid "turn-a" 1 "a new turn reusing the id")
liftIO $ assertConflict "turn id reused at a different index" result
events <- readSessionEvents sid
liftIO $
assertEqual "no second TurnRecorded" 1 (length [() | TurnRecorded _ <- events])
-- | The command layer's dedup is a convenience; this proves the state machine enforces the
-- strictly-increasing index on its own, with every precheck bypassed.
testAggregateRejectsNonIncreasingTurn :: Assertion
testAggregateRejectsNonIncreasingTurn =
withApp do
sid <- startFixture
void (liftEither "recordTurn 0" =<< Session.recordTurn (turnData sid "turn-a" 0 "hello"))
void (liftEither "recordTurn 1" =<< Session.recordTurn (turnData sid "turn-b" 1 "again"))
now <- liftIO getCurrentTime
result <-
runSessionCommandDirect
sid
( RecordTurn
RecordTurnData
{ sessionId = sid,
turnId = "turn-c",
turnIndex = 1,
role = "user",
content = "a stale index",
toolSummary = Nothing,
promptTokens = Nothing,
outputTokens = Nothing,
recordedAt = now
}
)
liftIO $ assertRejected "non-increasing turn index" result
events <- readSessionEvents sid
liftIO $
assertEqual "still only the two committed turns" 2 (length [() | TurnRecorded _ <- events])
turnData :: SessionId -> Text -> Int -> Text -> RecordTurnData
turnData sid turnId turnIndex content =
RecordTurnData
{ sessionId = sid,
turnId,
turnIndex,
role = "user",
content,
toolSummary = Nothing,
promptTokens = Nothing,
outputTokens = Nothing,
recordedAt = fixedRecordedAt
}
-- | Turn recording compares semantic fields, not the clock (see 'Kioku.Session.mismatchOf'),
-- but pinning the timestamp keeps these fixtures obviously identical.
fixedRecordedAt :: UTCTime
fixedRecordedAt = UTCTime (fromGregorian 2026 7 11) 0
assertConflict :: String -> Either Session.SessionWriteError SessionId -> Assertion
assertConflict label = \case
Left (Session.SessionConflict _) -> pure ()
other -> assertFailure (label <> ": expected SessionConflict, got " <> show other)
-- | The @SessionResumed@ JSON exactly as it was written before this plan: no @force@ key.
legacyResumedPayload :: SessionId -> Maybe Text -> UTCTime -> Value
legacyResumedPayload sid key resumedAt =
object
[ "type" .= ("session_resumed" :: Text),
"data"
.= object
[ "sessionId" .= idText sid,
"correlationKey" .= key,
"input" .= ("approved" :: Text),
"resumedAt" .= resumedAt
]
]
rawEvent :: Text -> Value -> EventData
rawEvent typ payload =
EventData
{ eventId = Nothing,
eventType = EventType typ,
payload,
metadata = Nothing,
causationId = Nothing,
correlationId = Nothing
}
-- | Run a command straight at the aggregate, skipping 'Kioku.Session''s read-model
-- prechecks. This is the harness that makes "the aggregate enforces it" a testable claim.
runSessionCommandDirect ::
(IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
SessionId ->
SessionCommand ->
Eff es (Either CommandError ())
runSessionCommandDirect sid cmd =
void
<$> runCommandWithProjections
defaultRunCommandOptions
sessionEventStream
(sessionStream sid)
cmd
[sessionInlineProjection, l1TimerScheduleProjection]
assertRejected :: String -> Either CommandError () -> Assertion
assertRejected label = \case
Left CommandRejected -> pure ()
Left err -> assertFailure (label <> ": expected CommandRejected, got " <> show err)
Right () -> assertFailure (label <> ": expected CommandRejected, but the command was accepted")
withApp :: Eff AppEffects a -> IO a
withApp action =
withKiokuMigratedDatabase \connStr ->
withNoopAppEnv (defaultConnectionSettings connStr) \env -> do
result <- runAppIO env action
case result of
Left storeErr -> assertFailure ("store error: " <> show storeErr)
Right value -> pure value
startFixture ::
(IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
Eff es SessionId
startFixture = do
sid <- liftIO genSessionId
now <- liftIO getCurrentTime
result <-
Session.start
StartSessionData
{ sessionId = sid,
agentId = "test-agent",
focus = "session invariants",
scope = testScope,
subjectRef = Nothing,
previousSessionId = Nothing,
parentSessionId = Nothing,
delegationDepth = 0,
startedAt = now
}
void (liftEither "Session.start" result)
pure sid
parkFixture ::
(IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
SessionId ->
Maybe Text ->
Eff es ()
parkFixture sid key = do
now <- liftIO getCurrentTime
result <-
Session.awaitInput
AwaitInputData
{ sessionId = sid,
reason = "approval",
correlationKey = key,
deadline = Nothing,
awaitedAt = now
}
void (liftEither "Session.awaitInput" result)
resumeFixture ::
(IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
SessionId ->
Maybe Text ->
Text ->
Eff es ()
resumeFixture sid key input = do
now <- liftIO getCurrentTime
result <-
Session.resume
ResumeSessionData
{ sessionId = sid,
correlationKey = key,
force = False,
input,
resumedAt = now
}
void (liftEither "Session.resume" result)
getExisting ::
(IOE :> es, Store :> es) =>
SessionId ->
Eff es SessionRow
getExisting sid = do
result <- Session.getById sid >>= liftEither "Session.getById"
case result of
Nothing -> liftIO (assertFailure ("missing session row " <> show (idText sid)))
Just row -> pure row
readSessionEvents ::
(IOE :> es, Store :> es) =>
SessionId ->
Eff es [SessionEvent]
readSessionEvents sid = do
recorded <- Vector.toList <$> readStreamForward (Stream.streamName (sessionStream sid)) (StreamVersion 0) 100
traverse decodeRecorded recorded
where
decodeRecorded recorded =
case parseSessionEvent recorded.payload of
Left err -> liftIO (assertFailure ("parseSessionEvent: " <> show err))
Right event -> pure event
liftEither :: (Show e, IOE :> es) => String -> Either e a -> Eff es a
liftEither label = \case
Left err -> liftIO (assertFailure (label <> ": " <> show err))
Right value -> pure value
eventName :: SessionEvent -> Text
eventName = \case
SessionStarted {} -> "SessionStarted"
SessionAwaiting {} -> "SessionAwaiting"
SessionResumed {} -> "SessionResumed"
SessionCompleted {} -> "SessionCompleted"
SessionFailed {} -> "SessionFailed"
InteractiveSessionRecorded {} -> "InteractiveSessionRecorded"
TurnRecorded {} -> "TurnRecorded"
testScope :: MemoryScope
testScope = ScopeGlobal (Namespace "kioku-test")