From 38b007a6cd3ff278061e9bc9bb8504105f15515d Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 3 Jun 2026 15:52:08 +0400 Subject: [PATCH] =?UTF-8?q?feat(router):=20Task=203.2=20=E2=80=94=20Narrat?= =?UTF-8?q?ionTrigger=20+=20buildNarrationContext=20+=20RouterFacade.narra?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NarrationTrigger(kind, instruction) model to core:router - Add RouterContextBuilder.buildNarrationContext: minimal ContextPack (system + workflow status + L2 + trigger instruction); excludes conversationHistory and L3 recalled memory - Add RouterFacade.narrate: emits RouterNarrationEvent with content, latencyMs, tokensUsed; skips event on blank inference; never writes ChatTurnEvent or l3MemoryStore.store - Add RouterNarrationTest covering all acceptance criteria - Add narrate default to RouterContextBuilder interface so existing anonymous test stubs compile without a stub override --- .../core/router/RouterContextBuilder.kt | 109 ++++++ .../com/correx/core/router/RouterFacade.kt | 50 +++ .../core/router/model/NarrationTrigger.kt | 14 + .../src/test/kotlin/RouterFacadeTest.kt | 2 + .../src/test/kotlin/RouterNarrationTest.kt | 357 ++++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 core/router/src/main/kotlin/com/correx/core/router/model/NarrationTrigger.kt create mode 100644 testing/deterministic/src/test/kotlin/RouterNarrationTest.kt diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt index d09a30e6..8918544c 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt @@ -11,6 +11,7 @@ import com.correx.core.events.types.ContextPackId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.inference.Tokenizer +import com.correx.core.router.model.NarrationTrigger import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterL2Entry import com.correx.core.router.model.RouterState @@ -21,6 +22,17 @@ import kotlinx.coroutines.CancellationException interface RouterContextBuilder { suspend fun build(state: RouterState, budget: TokenBudget): ContextPack + + /** + * Builds a minimal [ContextPack] for a narration inference call. + * Implementations should include system prompt, workflow status, recent L2, and the trigger + * instruction, but must NOT include conversation history or L3 recalled memory. + * + * Default delegates to [build] so that anonymous test stubs that only override [build] compile + * without needing a stub override. + */ + suspend fun buildNarrationContext(state: RouterState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = + build(state, budget) } class DefaultRouterContextBuilder( @@ -256,4 +268,101 @@ class DefaultRouterContextBuilder( private fun fallbackEstimate(content: String): Int { return (content.length / 4).coerceAtLeast(1) } + + /** + * Builds a minimal [ContextPack] for a single narration inference call. + * + * Contains (in order, subject to [budget]): + * 1. L0 — system prompt (always present) + * 2. L0 — workflow status (always present) + * 3. L2 — recent stage summaries (oldest dropped first when tight) + * 4. L0 — trigger instruction (always present as the final user-facing entry) + * + * Conversation history and L3 recalled memory are intentionally excluded so + * narration lines never enter the conversation flow. + */ + override suspend fun buildNarrationContext( + state: RouterState, + trigger: NarrationTrigger, + budget: TokenBudget, + ): ContextPack { + val systemPromptEntry = buildContextEntry( + sourceType = "systemPrompt", + sourceId = "router-system", + content = SYSTEM_PROMPT, + layer = ContextLayer.L0, + role = EntryRole.SYSTEM, + ) + val workflowStatusEntry = buildContextEntry( + sourceType = "workflowStatus", + sourceId = state.currentStageId?.value ?: "none", + content = buildWorkflowStatusContent(state), + layer = ContextLayer.L0, + role = EntryRole.SYSTEM, + ) + val triggerEntry = buildContextEntry( + sourceType = "narrationTrigger", + sourceId = trigger.kind, + content = trigger.instruction, + layer = ContextLayer.L0, + role = EntryRole.USER, + ) + + val protectedTokens = systemPromptEntry.tokenEstimate + + workflowStatusEntry.tokenEstimate + + triggerEntry.tokenEstimate + var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0) + + val allEntries = mutableListOf() + allEntries += systemPromptEntry + allEntries += workflowStatusEntry + + var droppedCount = 0 + val truncatedLayers = mutableSetOf() + + // L2 stage summaries — newest-to-oldest so oldest get dropped first when tight. + val fittedL2 = mutableListOf() + for (l2Entry in state.l2Memory.asReversed()) { + val content = buildL2Content(l2Entry) + val entry = buildContextEntry( + sourceType = "stageSummary", + sourceId = l2Entry.stageId.value, + content = content, + layer = ContextLayer.L2, + role = EntryRole.SYSTEM, + ) + if (remainingBudget >= entry.tokenEstimate) { + remainingBudget -= entry.tokenEstimate + fittedL2.add(entry) + } else { + droppedCount++ + truncatedLayers.add(ContextLayer.L2) + } + } + fittedL2.reverse() + allEntries.addAll(fittedL2) + + // Trigger instruction is always last. + allEntries += triggerEntry + + val layers = allEntries + .groupBy { it.layer } + .toSortedMap(compareBy { it.ordinal }) + + val budgetUsed = allEntries.sumOf { it.tokenEstimate } + + return ContextPack( + id = ContextPackId("${state.sessionId?.value ?: "unknown"}-narration-pack"), + sessionId = state.sessionId ?: SessionId("unknown"), + stageId = state.currentStageId ?: StageId.NONE, + layers = layers, + budgetUsed = budgetUsed, + budgetLimit = budget.limit, + compressionMetadata = CompressionMetadata( + appliedStrategies = listOf("L0Immutable", "NarrationContext"), + truncatedLayers = truncatedLayers.toList(), + entriesDropped = droppedCount, + ), + ) + } } diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt index 6889aa4b..480a6efe 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt @@ -7,6 +7,7 @@ import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.L3MemoryRetrievedEvent import com.correx.core.events.events.L3RetrievedHit import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.RouterNarrationEvent import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.stores.EventStore import com.correx.core.events.types.EventId @@ -22,6 +23,7 @@ import com.correx.core.inference.TokenUsage import com.correx.core.router.l3.L3MemoryEntry import com.correx.core.router.l3.L3MemoryStore import com.correx.core.router.l3.L3Query +import com.correx.core.router.model.NarrationTrigger import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterResponse import kotlinx.coroutines.CancellationException @@ -37,6 +39,8 @@ interface RouterFacade { input: String, mode: ChatMode = ChatMode.CHAT, ): RouterResponse + + suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) } class DefaultRouterFacade( @@ -165,6 +169,52 @@ class DefaultRouterFacade( return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING)) } + override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) { + val state = routerRepository.getRouterState(sessionId) + val effectiveStageId = state.currentStageId ?: StageId.NONE + + val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget) + + val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General)) + val inferenceRequest = InferenceRequest( + requestId = InferenceRequestId(UUID.randomUUID().toString()), + sessionId = sessionId, + stageId = effectiveStageId, + contextPack = contextPack, + generationConfig = config.generationConfig, + responseFormat = ResponseFormat.Text, + ) + val inferenceResponse = provider.infer(inferenceRequest) + val content = inferenceResponse.text + + if (content.isBlank()) return + + val narrationId = UUID.randomUUID().toString() + val now = Clock.System.now() + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = now, + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = RouterNarrationEvent( + sessionId = sessionId, + narrationId = narrationId, + trigger = trigger.kind, + stageId = effectiveStageId.takeIf { it != StageId.NONE }?.value, + content = content, + latencyMs = inferenceResponse.latencyMs, + tokensUsed = inferenceResponse.tokensUsed, + timestampMs = now.toEpochMilliseconds(), + ), + ), + ) + } + private suspend fun emitChatTurn( sessionId: SessionId, content: String, diff --git a/core/router/src/main/kotlin/com/correx/core/router/model/NarrationTrigger.kt b/core/router/src/main/kotlin/com/correx/core/router/model/NarrationTrigger.kt new file mode 100644 index 00000000..e3e150a3 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/model/NarrationTrigger.kt @@ -0,0 +1,14 @@ +package com.correx.core.router.model + +/** + * Describes the event or condition that prompted a narration inference call. + * + * The trigger is stored verbatim in [RouterNarrationEvent.trigger] so it can be + * replayed and displayed without re-querying the environment. + */ +data class NarrationTrigger( + /** Short label identifying the trigger kind (e.g. "stage_completed", "tool_executed"). */ + val kind: String, + /** Human-readable instruction injected as the final context entry for this narration. */ + val instruction: String, +) diff --git a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt index fdc6bbcc..cb1e3b55 100644 --- a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt @@ -647,6 +647,8 @@ class RouterFacadeTest { object : RouterFacade { override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse = impl.onUserInput(sessionId = sessionId, input = input, mode = chatMode) + override suspend fun narrate(sessionId: SessionId, trigger: com.correx.core.router.model.NarrationTrigger) = + impl.narrate(sessionId = sessionId, trigger = trigger) } } diff --git a/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt b/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt new file mode 100644 index 00000000..ae2e6489 --- /dev/null +++ b/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt @@ -0,0 +1,357 @@ +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.ContextPack +import com.correx.core.context.model.TokenBudget +import com.correx.core.events.events.ChatTurnEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.RouterNarrationEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.Embedder +import com.correx.core.inference.FinishReason +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRequest +import com.correx.core.inference.InferenceResponse +import com.correx.core.inference.InferenceRouter +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.NoopEmbedder +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.ResponseFormat +import com.correx.core.inference.TokenUsage +import com.correx.core.router.DefaultRouterContextBuilder +import com.correx.core.router.DefaultRouterFacade +import com.correx.core.router.RouterContextBuilder +import com.correx.core.router.RouterRepository +import com.correx.core.router.l3.InMemoryL3MemoryStore +import com.correx.core.router.l3.L3MemoryEntry +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query +import com.correx.core.router.model.NarrationTrigger +import com.correx.core.router.model.RouterConfig +import com.correx.core.router.model.RouterState +import com.correx.core.router.model.WorkflowStatus +import com.correx.testing.fixtures.inference.MockTokenizer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RouterNarrationTest { + + // -------------------------------------------------------------------------- + // narrate() — event emission + // -------------------------------------------------------------------------- + + @Test + fun `narrate emits RouterNarrationEvent with inference content and metrics`(): Unit = runBlocking { + val knownLatencyMs = 55L + val knownUsage = TokenUsage(promptTokens = 8, completionTokens = 3) + val eventStore = MapBackedEventStore() + val facade = buildFacade( + eventStore = eventStore, + responseText = "narration line", + latencyMs = knownLatencyMs, + tokensUsed = knownUsage, + ) + val trigger = NarrationTrigger(kind = "stage_completed", instruction = "Summarise what just happened.") + + facade.narrate(SessionId("sess-1"), trigger) + + val narrationEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance() + assertEquals(1, narrationEvents.size) + val event = narrationEvents[0] + assertEquals("narration line", event.content) + assertEquals(knownLatencyMs, event.latencyMs) + assertNotNull(event.tokensUsed) + assertEquals(11, event.tokensUsed!!.totalTokens) + assertEquals("stage_completed", event.trigger) + } + + @Test + fun `narrate does not emit ChatTurnEvent`(): Unit = runBlocking { + val eventStore = MapBackedEventStore() + val facade = buildFacade(eventStore = eventStore, responseText = "narration text") + val trigger = NarrationTrigger(kind = "tool_executed", instruction = "Describe the tool result.") + + facade.narrate(SessionId("sess-2"), trigger) + + val chatEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance() + assertTrue(chatEvents.isEmpty(), "narrate must not emit any ChatTurnEvent") + } + + @Test + fun `narrate does not write to l3MemoryStore`(): Unit = runBlocking { + val trackingL3 = TrackingL3MemoryStore() + val facade = buildFacade( + l3MemoryStore = trackingL3, + responseText = "narration output", + ) + val trigger = NarrationTrigger(kind = "stage_completed", instruction = "Tell the user.") + + facade.narrate(SessionId("sess-3"), trigger) + + assertTrue(trackingL3.storedEntries.isEmpty(), "narrate must not call l3MemoryStore.store") + } + + @Test + fun `narrate skips event emission when inference returns blank`(): Unit = runBlocking { + val eventStore = MapBackedEventStore() + val facade = buildFacade(eventStore = eventStore, responseText = " ") + val trigger = NarrationTrigger(kind = "stage_completed", instruction = "Say something.") + + facade.narrate(SessionId("sess-4"), trigger) + + val narrationEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance() + assertTrue(narrationEvents.isEmpty(), "blank inference must not emit RouterNarrationEvent") + } + + @Test + fun `narrate skips event emission when inference returns empty string`(): Unit = runBlocking { + val eventStore = MapBackedEventStore() + val facade = buildFacade(eventStore = eventStore, responseText = "") + val trigger = NarrationTrigger(kind = "tool_executed", instruction = "Narrate.") + + facade.narrate(SessionId("sess-5"), trigger) + + val narrationEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance() + assertTrue(narrationEvents.isEmpty(), "empty inference must not emit RouterNarrationEvent") + } + + @Test + fun `narrate RouterNarrationEvent carries sessionId and stageId from state`(): Unit = runBlocking { + val stageId = StageId("my-stage") + val eventStore = MapBackedEventStore() + val facade = DefaultRouterFacade( + routerRepository = object : RouterRepository { + override suspend fun getRouterState(sessionId: SessionId): RouterState = + RouterState( + sessionId = sessionId, + workflowStatus = WorkflowStatus.RUNNING, + currentStageId = stageId, + ) + }, + routerContextBuilder = object : RouterContextBuilder { + override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + override suspend fun buildNarrationContext(state: RouterState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = emptyContextPack() + }, + inferenceRouter = mockInferenceRouter("stage narration", latencyMs = 10L, tokensUsed = TokenUsage(1, 1)), + eventStore = eventStore, + config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), + ) + val trigger = NarrationTrigger(kind = "stage_completed", instruction = "Describe the stage.") + + facade.narrate(SessionId("session-x"), trigger) + + val narrationEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance() + assertEquals(1, narrationEvents.size) + assertEquals(SessionId("session-x"), narrationEvents[0].sessionId) + assertEquals("my-stage", narrationEvents[0].stageId) + } + + // -------------------------------------------------------------------------- + // buildNarrationContext — content structure + // -------------------------------------------------------------------------- + + @Test + fun `buildNarrationContext includes system entry in L0`(): Unit = runBlocking { + val builder = DefaultRouterContextBuilder(RouterConfig()) + val state = RouterState(sessionId = SessionId("s"), workflowStatus = WorkflowStatus.IDLE) + val trigger = NarrationTrigger(kind = "test", instruction = "Do something.") + val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000)) + + val l0 = pack.layers[ContextLayer.L0] + assertNotNull(l0, "L0 layer must be present") + assertTrue(l0!!.isNotEmpty(), "L0 must contain at least the system prompt") + } + + @Test + fun `buildNarrationContext includes workflow status entry`(): Unit = runBlocking { + val builder = DefaultRouterContextBuilder(RouterConfig()) + val state = RouterState( + sessionId = SessionId("s"), + workflowStatus = WorkflowStatus.RUNNING, + currentStageId = StageId("my-stage"), + ) + val trigger = NarrationTrigger(kind = "test", instruction = "Describe.") + val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000)) + + val l0 = pack.layers[ContextLayer.L0] + assertNotNull(l0) + val allContent = l0!!.joinToString(" ") { it.content } + assertTrue(allContent.contains("my-stage"), "workflow status must mention the current stage") + } + + @Test + fun `buildNarrationContext includes trigger instruction as final entry`(): Unit = runBlocking { + val builder = DefaultRouterContextBuilder(RouterConfig()) + val state = RouterState(sessionId = SessionId("s"), workflowStatus = WorkflowStatus.IDLE) + val instruction = "Narrate the completed action now." + val trigger = NarrationTrigger(kind = "stage_completed", instruction = instruction) + val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000)) + + val allEntries = pack.layers.values.flatten() + assertTrue( + allEntries.any { it.content.contains(instruction) }, + "trigger instruction must appear in narration context", + ) + } + + @Test + fun `buildNarrationContext stays within budget`(): Unit = runBlocking { + val builder = DefaultRouterContextBuilder(RouterConfig()) + val state = RouterState(sessionId = SessionId("s"), workflowStatus = WorkflowStatus.IDLE) + val trigger = NarrationTrigger(kind = "test", instruction = "Short.") + val budget = TokenBudget(limit = 5000) + val pack = builder.buildNarrationContext(state, trigger, budget) + + assertTrue(pack.budgetUsed <= budget.limit, "narration context must not exceed budget") + } + + @Test + fun `buildNarrationContext does not include conversationHistory turns`(): Unit = runBlocking { + val builder = DefaultRouterContextBuilder(RouterConfig()) + val state = RouterState( + sessionId = SessionId("s"), + workflowStatus = WorkflowStatus.IDLE, + conversationHistory = listOf( + com.correx.core.router.model.RouterTurn( + role = com.correx.core.router.model.TurnRole.USER, + content = "secret conversation turn", + timestamp = kotlinx.datetime.Clock.System.now(), + ), + ), + ) + val trigger = NarrationTrigger(kind = "test", instruction = "Narrate.") + val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000)) + + val allContent = pack.layers.values.flatten().joinToString(" ") { it.content } + assertTrue( + !allContent.contains("secret conversation turn"), + "buildNarrationContext must not include conversationHistory turns", + ) + } + + // -------------------------------------------------------------------------- + // Helpers + // -------------------------------------------------------------------------- + + private fun buildFacade( + eventStore: EventStore = MapBackedEventStore(), + responseText: String = "narration", + latencyMs: Long = 10L, + tokensUsed: TokenUsage = TokenUsage(5, 5), + l3MemoryStore: L3MemoryStore = InMemoryL3MemoryStore(), + ): DefaultRouterFacade = DefaultRouterFacade( + routerRepository = object : RouterRepository { + override suspend fun getRouterState(sessionId: SessionId): RouterState = + RouterState( + sessionId = sessionId, + workflowStatus = WorkflowStatus.RUNNING, + currentStageId = StageId("s1"), + ) + }, + routerContextBuilder = DefaultRouterContextBuilder(RouterConfig()), + inferenceRouter = mockInferenceRouter(responseText, latencyMs, tokensUsed), + eventStore = eventStore, + config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = l3MemoryStore, + ) + + private fun mockInferenceRouter( + responseText: String, + latencyMs: Long = 10L, + tokensUsed: TokenUsage = TokenUsage(5, 5), + ): InferenceRouter = object : InferenceRouter { + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + ): InferenceProvider = object : InferenceProvider { + override val id = ProviderId("mock") + override val name = "Mock" + override val tokenizer = MockTokenizer() + override suspend fun infer(request: InferenceRequest): InferenceResponse = InferenceResponse( + requestId = request.requestId, + text = responseText, + finishReason = FinishReason.Stop, + tokensUsed = tokensUsed, + latencyMs = latencyMs, + ) + override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy + override fun capabilities(): Set = setOf(CapabilityScore(ModelCapability.General, 1.0)) + } + } + + private fun emptyContextPack(): ContextPack = ContextPack( + id = ContextPackId("empty"), + sessionId = SessionId("unknown"), + stageId = StageId("none"), + layers = emptyMap(), + budgetUsed = 0, + budgetLimit = 5000, + ) + + private class MapBackedEventStore : EventStore { + val appendedEvents = mutableListOf() + private val storedEvents: MutableMap = mutableMapOf() + private var nextSequence = 1L + + override suspend fun append(event: NewEvent): StoredEvent { + appendedEvents.add(event) + val stored = StoredEvent( + metadata = event.metadata, + sequence = nextSequence, + sessionSequence = nextSequence++, + payload = event.payload, + ) + storedEvents[event.metadata.eventId] = stored + return stored + } + + override suspend fun appendAll(events: List): List = + events.map { append(it) } + + override fun read(sessionId: SessionId): List = + storedEvents.values.filter { it.metadata.sessionId == sessionId }.toList() + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + read(sessionId).filter { it.sequence >= fromSequence } + + override fun lastSequence(sessionId: SessionId): Long? = + read(sessionId).maxOfOrNull { it.sequence } + + override fun subscribe(sessionId: SessionId): Flow = + throw UnsupportedOperationException("subscribe not implemented for mock") + + override fun allEvents(): Sequence = storedEvents.values.asSequence() + + override fun allSessionIds(): Set = storedEvents.values.map { it.metadata.sessionId }.toSet() + + override fun subscribeAll(): Flow = TODO("Not needed in this test context") + + override suspend fun lastGlobalSequence(): Long = TODO("Not needed in this test context") + } + + private class TrackingL3MemoryStore : L3MemoryStore { + val storedEntries = mutableListOf() + + override suspend fun store(entry: L3MemoryEntry) { + storedEntries.add(entry) + } + + override suspend fun query(query: L3Query): List = emptyList() + + override suspend fun close() = Unit + } +}