feat: event-source router CHAT and STEERING conversation turns
Adds ChatTurnEvent (role USER|ROUTER) so router conversation history survives restart and is rebuilt from the event log via projection, restoring Hard Invariant #1 for the CHAT path. Both CHAT and STEERING now emit user + router turn events; STEERING additionally keeps its existing SteeringNoteAddedEvent so the structured directive record is preserved alongside the conversation turns. RouterFacade drops the in-memory ConcurrentHashMap of histories and rebuilds state through the repository after each emission. Reducer appends turns to conversationHistory. ChatTurnEvent is registered in eventModule.
This commit is contained in:
@@ -21,14 +21,17 @@ import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.core.router.ChatMode
|
||||
import com.correx.core.router.DefaultRouterFacade
|
||||
import com.correx.core.router.DefaultRouterReducer
|
||||
import com.correx.core.router.RouterContextBuilder
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.router.RouterProjector
|
||||
import com.correx.core.router.RouterRepository
|
||||
import com.correx.core.router.model.RouterConfig
|
||||
import com.correx.core.router.model.RouterResponse
|
||||
import com.correx.core.router.model.RouterState
|
||||
import com.correx.core.router.model.TurnRole
|
||||
import com.correx.core.router.model.WorkflowStatus
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.testing.fixtures.inference.MockTokenizer
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -61,11 +64,19 @@ class RouterFacadeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CHAT mode does not append to EventStore`(): Unit = runBlocking {
|
||||
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!")
|
||||
assertTrue(mockStore.appendedEvents.isEmpty())
|
||||
assertEquals(2, mockStore.appendedEvents.size)
|
||||
val userEvent = mockStore.appendedEvents[0].payload
|
||||
val routerEvent = mockStore.appendedEvents[1].payload
|
||||
assertTrue(userEvent is com.correx.core.events.events.ChatTurnEvent)
|
||||
assertTrue(routerEvent is com.correx.core.events.events.ChatTurnEvent)
|
||||
assertEquals("Hello!", (userEvent as com.correx.core.events.events.ChatTurnEvent).content)
|
||||
assertEquals(com.correx.core.events.events.ChatTurnRole.USER, userEvent.role)
|
||||
assertEquals("inference response", (routerEvent as com.correx.core.events.events.ChatTurnEvent).content)
|
||||
assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, routerEvent.role)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -81,14 +92,20 @@ class RouterFacadeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STEERING mode appends SteeringNoteAddedEvent to store`(): Unit = runBlocking {
|
||||
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")
|
||||
assertEquals(1, mockStore.appendedEvents.size)
|
||||
val payload = mockStore.appendedEvents.single().payload
|
||||
assertTrue(payload is com.correx.core.events.events.SteeringNoteAddedEvent)
|
||||
assertEquals("inference response", (payload as com.correx.core.events.events.SteeringNoteAddedEvent).content)
|
||||
assertEquals(3, mockStore.appendedEvents.size)
|
||||
val userEvent = mockStore.appendedEvents[0].payload
|
||||
val routerEvent = mockStore.appendedEvents[1].payload
|
||||
val steeringEvent = mockStore.appendedEvents[2].payload
|
||||
assertTrue(userEvent is com.correx.core.events.events.ChatTurnEvent)
|
||||
assertTrue(routerEvent is com.correx.core.events.events.ChatTurnEvent)
|
||||
assertTrue(steeringEvent is com.correx.core.events.events.SteeringNoteAddedEvent)
|
||||
assertEquals("steer this way", (userEvent as com.correx.core.events.events.ChatTurnEvent).content)
|
||||
assertEquals("inference response", (routerEvent as com.correx.core.events.events.ChatTurnEvent).content)
|
||||
assertEquals("inference response", (steeringEvent as com.correx.core.events.events.SteeringNoteAddedEvent).content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -112,10 +129,10 @@ class RouterFacadeTest {
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING)
|
||||
assertEquals(1, mockStore.appendedEvents.size)
|
||||
val payload = mockStore.appendedEvents.single().payload
|
||||
assertTrue(payload is com.correx.core.events.events.SteeringNoteAddedEvent)
|
||||
assertEquals("steering response", (payload as com.correx.core.events.events.SteeringNoteAddedEvent).content)
|
||||
assertEquals(3, mockStore.appendedEvents.size)
|
||||
val steeringEvent = mockStore.appendedEvents[2].payload
|
||||
assertTrue(steeringEvent is com.correx.core.events.events.SteeringNoteAddedEvent)
|
||||
assertEquals("steering response", (steeringEvent as com.correx.core.events.events.SteeringNoteAddedEvent).content)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -125,14 +142,15 @@ class RouterFacadeTest {
|
||||
@Test
|
||||
fun `conversation history grows per call - user and router turns appended`(): Unit = runBlocking {
|
||||
val capturedStates = mutableListOf<RouterState>()
|
||||
val eventStore = mockEventStore()
|
||||
val replayer = DefaultEventReplayer<RouterState>(
|
||||
store = eventStore,
|
||||
projection = RouterProjector(DefaultRouterReducer()),
|
||||
)
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = StageId("s1"),
|
||||
)
|
||||
replayer.rebuild(sessionId)
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack {
|
||||
@@ -141,7 +159,7 @@ class RouterFacadeTest {
|
||||
}
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter("router reply"),
|
||||
eventStore = mockEventStore(),
|
||||
eventStore = eventStore,
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
val sessionId = SessionId("history-session")
|
||||
@@ -162,10 +180,15 @@ class RouterFacadeTest {
|
||||
@Test
|
||||
fun `conversation history is session-scoped - different sessions do not share history`(): Unit = runBlocking {
|
||||
val capturedStates = mutableListOf<RouterState>()
|
||||
val eventStore = mockEventStore()
|
||||
val replayer = DefaultEventReplayer<RouterState>(
|
||||
store = eventStore,
|
||||
projection = RouterProjector(DefaultRouterReducer()),
|
||||
)
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING)
|
||||
replayer.rebuild(sessionId)
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack {
|
||||
@@ -174,7 +197,7 @@ class RouterFacadeTest {
|
||||
}
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter("response"),
|
||||
eventStore = mockEventStore(),
|
||||
eventStore = eventStore,
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("session-A"), input = "message A")
|
||||
@@ -453,7 +476,7 @@ class RouterFacadeTest {
|
||||
assertNotNull(response)
|
||||
assertEquals("inference response", response.content)
|
||||
assertTrue(response.steeringEmitted)
|
||||
assertEquals(1, mockStore.appendedEvents.size)
|
||||
assertEquals(3, mockStore.appendedEvents.size)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user