packages feed

kioku-core-0.1.0.0: test/Kioku/IdempotencySpec.hs

-- | The idempotent-accept contract for session and memory writes.
--
-- Every case here asserts two things: the right value came back, /and/ the event stream
-- gained no extra event. A write that returns @Right@ but quietly appended a second event
-- is not idempotent, and a write that returns a conflict but appended anyway has already
-- done the damage.
module Kioku.IdempotencySpec (tests) where

import Control.Monad (void)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Time (UTCTime, getCurrentTime)
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, liftIO, (:>))
import Effectful.Error.Static (Error)
import Keiro.Stream qualified as Stream
import Kioku.Api.Scope (MemoryScope (..), Namespace (..))
import Kioku.Api.Types (Confidence (..), MemoryType (..))
import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)
import Kioku.Id (MemoryId, SessionId, genMemoryId, genSessionId, idText)
import Kioku.Memory qualified as Memory
import Kioku.Memory.Domain (ArchiveMemoryData (..), RecordMemoryData (..), SupersedeMemoryData (..))
import Kioku.Memory.EventStream (memoryStream)
import Kioku.Memory.ReadModel (MemoryRow (..))
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Session qualified as Session
import Kioku.Session.Domain
  ( AwaitInputData (..),
    CompleteSessionData (..),
    FailSessionData (..),
    ResumeSessionData (..),
    StartSessionData (..),
  )
import Kioku.Session.EventStream (sessionStream)
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 (StreamVersion (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, assertFailure, testCase)

tests :: TestTree
tests =
  testGroup
    "Idempotent accepts"
    [ testGroup
        "sessions"
        [ testCase "an identical start is a duplicate" testStartDuplicate,
          testCase "a start with a different focus is a conflict" testStartConflict,
          testCase "an identical awaitInput is a duplicate" testAwaitDuplicate,
          testCase "an awaitInput with a different reason is a conflict" testAwaitConflict,
          testCase "an identical resume is a duplicate" testResumeDuplicate,
          testCase "a resume with different input is a conflict" testResumeConflict,
          testCase "an identical complete is a duplicate" testCompleteDuplicate,
          testCase "completing a failed session is a conflict" testCompleteAfterFail,
          testCase "failing a completed session is a conflict" testFailAfterComplete
        ],
      testGroup
        "memories"
        [ testCase "an identical record is a duplicate" testRecordDuplicate,
          testCase "a record retried with a fresh clock is a duplicate" testRecordRetriedWithNewClock,
          testCase "a record with different content is a conflict" testRecordConflict,
          testCase "an identical supersede is a duplicate" testSupersedeDuplicate,
          testCase "superseding by a different winner is a conflict" testSupersedeConflict,
          testCase "archiving a superseded memory is a conflict" testArchiveAfterSupersede,
          testCase "an identical merge is a duplicate" testMergeDuplicate,
          testCase "merging into a different winner is a conflict" testMergeConflict
        ]
    ]

-- * Sessions

testStartDuplicate :: Assertion
testStartDuplicate =
  withApp do
    sid <- liftIO genSessionId
    now <- liftIO getCurrentTime
    let cmd = startData sid now
    void (expectRight "first start" =<< Session.start cmd)
    void (expectRight "duplicate start" =<< Session.start cmd)
    assertSessionEvents sid 1

testStartConflict :: Assertion
testStartConflict =
  withApp do
    sid <- liftIO genSessionId
    now <- liftIO getCurrentTime
    let cmd = startData sid now
    void (expectRight "first start" =<< Session.start cmd)
    expectConflict "start with a different focus"
      =<< Session.start cmd {focus = "a different focus"}
    assertSessionEvents sid 1

testAwaitDuplicate :: Assertion
testAwaitDuplicate =
  withApp do
    sid <- startedSession
    now <- liftIO getCurrentTime
    let cmd = awaitData sid now
    void (expectRight "first awaitInput" =<< Session.awaitInput cmd)
    void (expectRight "duplicate awaitInput" =<< Session.awaitInput cmd)
    assertSessionEvents sid 2

testAwaitConflict :: Assertion
testAwaitConflict =
  withApp do
    sid <- startedSession
    now <- liftIO getCurrentTime
    let cmd = awaitData sid now
    void (expectRight "first awaitInput" =<< Session.awaitInput cmd)
    expectConflict "awaitInput with a different reason"
      =<< Session.awaitInput cmd {reason = "a different reason"}
    assertSessionEvents sid 2

testResumeDuplicate :: Assertion
testResumeDuplicate =
  withApp do
    sid <- startedSession
    now <- liftIO getCurrentTime
    void (expectRight "awaitInput" =<< Session.awaitInput (awaitData sid now))
    let cmd = resumeData sid now "approved"
    void (expectRight "first resume" =<< Session.resume cmd)
    void (expectRight "duplicate resume" =<< Session.resume cmd)
    assertSessionEvents sid 3

testResumeConflict :: Assertion
testResumeConflict =
  withApp do
    sid <- startedSession
    now <- liftIO getCurrentTime
    void (expectRight "awaitInput" =<< Session.awaitInput (awaitData sid now))
    void (expectRight "first resume" =<< Session.resume (resumeData sid now "approved"))
    -- The session is running again; a re-delivery carrying a *different* answer is not this
    -- request's own echo.
    expectConflict "resume with different input"
      =<< Session.resume (resumeData sid now "rejected")
    assertSessionEvents sid 3

testCompleteDuplicate :: Assertion
testCompleteDuplicate =
  withApp do
    sid <- startedSession
    now <- liftIO getCurrentTime
    let cmd = completeData sid now
    void (expectRight "first complete" =<< Session.complete cmd)
    void (expectRight "duplicate complete" =<< Session.complete cmd)
    assertSessionEvents sid 2

-- | The headline regression: this used to return @Right@ and report success for a session
-- that had actually failed.
testCompleteAfterFail :: Assertion
testCompleteAfterFail =
  withApp do
    sid <- startedSession
    now <- liftIO getCurrentTime
    void (expectRight "failSession" =<< Session.failSession (failData sid now))
    expectConflict "complete after fail" =<< Session.complete (completeData sid now)
    assertSessionEvents sid 2

testFailAfterComplete :: Assertion
testFailAfterComplete =
  withApp do
    sid <- startedSession
    now <- liftIO getCurrentTime
    void (expectRight "complete" =<< Session.complete (completeData sid now))
    expectConflict "fail after complete" =<< Session.failSession (failData sid now)
    assertSessionEvents sid 2

-- * Memories

testRecordDuplicate :: Assertion
testRecordDuplicate =
  withApp do
    mid <- liftIO genMemoryId
    now <- liftIO getCurrentTime
    let cmd = recordData mid now "the original content"
    void (expectRightM "first record" =<< Memory.record cmd)
    void (expectRightM "duplicate record" =<< Memory.record cmd)
    assertMemoryEvents mid 1

-- | @recordedAt@ must not participate in conflict detection. Distillation depends on this:
-- 'Kioku.Distill.L1.recordAtom' derives a deterministic memory id but stamps
-- @recordedAt = now@, so a timer re-fire re-records the same atom with a later clock. If
-- that were a conflict, every re-fire of an L1 pass would hard-fail — which is exactly what
-- happened when this contract was first written the other way.
testRecordRetriedWithNewClock :: Assertion
testRecordRetriedWithNewClock =
  withApp do
    mid <- liftIO genMemoryId
    firstAt <- liftIO getCurrentTime
    let content = "identical content, later clock"
    void (expectRightM "first record" =<< Memory.record (recordData mid firstAt content))
    laterAt <- liftIO getCurrentTime
    void (expectRightM "retry with a fresh clock" =<< Memory.record (recordData mid laterAt content))
    assertMemoryEvents mid 1
    lookedUp <- Memory.getMemoryRowById mid
    liftIO case lookedUp of
      Left err -> assertFailure ("lookup: " <> show err)
      Right Nothing -> assertFailure "the memory row vanished"
      Right (Just r) -> assertEqual "the first write's createdAt is kept" firstAt r.createdAt

testRecordConflict :: Assertion
testRecordConflict =
  withApp do
    mid <- liftIO genMemoryId
    now <- liftIO getCurrentTime
    void (expectRightM "first record" =<< Memory.record (recordData mid now "the original content"))
    expectConflictM "record with different content"
      =<< Memory.record (recordData mid now "something else entirely")
    assertMemoryEvents mid 1

testSupersedeDuplicate :: Assertion
testSupersedeDuplicate =
  withApp do
    loser <- recordedMemory "loser"
    winner <- recordedMemory "winner"
    now <- liftIO getCurrentTime
    let cmd = SupersedeMemoryData {memoryId = loser, supersededBy = winner, supersededAt = now}
    void (expectRightM "first supersede" =<< Memory.supersede cmd)
    void (expectRightM "duplicate supersede" =<< Memory.supersede cmd)
    assertMemoryEvents loser 2

-- | The other headline regression: supersede by X, then by Y, used to report success for Y
-- while X remained the recorded winner.
testSupersedeConflict :: Assertion
testSupersedeConflict =
  withApp do
    loser <- recordedMemory "loser"
    winnerX <- recordedMemory "winner x"
    winnerY <- recordedMemory "winner y"
    now <- liftIO getCurrentTime
    void $
      expectRightM "supersede by X"
        =<< Memory.supersede SupersedeMemoryData {memoryId = loser, supersededBy = winnerX, supersededAt = now}
    expectConflictM "supersede by Y after X"
      =<< Memory.supersede SupersedeMemoryData {memoryId = loser, supersededBy = winnerY, supersededAt = now}
    assertMemoryEvents loser 2

testArchiveAfterSupersede :: Assertion
testArchiveAfterSupersede =
  withApp do
    loser <- recordedMemory "loser"
    winner <- recordedMemory "winner"
    now <- liftIO getCurrentTime
    void $
      expectRightM "supersede"
        =<< Memory.supersede SupersedeMemoryData {memoryId = loser, supersededBy = winner, supersededAt = now}
    expectConflictM "archive after supersede"
      =<< Memory.archive ArchiveMemoryData {memoryId = loser, archivedAt = now}
    assertMemoryEvents loser 2

testMergeDuplicate :: Assertion
testMergeDuplicate =
  withApp do
    loser <- recordedMemory "loser"
    winner <- recordedMemory "winner"
    void (expectRightM "first merge" =<< Memory.merge loser winner)
    void (expectRightM "duplicate merge" =<< Memory.merge loser winner)
    assertMemoryEvents loser 2

testMergeConflict :: Assertion
testMergeConflict =
  withApp do
    loser <- recordedMemory "loser"
    winnerX <- recordedMemory "winner x"
    winnerY <- recordedMemory "winner y"
    void (expectRightM "merge into X" =<< Memory.merge loser winnerX)
    expectConflictM "merge into Y after X" =<< Memory.merge loser winnerY
    assertMemoryEvents loser 2

-- * Fixtures

startData :: SessionId -> UTCTime -> StartSessionData
startData sid startedAt =
  StartSessionData
    { sessionId = sid,
      agentId = "test-agent",
      focus = "idempotency",
      scope = testScope,
      subjectRef = Nothing,
      previousSessionId = Nothing,
      parentSessionId = Nothing,
      delegationDepth = 0,
      startedAt
    }

awaitData :: SessionId -> UTCTime -> AwaitInputData
awaitData sid awaitedAt =
  AwaitInputData
    { sessionId = sid,
      reason = "approval",
      correlationKey = Just "k1",
      deadline = Nothing,
      awaitedAt
    }

resumeData :: SessionId -> UTCTime -> Text -> ResumeSessionData
resumeData sid resumedAt input =
  ResumeSessionData
    { sessionId = sid,
      correlationKey = Just "k1",
      force = False,
      input,
      resumedAt
    }

completeData :: SessionId -> UTCTime -> CompleteSessionData
completeData sid completedAt =
  CompleteSessionData
    { sessionId = sid,
      completedAt,
      modelUsed = Just "test-model",
      summary = Just "done"
    }

failData :: SessionId -> UTCTime -> FailSessionData
failData sid failedAt =
  FailSessionData
    { sessionId = sid,
      failedAt,
      errorMessage = "boom"
    }

recordData :: MemoryId -> UTCTime -> Text -> RecordMemoryData
recordData mid recordedAt content =
  RecordMemoryData
    { memoryId = mid,
      agentId = "test-agent",
      sessionId = Nothing,
      scope = testScope,
      memoryType = MemoryFact,
      content,
      priority = 50,
      confidence = HighConfidence,
      tags = Set.fromList ["t"],
      supersedes = Nothing,
      recordedAt
    }

startedSession ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  Eff es SessionId
startedSession = do
  sid <- liftIO genSessionId
  now <- liftIO getCurrentTime
  void (expectRight "Session.start" =<< Session.start (startData sid now))
  pure sid

recordedMemory ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  Text ->
  Eff es MemoryId
recordedMemory content = do
  mid <- liftIO genMemoryId
  now <- liftIO getCurrentTime
  void (expectRightM "Memory.record" =<< Memory.record (recordData mid now content))
  pure mid

-- * Assertions

assertSessionEvents ::
  (IOE :> es, Store :> es) =>
  SessionId ->
  Int ->
  Eff es ()
assertSessionEvents sid expected = do
  events <- readStreamForward (Stream.streamName (sessionStream sid)) (StreamVersion 0) 100
  liftIO $
    assertEqual
      "the session stream gained no extra event"
      expected
      (Vector.length events)

assertMemoryEvents ::
  (IOE :> es, Store :> es) =>
  MemoryId ->
  Int ->
  Eff es ()
assertMemoryEvents mid expected = do
  events <- readStreamForward (Stream.streamName (memoryStream mid)) (StreamVersion 0) 100
  liftIO $
    assertEqual
      "the memory stream gained no extra event"
      expected
      (Vector.length events)

expectRight :: (IOE :> es) => String -> Either Session.SessionWriteError a -> Eff es a
expectRight label = \case
  Right value -> pure value
  Left err -> liftIO (assertFailure (label <> ": expected success, got " <> show err))

expectConflict :: (IOE :> es) => String -> Either Session.SessionWriteError SessionId -> Eff es ()
expectConflict label = \case
  Left (Session.SessionConflict reason) ->
    liftIO $ assertBool (label <> ": the conflict names a field") (reason /= "")
  other -> liftIO (assertFailure (label <> ": expected SessionConflict, got " <> show other))

expectRightM :: (IOE :> es) => String -> Either Memory.MemoryWriteError a -> Eff es a
expectRightM label = \case
  Right value -> pure value
  Left err -> liftIO (assertFailure (label <> ": expected success, got " <> show err))

expectConflictM :: (IOE :> es) => String -> Either Memory.MemoryWriteError MemoryId -> Eff es ()
expectConflictM label = \case
  Left (Memory.MemoryConflict reason) ->
    liftIO $ assertBool (label <> ": the conflict names a field") (reason /= "")
  other -> liftIO (assertFailure (label <> ": expected MemoryConflict, got " <> show other))

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

testScope :: MemoryScope
testScope = ScopeGlobal (Namespace "kioku-test")