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.ChatTurnRole import com.correx.core.events.events.ContextTruncatedEvent 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.StoredEvent import com.correx.core.events.events.WorkflowProposedEvent 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.InferenceRequestId 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.talkie.ChatMode import com.correx.core.talkie.DefaultTalkieContextBuilder import com.correx.core.talkie.DefaultTalkieFacade import com.correx.core.talkie.DefaultTalkieReducer import com.correx.core.talkie.IdeaReader import com.correx.core.talkie.TalkieContextBuilder import com.correx.core.talkie.TalkieFacade import com.correx.core.talkie.TalkieProjector import com.correx.core.talkie.TalkieRepository import com.correx.core.talkie.l3.InMemoryL3MemoryStore import com.correx.core.talkie.l3.L3MemoryEntry import com.correx.core.talkie.l3.L3MemoryStore import com.correx.core.talkie.l3.L3Query import com.correx.core.talkie.model.TalkieConfig import com.correx.core.talkie.model.TalkieResponse import com.correx.core.talkie.model.TalkieState import com.correx.core.talkie.model.TalkieTurn import com.correx.core.talkie.model.TurnRole import com.correx.core.talkie.model.WorkflowSummary import com.correx.core.talkie.model.WorkflowStatus import com.correx.core.sessions.projections.replay.DefaultEventReplayer import com.correx.testing.fixtures.EventFixtures import com.correx.testing.fixtures.inference.MockTokenizer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.runBlocking import kotlinx.datetime.Instant import kotlinx.serialization.decodeFromString import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse 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 TalkieFacadeTest { // -------------------------------------------------------------------------- // CHAT mode // -------------------------------------------------------------------------- @Test fun `CHAT mode returns inference response content`(): Unit = runBlocking { val mockStore = mockEventStore() val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT) val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello, world!") assertEquals("inference response", response.content) } @Test fun `CHAT mode sets steeringEmitted to false`(): Unit = runBlocking { val mockStore = mockEventStore() val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT) val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertFalse(response.steeringEmitted) } @Test fun `CHAT mode appends USER and ROUTER ChatTurnEvent to EventStore`(): Unit = runBlocking { val mockStore = mockEventStore() val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") val chatEvents = mockStore.appendedEvents .map { it.payload } .filterIsInstance() assertEquals(2, chatEvents.size) val userEvent = chatEvents[0] val routerEvent = chatEvents[1] assertEquals("Hello!", userEvent.content) assertEquals(com.correx.core.events.events.ChatTurnRole.USER, userEvent.role) assertEquals("inference response", routerEvent.content) assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, routerEvent.role) } // -------------------------------------------------------------------------- // STEERING mode // -------------------------------------------------------------------------- @Test fun `STEERING mode sets steeringEmitted to true`(): Unit = runBlocking { val mockStore = mockEventStore() val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING) val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertTrue(response.steeringEmitted) } @Test fun `STEERING mode appends USER ChatTurnEvent, ROUTER ChatTurnEvent, and SteeringNoteAddedEvent to store`(): Unit = runBlocking { val mockStore = mockEventStore() val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING) facade.onUserInput(sessionId = SessionId("session-xyz"), input = "steer this way") val payloads = mockStore.appendedEvents.map { it.payload } val chatEvents = payloads.filterIsInstance() val steeringEvents = payloads.filterIsInstance() assertEquals(2, chatEvents.size) assertEquals(1, steeringEvents.size) assertEquals("steer this way", chatEvents[0].content) assertEquals(com.correx.core.events.events.ChatTurnRole.USER, chatEvents[0].role) assertEquals("inference response", chatEvents[1].content) assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, chatEvents[1].role) assertEquals("inference response", steeringEvents[0].content) } @Test fun `STEERING mode appends SteeringNoteAddedEvent with current stageId`(): Unit = runBlocking { val mockStore = mockEventStore() val stageId = StageId("stage-A") val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState( sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = stageId, ) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("steering response"), eventStore = mockStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING) val payloads = mockStore.appendedEvents.map { it.payload } val steeringEvents = payloads.filterIsInstance() assertEquals(1, steeringEvents.size) assertEquals("steering response", steeringEvents[0].content) } // -------------------------------------------------------------------------- // In-memory conversation history // -------------------------------------------------------------------------- @Test fun `conversation history grows per call - user and router turns appended`(): Unit = runBlocking { val capturedStates = mutableListOf() val eventStore = mockEventStore() val replayer = DefaultEventReplayer( store = eventStore, projection = TalkieProjector(DefaultTalkieReducer()), ) val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = replayer.rebuild(sessionId) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedStates.add(state) return emptyContextPack() } }, inferenceRouter = mockInferenceRouter("router reply"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) val sessionId = SessionId("history-session") facade.onUserInput(sessionId = sessionId, input = "first message") facade.onUserInput(sessionId = sessionId, input = "second message") // Second call's context builder sees two user turns + one router turn from the first call val stateOnSecondCall = capturedStates[1] assertEquals(3, stateOnSecondCall.conversationHistory.size) assertEquals(TurnRole.USER, stateOnSecondCall.conversationHistory[0].role) assertEquals("first message", stateOnSecondCall.conversationHistory[0].content) assertEquals(TurnRole.ROUTER, stateOnSecondCall.conversationHistory[1].role) assertEquals("router reply", stateOnSecondCall.conversationHistory[1].content) assertEquals(TurnRole.USER, stateOnSecondCall.conversationHistory[2].role) assertEquals("second message", stateOnSecondCall.conversationHistory[2].content) } @Test fun `conversation history is session-scoped - different sessions do not share history`(): Unit = runBlocking { val capturedStates = mutableListOf() val eventStore = mockEventStore() val replayer = DefaultEventReplayer( store = eventStore, projection = TalkieProjector(DefaultTalkieReducer()), ) val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = replayer.rebuild(sessionId) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedStates.add(state) return emptyContextPack() } }, inferenceRouter = mockInferenceRouter("response"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("session-A"), input = "message A") facade.onUserInput(sessionId = SessionId("session-B"), input = "message B") val stateA = capturedStates[0] val stateB = capturedStates[1] assertEquals(1, stateA.conversationHistory.size) assertEquals(1, stateB.conversationHistory.size) assertEquals("message A", stateA.conversationHistory[0].content) assertEquals("message B", stateB.conversationHistory[0].content) } // -------------------------------------------------------------------------- // Orchestration // -------------------------------------------------------------------------- @Test fun `state is passed through to context builder`(): Unit = runBlocking { val capturedState = mutableListOf() val mockContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedState.add(state) return emptyContextPack() } } val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState( sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = StageId("s1"), ) }, routerContextBuilder = mockContextBuilder, inferenceRouter = mockInferenceRouter("inference response"), eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(1, capturedState.size) assertEquals(SessionId("test-session"), capturedState[0].sessionId) assertEquals(WorkflowStatus.RUNNING, capturedState[0].workflowStatus) } @Test fun `budget is passed through to context builder`(): Unit = runBlocking { val capturedBudget = mutableListOf() val mockContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedBudget.add(budget) return emptyContextPack() } } val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = mockContextBuilder, inferenceRouter = mockInferenceRouter("response"), eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 4200)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(1, capturedBudget.size) assertEquals(4200, capturedBudget[0].limit) } @Test fun `stage ID uses state currentStageId`(): Unit = runBlocking { val capturedStageId = mutableListOf() val mockInferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider { capturedStageId.add(stageId) return mockProvider("response") } } val stateStageId = StageId("state-stage") val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState( sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = stateStageId, ) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(stateStageId, capturedStageId[0]) } @Test fun `stage ID falls back to StageId none when state has no currentStageId`(): Unit = runBlocking { val capturedStageId = mutableListOf() val mockInferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider { capturedStageId.add(stageId) return mockProvider("response") } } val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState( sessionId = sessionId, workflowStatus = WorkflowStatus.IDLE, currentStageId = null, ) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(StageId("none"), capturedStageId[0]) } @Test fun `new InferenceRequestId per call`(): Unit = runBlocking { val capturedRequestIds = mutableListOf() val mockInferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider { return mockProviderWithCapture("response", capturedRequestIds) } } val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("s1"), input = "first") facade.onUserInput(sessionId = SessionId("s1"), input = "second") assertEquals(2, capturedRequestIds.size) assertFalse(capturedRequestIds[0] == capturedRequestIds[1]) } @Test fun `GenerationConfig defaults temperature 0 7 topP 0 9 maxTokens 512`(): Unit = runBlocking { val capturedRequests = mutableListOf() val mockInferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider { return mockProviderWithRequestCapture("response", capturedRequests) } } val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") val req = capturedRequests[0] assertEquals(0.7, req.generationConfig.temperature) assertEquals(0.9, req.generationConfig.topP) assertEquals(512, req.generationConfig.maxTokens) } @Test fun `context pack is passed to inference provider`(): Unit = runBlocking { val capturedContextPacks = mutableListOf() val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { val pack = ContextPack( id = ContextPackId("test-pack"), sessionId = state.sessionId ?: SessionId("unknown"), stageId = StageId("test"), layers = emptyMap(), budgetUsed = 0, budgetLimit = budget.limit, ) capturedContextPacks.add(pack) return pack } }, inferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider { return object : InferenceProvider { override val id = ProviderId("mock") override val name = "Mock" override val tokenizer = MockTokenizer() override suspend fun infer(request: InferenceRequest): InferenceResponse { assertEquals(1, capturedContextPacks.size) assertEquals(capturedContextPacks[0].id, request.contextPack.id) return InferenceResponse( requestId = request.requestId, text = "response", finishReason = FinishReason.Stop, tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5), latencyMs = 0, ) } override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy override fun capabilities(): Set = emptySet() } } }, eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") } @Test fun `responseFormat defaults to Text`(): Unit = runBlocking { val capturedRequests = mutableListOf() val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider { return mockProviderWithRequestCapture("response", capturedRequests) } }, eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") val req = capturedRequests[0] assertTrue(req.responseFormat is ResponseFormat.Text) } @Test fun `onUserInput returns TalkieResponse with content and mode flag`(): Unit = runBlocking { val mockStore = mockEventStore() val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING) val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertNotNull(response) assertEquals("inference response", response.content) assertTrue(response.steeringEmitted) val payloads = mockStore.appendedEvents.map { it.payload } assertEquals(2, payloads.filterIsInstance().size) assertEquals(1, payloads.filterIsInstance().size) } @Test fun `CHAT round-trip stores user and router turns in L3 memory`(): Unit = runBlocking { val mockStore = mockEventStore() val l3Store = InMemoryL3MemoryStore() val embedder = NoopEmbedder(dimension = 8) val facade = facadeWithMocks( eventStore = mockStore, chatMode = ChatMode.CHAT, embedder = embedder, l3MemoryStore = l3Store, ) val sessionId = SessionId("test-session") facade.onUserInput(sessionId = sessionId, input = "Hello!") val hits = l3Store.query( L3Query( vector = FloatArray(8), k = 10, sessionIdFilter = sessionId, ) ) assertEquals(2, hits.size) } // -------------------------------------------------------------------------- // Task 2.1 — latency + token metrics on ROUTER ChatTurnEvent // -------------------------------------------------------------------------- @Test fun `ROUTER ChatTurnEvent carries latencyMs and tokensUsed from inference and USER turn has both null`(): Unit = runBlocking { val capturedRequests = mutableListOf() val knownLatencyMs = 42L val knownTokenUsage = TokenUsage(promptTokens = 10, completionTokens = 20) val metricsProvider = object : InferenceProvider { override val id = ProviderId("mock") override val name = "Mock" override val tokenizer = MockTokenizer() override suspend fun infer(request: InferenceRequest): InferenceResponse { capturedRequests.add(request) return InferenceResponse( requestId = request.requestId, text = "metrics response", finishReason = FinishReason.Stop, tokensUsed = knownTokenUsage, latencyMs = knownLatencyMs, ) } override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy override fun capabilities(): Set = setOf(CapabilityScore(ModelCapability.General, 1.0)) } val eventStore = mockEventStore() val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = object : InferenceRouter { override suspend fun route(stageId: StageId, requiredCapabilities: Set): InferenceProvider = metricsProvider }, eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("metrics-session"), input = "hello metrics") val chatEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance() assertEquals(2, chatEvents.size) val userEvent = chatEvents.first { it.role == ChatTurnRole.USER } val routerEvent = chatEvents.first { it.role == ChatTurnRole.ROUTER } assertNull(userEvent.latencyMs, "USER ChatTurnEvent must have null latencyMs") assertNull(userEvent.tokensUsed, "USER ChatTurnEvent must have null tokensUsed") assertEquals(knownLatencyMs, routerEvent.latencyMs) assertNotNull(routerEvent.tokensUsed) assertEquals(30, routerEvent.tokensUsed!!.totalTokens) } @Test fun `ChatTurnEvent backward-compat - legacy JSON without latencyMs and tokensUsed deserializes with both null`() { val legacyJson = """{"sessionId":"s1","turnId":"t1","role":"USER","content":"hello","timestampMs":1000}""" val event = com.correx.core.events.serialization.eventJson.decodeFromString(legacyJson) assertEquals(SessionId("s1"), event.sessionId) assertEquals("hello", event.content) assertNull(event.latencyMs) assertNull(event.tokensUsed) } @Test fun `triage proposing a known workflow emits WorkflowProposedEvent and strips the json block`(): Unit = runBlocking { val store = mockEventStore() val reply = """ The research workflow fits — it gathers sources then writes a report. ```json {"propose_workflows":[{"id":"research","reason":"gather + report"}],"prompt":"Run it?"} ``` """.trimIndent() val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "desc", listOf("decompose")))) facade.onUserInput(SessionId("s"), "find me papers on event sourcing") val payloads = store.appendedEvents.map { it.payload } val proposed = payloads.filterIsInstance() assertEquals(1, proposed.size) assertEquals(listOf("research"), proposed.single().candidates.map { it.workflowId }) assertEquals("Run it?", proposed.single().prompt) assertEquals("find me papers on event sourcing", proposed.single().originalRequest) val routerTurn = payloads.filterIsInstance().first { it.role == ChatTurnRole.ROUTER } assertFalse(routerTurn.content.contains("```"), "json block must be stripped from the chat turn") assertTrue(routerTurn.content.startsWith("The research workflow fits")) } @Test fun `proposed workflow ids not in the registry are dropped so no proposal is recorded`(): Unit = runBlocking { val store = mockEventStore() val reply = "Try this.\n```json\n{\"propose_workflows\":[{\"id\":\"nonexistent\"}],\"prompt\":\"go?\"}\n```" val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "d", listOf("x")))) facade.onUserInput(SessionId("s"), "do something") assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance().isEmpty()) } @Test fun `a steering turn never records a workflow proposal`(): Unit = runBlocking { val store = mockEventStore() val reply = "x\n```json\n{\"propose_workflows\":[{\"id\":\"research\"}],\"prompt\":\"go?\"}\n```" val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "d", listOf("x")))) facade.onUserInput(SessionId("s"), "steer it", ChatMode.STEERING) assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance().isEmpty()) } @Test fun `rubber-duck ideas are captured, readable cross-session, and removed by a discard`(): Unit = runBlocking { val store = mockEventStore() val reply = "Right — worth keeping.\n```json\n{\"ideas\":[\"cache the repo map\",\"add --dry-run\"]}\n```" val facade = facadeProposing(store, reply, emptyList()) facade.onUserInput(SessionId("chat-1"), "how do we speed this up?") val reader = IdeaReader(store) val ideas = reader.activeIdeas() assertEquals(2, ideas.size) assertTrue(ideas.any { it.text == "cache the repo map" } && ideas.any { it.text == "add --dry-run" }) // The visible chat turn must not leak the json block. val routerTurn = store.appendedEvents.map { it.payload } .filterIsInstance().first { it.role == ChatTurnRole.ROUTER } assertFalse(routerTurn.content.contains("```")) // Discard one (from a *different* session) → it drops off the board, the other stays. val drop = ideas.first { it.text == "add --dry-run" } store.append( NewEvent( metadata = EventMetadata( eventId = EventId("disc-1"), sessionId = SessionId("chat-2"), timestamp = Instant.parse("2026-01-01T00:00:00Z"), schemaVersion = 1, causationId = null, correlationId = null, ), payload = com.correx.core.events.events.IdeaDiscardedEvent(ideaId = drop.id, sessionId = SessionId("chat-2")), ), ) val after = reader.activeIdeas() assertEquals(listOf("cache the repo map"), after.map { it.text }) } @Test fun `a steering turn never captures ideas`(): Unit = runBlocking { val store = mockEventStore() val reply = "x\n```json\n{\"ideas\":[\"nope\"]}\n```" val facade = facadeProposing(store, reply, emptyList()) facade.onUserInput(SessionId("s"), "steer", ChatMode.STEERING) assertTrue(IdeaReader(store).activeIdeas().isEmpty()) } // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- private fun facadeProposing( store: EventStore, reply: String, workflows: List, ): TalkieFacade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState(sessionId = sessionId) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter(reply), eventStore = store, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), workflowSummaryProvider = { workflows }, ) private fun mockEventStore(): MapBackedEventStore = MapBackedEventStore() private fun facadeWithMocks( eventStore: EventStore, chatMode: ChatMode = ChatMode.CHAT, embedder: Embedder = NoopEmbedder(dimension = 8), l3MemoryStore: L3MemoryStore = InMemoryL3MemoryStore(), ): TalkieFacade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState( sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = StageId("s1"), ) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("inference response"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = embedder, l3MemoryStore = l3MemoryStore, ).let { impl -> object : TalkieFacade { override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): TalkieResponse = impl.onUserInput(sessionId = sessionId, input = input, mode = chatMode) override suspend fun narrate(sessionId: SessionId, trigger: com.correx.core.talkie.model.NarrationTrigger) = impl.narrate(sessionId = sessionId, trigger = trigger) } } private fun mockInferenceRouter(responseText: String): InferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider = mockProvider(responseText) } private fun mockProvider(responseText: String): 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 = TokenUsage(promptTokens = 10, completionTokens = 5), latencyMs = 0, ) override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy override fun capabilities(): Set = setOf(CapabilityScore(ModelCapability.General, 1.0)) } private fun mockProviderWithCapture( responseText: String, requestIds: MutableList, ): InferenceProvider = object : InferenceProvider { override val id = ProviderId("mock") override val name = "Mock" override val tokenizer = MockTokenizer() override suspend fun infer(request: InferenceRequest): InferenceResponse { requestIds.add(request.requestId) return InferenceResponse( requestId = request.requestId, text = responseText, finishReason = FinishReason.Stop, tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5), latencyMs = 0, ) } override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy override fun capabilities(): Set = emptySet() } private fun mockProviderWithRequestCapture( responseText: String, requests: MutableList, ): InferenceProvider = object : InferenceProvider { override val id = ProviderId("mock") override val name = "Mock" override val tokenizer = MockTokenizer() override suspend fun infer(request: InferenceRequest): InferenceResponse { requests.add(request) return InferenceResponse( requestId = request.requestId, text = responseText, finishReason = FinishReason.Stop, tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5), latencyMs = 0, ) } override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy override fun capabilities(): Set = emptySet() } // -------------------------------------------------------------------------- // L3 retrieval slice 2a // -------------------------------------------------------------------------- @Test fun `retrieval emits L3MemoryRetrievedEvent with cross-session hit`(): Unit = runBlocking { val dimension = 8 val knownVector = FloatArray(dimension) { if (it == 0) 1f else 0f } val stubEmbedder = object : Embedder { override val dimension: Int = dimension override suspend fun embed(text: String): FloatArray = knownVector.copyOf() } val l3Store = InMemoryL3MemoryStore() val otherSessionId = SessionId("other-session") val crossSessionEntryId = "cross-entry-id" val crossSessionTurnId = "cross-turn-id" l3Store.store( L3MemoryEntry( id = crossSessionEntryId, sessionId = otherSessionId, turnId = crossSessionTurnId, text = "memory from another session", vector = knownVector.copyOf(), timestampMs = 1000L, ) ) val eventStore = mockEventStore() val replayer = DefaultEventReplayer( store = eventStore, projection = TalkieProjector(DefaultTalkieReducer()), ) val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = replayer.rebuild(sessionId) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("router reply"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000), retrievalK = 5), embedder = stubEmbedder, l3MemoryStore = l3Store, ) facade.onUserInput(sessionId = SessionId("current-session"), input = "hello") val l3Events = eventStore.appendedEvents.filter { it.payload is L3MemoryRetrievedEvent } assertEquals(1, l3Events.size) val retrieved = l3Events[0].payload as L3MemoryRetrievedEvent assertEquals(1, retrieved.hits.size) assertEquals(crossSessionEntryId, retrieved.hits[0].entryId) assertEquals(otherSessionId, retrieved.hits[0].sourceSessionId) assertEquals(crossSessionTurnId, retrieved.hits[0].sourceTurnId) assertEquals("memory from another session", retrieved.hits[0].text) } @Test fun `dedup filters hit whose turnId is already in current session history`(): Unit = runBlocking { val dimension = 8 val knownVector = FloatArray(dimension) { if (it == 0) 1f else 0f } val stubEmbedder = object : Embedder { override val dimension: Int = dimension override suspend fun embed(text: String): FloatArray = knownVector.copyOf() } val l3Store = InMemoryL3MemoryStore() val currentSessionId = SessionId("dedup-session") val inSessionTurnId = "in-session-turn-id" // Pre-populate L3 with an entry whose turnId matches an in-session turn l3Store.store( L3MemoryEntry( id = inSessionTurnId, sessionId = currentSessionId, turnId = inSessionTurnId, text = "already in session history", vector = knownVector.copyOf(), timestampMs = 500L, ) ) val eventStore = mockEventStore() val replayer = DefaultEventReplayer( store = eventStore, projection = TalkieProjector(DefaultTalkieReducer()), ) // Bootstrap session with a ChatTurnEvent whose turnId matches the L3 entry eventStore.append( NewEvent( metadata = EventMetadata( eventId = EventId("seed-event-id"), sessionId = currentSessionId, timestamp = kotlinx.datetime.Clock.System.now(), schemaVersion = 1, causationId = null, correlationId = null, ), payload = ChatTurnEvent( sessionId = currentSessionId, turnId = inSessionTurnId, role = ChatTurnRole.USER, content = "already in session history", timestampMs = 500L, ), ) ) val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = replayer.rebuild(sessionId) }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("router reply"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000), retrievalK = 5), embedder = stubEmbedder, l3MemoryStore = l3Store, ) facade.onUserInput(sessionId = currentSessionId, input = "a new message") val l3Events = eventStore.appendedEvents.filter { it.payload is L3MemoryRetrievedEvent } // The only hit was in-session, so deduped to empty → L3MemoryRetrievedEvent emitted with empty hits assertEquals(1, l3Events.size, "Expected one L3MemoryRetrievedEvent even when all hits are deduplicated") val deduplicatedRetrieved = l3Events[0].payload as L3MemoryRetrievedEvent assertTrue(deduplicatedRetrieved.hits.isEmpty(), "Expected empty hits list when all hits are deduplicated") } @Test fun `retrieval failure is non-fatal - CHAT turn still completes`(): Unit = runBlocking { val throwingEmbedder = object : Embedder { override val dimension: Int = 8 override suspend fun embed(text: String): FloatArray = throw RuntimeException("embedding service unavailable") } val eventStore = mockEventStore() val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter("response"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = throwingEmbedder, l3MemoryStore = InMemoryL3MemoryStore(), ) // Should not throw despite embedder failure val response = facade.onUserInput(sessionId = SessionId("fail-session"), input = "test") assertEquals("response", response.content) val payloads = eventStore.appendedEvents.map { it.payload } val chatEvents = payloads.filterIsInstance() assertEquals(2, chatEvents.size, "USER and ROUTER ChatTurnEvents must be present") // Retrieval failed → deduped is empty; L3MemoryRetrievedEvent still emitted with empty hits val l3Events = payloads.filterIsInstance() assertEquals(1, l3Events.size, "L3MemoryRetrievedEvent must be emitted even on retrieval failure") assertTrue(l3Events[0].hits.isEmpty(), "Expected empty hits when retrieval failed") } @Test fun `replay of L3MemoryRetrievedEvent populates lastRetrievedMemory in TalkieState`(): Unit = runBlocking { val reducer = DefaultTalkieReducer() val projector = TalkieProjector(reducer) val sessionId = SessionId("replay-session") val hits = listOf( L3RetrievedHit( entryId = "entry-1", sourceSessionId = SessionId("source-session"), sourceTurnId = "source-turn-1", text = "remembered text", score = 0.95f, ) ) val event = EventFixtures.stored( payload = L3MemoryRetrievedEvent( sessionId = sessionId, queryTurnId = "query-turn-id", hits = hits, timestampMs = 9999L, ) ) val state = projector.apply(projector.initial(), event) assertEquals(1, state.lastRetrievedMemory.size) assertEquals("entry-1", state.lastRetrievedMemory[0].entryId) assertEquals("remembered text", state.lastRetrievedMemory[0].text) assertEquals(0.95f, state.lastRetrievedMemory[0].score) } // -------------------------------------------------------------------------- // Slice 2b: ContextTruncatedEvent + end-to-end L3 recall injection // -------------------------------------------------------------------------- @Test fun `ContextTruncatedEvent emitted when context builder drops entries`(): Unit = runBlocking { // Use the real DefaultTalkieContextBuilder with a tight budget and large conversation val longContent = "x".repeat(800) // ~200 tokens each val sessionId = SessionId("truncation-session") val eventStore = mockEventStore() val replayer = DefaultEventReplayer( store = eventStore, projection = TalkieProjector(DefaultTalkieReducer()), ) val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = replayer.rebuild(sessionId) }, routerContextBuilder = DefaultTalkieContextBuilder( config = TalkieConfig( conversationKeepLast = 4, tokenBudget = TokenBudget(limit = 80), ), ), inferenceRouter = mockInferenceRouter("response"), eventStore = eventStore, config = TalkieConfig( conversationKeepLast = 4, tokenBudget = TokenBudget(limit = 80), ), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) // Pre-populate with large conversation turns so budget overflows eventStore.append( NewEvent( metadata = EventMetadata( eventId = EventId("seed-1"), sessionId = sessionId, timestamp = kotlinx.datetime.Clock.System.now(), schemaVersion = 1, causationId = null, correlationId = null, ), payload = ChatTurnEvent( sessionId = sessionId, turnId = "old-turn", role = ChatTurnRole.USER, content = longContent, timestampMs = 1000L, ), ) ) facade.onUserInput(sessionId = sessionId, input = "current input") val payloads = eventStore.appendedEvents.map { it.payload } val truncEvents = payloads.filterIsInstance() assertEquals(1, truncEvents.size) val truncEvent = truncEvents[0] assertEquals(sessionId, truncEvent.sessionId) assertTrue(truncEvent.entriesDropped > 0) assertTrue(truncEvent.truncatedLayers.isNotEmpty()) } @Test fun `no ContextTruncatedEvent when budget is generous`(): Unit = runBlocking { val eventStore = mockEventStore() val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = DefaultTalkieContextBuilder( config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000)), ), inferenceRouter = mockInferenceRouter("response"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("generous-session"), input = "hello") val payloads = eventStore.appendedEvents.map { it.payload } val truncEvents = payloads.filterIsInstance() assertTrue(truncEvents.isEmpty(), "No ContextTruncatedEvent expected when budget is generous") } @Test fun `ContextTruncatedEvent carries correct turnId matching userTurnId`(): Unit = runBlocking { val longContent = "x".repeat(800) val sessionId = SessionId("turnid-session") val eventStore = mockEventStore() val replayer = DefaultEventReplayer( store = eventStore, projection = TalkieProjector(DefaultTalkieReducer()), ) // Pre-seed with a large turn so the budget overflows on the next call eventStore.append( NewEvent( metadata = EventMetadata( eventId = EventId("seed-x"), sessionId = sessionId, timestamp = kotlinx.datetime.Clock.System.now(), schemaVersion = 1, causationId = null, correlationId = null, ), payload = ChatTurnEvent( sessionId = sessionId, turnId = "seeded-turn", role = ChatTurnRole.USER, content = longContent, timestampMs = 500L, ), ) ) val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = replayer.rebuild(sessionId) }, routerContextBuilder = DefaultTalkieContextBuilder( config = TalkieConfig(conversationKeepLast = 4, tokenBudget = TokenBudget(limit = 80)), ), inferenceRouter = mockInferenceRouter("response"), eventStore = eventStore, config = TalkieConfig(conversationKeepLast = 4, tokenBudget = TokenBudget(limit = 80)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = sessionId, input = "new input") val payloads = eventStore.appendedEvents.map { it.payload } val userChatEvent = payloads.filterIsInstance().first { it.role == ChatTurnRole.USER && it.content == "new input" } val truncEvent = payloads.filterIsInstance().firstOrNull() assertNotNull(truncEvent) assertEquals(userChatEvent.turnId, truncEvent!!.turnId) } @Test fun `empty retrieval always emits L3MemoryRetrievedEvent so next turn does not see stale memory`(): Unit = runBlocking { // Scenario: two consecutive CHAT turns with no cross-session L3 entries. // Each turn must emit L3MemoryRetrievedEvent (with empty hits), so that // lastRetrievedMemory is always current-turn-scoped and never stale. val eventStore = mockEventStore() val replayer = DefaultEventReplayer( store = eventStore, projection = TalkieProjector(DefaultTalkieReducer()), ) val capturedStates = mutableListOf() val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = replayer.rebuild(sessionId) }, routerContextBuilder = object : TalkieContextBuilder { private val real = DefaultTalkieContextBuilder( config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000)), ) override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack { capturedStates.add(state) return real.build(state, budget) } }, inferenceRouter = mockInferenceRouter("reply"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000), retrievalK = 5), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) val sid = SessionId("stale-test-session") facade.onUserInput(sessionId = sid, input = "first input") facade.onUserInput(sessionId = sid, input = "second input") // Exactly two L3MemoryRetrievedEvents must have been emitted — one per CHAT turn val l3Events = eventStore.appendedEvents.filter { it.payload is L3MemoryRetrievedEvent } assertEquals(2, l3Events.size, "One L3MemoryRetrievedEvent must be emitted per CHAT turn") assertTrue((l3Events[0].payload as L3MemoryRetrievedEvent).hits.isEmpty(), "First turn: no cross-session entries → empty hits") assertTrue((l3Events[1].payload as L3MemoryRetrievedEvent).hits.isEmpty(), "Second turn: no cross-session entries → empty hits") // State passed to builder on both calls must have empty lastRetrievedMemory // (because the L3MemoryRetrievedEvent with empty hits was emitted and replayed before each build) assertEquals(2, capturedStates.size) assertTrue( capturedStates[0].lastRetrievedMemory.isEmpty(), "First build: lastRetrievedMemory must be empty (no prior hits)", ) assertTrue( capturedStates[1].lastRetrievedMemory.isEmpty(), "Second build: lastRetrievedMemory must be empty (stale memory guard)", ) } @Test fun `end-to-end L3 hit from cross-session injected into context pack reaching inference`(): Unit = runBlocking { val dimension = 8 val knownVector = FloatArray(dimension) { if (it == 0) 1f else 0f } val stubEmbedder = object : Embedder { override val dimension: Int = dimension override suspend fun embed(text: String): FloatArray = knownVector.copyOf() } val l3Store = InMemoryL3MemoryStore() val otherSessionId = SessionId("cross-session") val crossSessionText = "important cross-session memory" l3Store.store( L3MemoryEntry( id = "cross-entry", sessionId = otherSessionId, turnId = "cross-turn", text = crossSessionText, vector = knownVector.copyOf(), timestampMs = 1000L, ) ) val capturedPacks = mutableListOf() val eventStore = mockEventStore() val replayer = DefaultEventReplayer( store = eventStore, projection = TalkieProjector(DefaultTalkieReducer()), ) val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = replayer.rebuild(sessionId) }, routerContextBuilder = object : TalkieContextBuilder { private val real = DefaultTalkieContextBuilder( config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000)), ) override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = real.build(state, budget).also { capturedPacks.add(it) } }, inferenceRouter = mockInferenceRouter("router reply"), eventStore = eventStore, config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000), retrievalK = 5), embedder = stubEmbedder, l3MemoryStore = l3Store, ) facade.onUserInput(sessionId = SessionId("current-session"), input = "tell me something") assertEquals(1, capturedPacks.size) val pack = capturedPacks[0] val l3Entries = pack.layers[ContextLayer.L3] assertNotNull(l3Entries) assertTrue(l3Entries!!.isNotEmpty()) assertTrue(l3Entries.any { it.content.contains(crossSessionText) }) assertTrue(l3Entries.any { it.content.contains("[recalled memory]") }) } // -------------------------------------------------------------------------- // Narration model ID routing // -------------------------------------------------------------------------- @Test fun `narrate passes narrationModelId to inferenceRouter 3-arg route`(): Unit = runBlocking { val capturedModelId = mutableListOf() val mockInferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider = mockProvider("narration text") override suspend fun route( stageId: StageId, requiredCapabilities: Set, modelId: String?, ): InferenceProvider { capturedModelId.add(modelId) return mockProvider("narration text") } } val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = TalkieConfig( tokenBudget = TokenBudget(limit = 5000), narrationModelId = "llama-cpp:phi-3-mini", ), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.narrate( sessionId = SessionId("test-session"), trigger = com.correx.core.talkie.model.NarrationTrigger( kind = "test", instruction = "describe what happened", ), ) assertEquals(1, capturedModelId.size) assertEquals("llama-cpp:phi-3-mini", capturedModelId[0]) } @Test fun `onUserInput does NOT pass modelId — uses 2-arg route`(): Unit = runBlocking { var threeArgCallCount = 0 val mockInferenceRouter = object : InferenceRouter { override suspend fun route( stageId: StageId, requiredCapabilities: Set, ): InferenceProvider = mockProvider("response") override suspend fun route( stageId: StageId, requiredCapabilities: Set, modelId: String?, ): InferenceProvider { threeArgCallCount++ return mockProvider("response") } } val facade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState() }, routerContextBuilder = object : TalkieContextBuilder { override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?): ContextPack = emptyContextPack() }, inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = TalkieConfig( tokenBudget = TokenBudget(limit = 5000), narrationModelId = "llama-cpp:phi-3-mini", ), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(0, threeArgCallCount, "onUserInput must not call 3-arg route()") } // -------------------------------------------------------------------------- // A4 — workflowSummaryProvider and sessionProfileProvider forwarding // -------------------------------------------------------------------------- @Test fun `workflowSummaryProvider is called and forwarded to context builder on each turn`(): Unit = runBlocking { val captured = mutableListOf>() val capturingBuilder = object : TalkieContextBuilder { override suspend fun build( state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?, ): ContextPack { captured.add(availableWorkflows) return emptyContextPack() } } val workflow = WorkflowSummary("wf", "desc", listOf("s1")) val facade = facadeWith(contextBuilder = capturingBuilder, workflowSummaryProvider = { listOf(workflow) }) facade.onUserInput(SessionId("s"), "hello") assertEquals(listOf(listOf(workflow)), captured) } @Test fun `sessionProfileProvider is called and forwarded to context builder`(): Unit = runBlocking { val capturedProfiles = mutableListOf() val capturingBuilder = object : TalkieContextBuilder { override suspend fun build( state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?, ): ContextPack { capturedProfiles.add(projectProfileText) return emptyContextPack() } } val facade = facadeWith( contextBuilder = capturingBuilder, sessionProfileProvider = { "profile text" }, ) facade.onUserInput(SessionId("s"), "hello") assertEquals(listOf("profile text"), capturedProfiles) } private fun facadeWith( contextBuilder: TalkieContextBuilder = object : TalkieContextBuilder { override suspend fun build( state: TalkieState, budget: TokenBudget, availableWorkflows: List, projectProfileText: String?, ): ContextPack = emptyContextPack() }, workflowSummaryProvider: () -> List = { emptyList() }, sessionProfileProvider: suspend (SessionId) -> String? = { null }, ): TalkieFacade = DefaultTalkieFacade( routerRepository = object : TalkieRepository { override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState(sessionId = sessionId) }, routerContextBuilder = contextBuilder, inferenceRouter = mockInferenceRouter("inference response"), eventStore = mockEventStore(), config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)), embedder = NoopEmbedder(dimension = 8), l3MemoryStore = InMemoryL3MemoryStore(), workflowSummaryProvider = workflowSummaryProvider, sessionProfileProvider = sessionProfileProvider, ) 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") } }