feat(router): Task 3.2 — NarrationTrigger + buildNarrationContext + RouterFacade.narrate
- 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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RouterNarrationEvent>()
|
||||
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<ChatTurnEvent>()
|
||||
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<RouterNarrationEvent>()
|
||||
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<RouterNarrationEvent>()
|
||||
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<RouterNarrationEvent>()
|
||||
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<ModelCapability>,
|
||||
): 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<CapabilityScore> = 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<NewEvent>()
|
||||
private val storedEvents: MutableMap<EventId, StoredEvent> = 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<NewEvent>): List<StoredEvent> =
|
||||
events.map { append(it) }
|
||||
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||
storedEvents.values.filter { it.metadata.sessionId == sessionId }.toList()
|
||||
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
read(sessionId).filter { it.sequence >= fromSequence }
|
||||
|
||||
override fun lastSequence(sessionId: SessionId): Long? =
|
||||
read(sessionId).maxOfOrNull { it.sequence }
|
||||
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
|
||||
throw UnsupportedOperationException("subscribe not implemented for mock")
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> = storedEvents.values.asSequence()
|
||||
|
||||
override fun allSessionIds(): Set<SessionId> = storedEvents.values.map { it.metadata.sessionId }.toSet()
|
||||
|
||||
override fun subscribeAll(): Flow<StoredEvent> = 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<L3MemoryEntry>()
|
||||
|
||||
override suspend fun store(entry: L3MemoryEntry) {
|
||||
storedEntries.add(entry)
|
||||
}
|
||||
|
||||
override suspend fun query(query: L3Query): List<com.correx.core.router.l3.L3Hit> = emptyList()
|
||||
|
||||
override suspend fun close() = Unit
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user