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:
2026-05-30 00:53:26 +04:00
parent 6feef150c9
commit a3f29e6eb9
5 changed files with 143 additions and 53 deletions
@@ -0,0 +1,21 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("ChatTurn")
data class ChatTurnEvent(
val sessionId: SessionId,
val turnId: String,
val role: ChatTurnRole,
val content: String,
val timestampMs: Long,
) : EventPayload
@Serializable
enum class ChatTurnRole {
USER,
ROUTER,
}
@@ -8,6 +8,7 @@ import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.EventPayload import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceFailedEvent import com.correx.core.events.events.InferenceFailedEvent
@@ -71,6 +72,7 @@ val eventModule = SerializersModule {
subclass(RetryAttemptedEvent::class) subclass(RetryAttemptedEvent::class)
subclass(RiskAssessedEvent::class) subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class) subclass(ChatSessionStartedEvent::class)
subclass(ChatTurnEvent::class)
} }
} }
@@ -1,11 +1,11 @@
package com.correx.core.router package com.correx.core.router
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ChatTurnRole
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.CausationId
import com.correx.core.events.types.CorrelationId
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
@@ -16,11 +16,8 @@ import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ResponseFormat import com.correx.core.inference.ResponseFormat
import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.RouterResponse import com.correx.core.router.model.RouterResponse
import com.correx.core.router.model.RouterTurn
import com.correx.core.router.model.TurnRole
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import java.util.* import java.util.*
import java.util.concurrent.*
interface RouterFacade { interface RouterFacade {
suspend fun onUserInput( suspend fun onUserInput(
@@ -39,22 +36,19 @@ class DefaultRouterFacade(
private val validateSteering: (suspend (String) -> String?)? = null, private val validateSteering: (suspend (String) -> String?)? = null,
) : RouterFacade { ) : RouterFacade {
private val histories = ConcurrentHashMap<SessionId, MutableList<RouterTurn>>()
override suspend fun onUserInput( override suspend fun onUserInput(
sessionId: SessionId, sessionId: SessionId,
input: String, input: String,
mode: ChatMode, mode: ChatMode,
): RouterResponse { ): RouterResponse {
val state = routerRepository.getRouterState(sessionId) // Emit USER turn event
emitChatTurn(sessionId, input, ChatTurnRole.USER)
val history = histories.getOrPut(sessionId) { mutableListOf() } // Rebuild state with user turn appended
history.add(RouterTurn(role = TurnRole.USER, content = input, timestamp = Clock.System.now())) val stateWithUserTurn = routerRepository.getRouterState(sessionId)
val effectiveStageId = stateWithUserTurn.currentStageId ?: StageId.NONE
val stateWithHistory = state.copy(conversationHistory = history.toList()) val contextPack = routerContextBuilder.build(stateWithUserTurn, config.tokenBudget)
val effectiveStageId = state.currentStageId ?: StageId.NONE
val contextPack = routerContextBuilder.build(stateWithHistory, config.tokenBudget)
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General)) val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
val inferenceRequest = InferenceRequest( val inferenceRequest = InferenceRequest(
requestId = InferenceRequestId(UUID.randomUUID().toString()), requestId = InferenceRequestId(UUID.randomUUID().toString()),
@@ -67,36 +61,65 @@ class DefaultRouterFacade(
val inferenceResponse = provider.infer(inferenceRequest) val inferenceResponse = provider.infer(inferenceRequest)
val content = inferenceResponse.text val content = inferenceResponse.text
history.add(RouterTurn(role = TurnRole.ROUTER, content = content, timestamp = Clock.System.now())) // Emit ROUTER turn event
emitChatTurn(sessionId, content, ChatTurnRole.ROUTER)
if (mode == ChatMode.STEERING) { if (mode == ChatMode.STEERING) {
val validationError = validateSteering?.invoke(content) val validationError = validateSteering?.invoke(content)
if (validationError == null) { if (validationError == null) {
eventStore.append( emitSteeringNote(sessionId, content, effectiveStageId)
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = SteeringNoteAddedEvent(
sessionId = sessionId,
content = content,
stageId = effectiveStageId.takeIf { it != StageId.NONE },
),
),
)
} }
} }
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING)) return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
} }
private suspend fun emitChatTurn(sessionId: SessionId, content: String, role: ChatTurnRole) {
val turnId = UUID.randomUUID().toString()
val nowMs = Clock.System.now().toEpochMilliseconds()
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ChatTurnEvent(
sessionId = sessionId,
turnId = turnId,
role = role,
content = content,
timestampMs = nowMs,
),
),
)
}
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = SteeringNoteAddedEvent(
sessionId = sessionId,
content = content,
stageId = effectiveStageId.takeIf { it != StageId.NONE },
),
),
)
}
} }
enum class ChatMode { enum class ChatMode {
CHAT, CHAT,
STEERING, STEERING,
} }
@@ -1,5 +1,6 @@
package com.correx.core.router package com.correx.core.router
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageCompletedEvent
@@ -10,8 +11,11 @@ import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.router.model.RouterL2Entry import com.correx.core.router.model.RouterL2Entry
import com.correx.core.router.model.RouterState import com.correx.core.router.model.RouterState
import com.correx.core.router.model.RouterTurn
import com.correx.core.router.model.StageOutcomeKind import com.correx.core.router.model.StageOutcomeKind
import com.correx.core.router.model.TurnRole
import com.correx.core.router.model.WorkflowStatus import com.correx.core.router.model.WorkflowStatus
import kotlinx.datetime.Instant
interface RouterReducer { interface RouterReducer {
val initial: RouterState val initial: RouterState
@@ -37,6 +41,7 @@ class DefaultRouterReducer : RouterReducer {
is OrchestrationResumedEvent -> handleOrchestrationResumed(state) is OrchestrationResumedEvent -> handleOrchestrationResumed(state)
is StageCompletedEvent -> handleStageCompleted(state, event) is StageCompletedEvent -> handleStageCompleted(state, event)
is StageFailedEvent -> handleStageFailed(state, event) is StageFailedEvent -> handleStageFailed(state, event)
is ChatTurnEvent -> handleChatTurn(state, event)
else -> state else -> state
} }
} }
@@ -102,4 +107,20 @@ class DefaultRouterReducer : RouterReducer {
currentStageId = null currentStageId = null
) )
} }
private fun handleChatTurn(state: RouterState, event: StoredEvent): RouterState {
val payload = event.payload as ChatTurnEvent
val turnRole = when (payload.role) {
com.correx.core.events.events.ChatTurnRole.USER -> TurnRole.USER
com.correx.core.events.events.ChatTurnRole.ROUTER -> TurnRole.ROUTER
}
val turn = RouterTurn(
role = turnRole,
content = payload.content,
timestamp = Instant.fromEpochMilliseconds(payload.timestampMs),
)
return state.copy(
conversationHistory = state.conversationHistory + turn
)
}
} }
@@ -21,14 +21,17 @@ import com.correx.core.inference.ResponseFormat
import com.correx.core.inference.TokenUsage import com.correx.core.inference.TokenUsage
import com.correx.core.router.ChatMode import com.correx.core.router.ChatMode
import com.correx.core.router.DefaultRouterFacade import com.correx.core.router.DefaultRouterFacade
import com.correx.core.router.DefaultRouterReducer
import com.correx.core.router.RouterContextBuilder import com.correx.core.router.RouterContextBuilder
import com.correx.core.router.RouterFacade import com.correx.core.router.RouterFacade
import com.correx.core.router.RouterProjector
import com.correx.core.router.RouterRepository import com.correx.core.router.RouterRepository
import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.RouterResponse import com.correx.core.router.model.RouterResponse
import com.correx.core.router.model.RouterState import com.correx.core.router.model.RouterState
import com.correx.core.router.model.TurnRole import com.correx.core.router.model.TurnRole
import com.correx.core.router.model.WorkflowStatus import com.correx.core.router.model.WorkflowStatus
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.testing.fixtures.inference.MockTokenizer import com.correx.testing.fixtures.inference.MockTokenizer
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -61,11 +64,19 @@ class RouterFacadeTest {
} }
@Test @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 mockStore = mockEventStore()
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT) val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") 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 @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 mockStore = mockEventStore()
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING) val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
facade.onUserInput(sessionId = SessionId("session-xyz"), input = "steer this way") facade.onUserInput(sessionId = SessionId("session-xyz"), input = "steer this way")
assertEquals(1, mockStore.appendedEvents.size) assertEquals(3, mockStore.appendedEvents.size)
val payload = mockStore.appendedEvents.single().payload val userEvent = mockStore.appendedEvents[0].payload
assertTrue(payload is com.correx.core.events.events.SteeringNoteAddedEvent) val routerEvent = mockStore.appendedEvents[1].payload
assertEquals("inference response", (payload as com.correx.core.events.events.SteeringNoteAddedEvent).content) 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 @Test
@@ -112,10 +129,10 @@ class RouterFacadeTest {
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING)
assertEquals(1, mockStore.appendedEvents.size) assertEquals(3, mockStore.appendedEvents.size)
val payload = mockStore.appendedEvents.single().payload val steeringEvent = mockStore.appendedEvents[2].payload
assertTrue(payload is com.correx.core.events.events.SteeringNoteAddedEvent) assertTrue(steeringEvent is com.correx.core.events.events.SteeringNoteAddedEvent)
assertEquals("steering response", (payload as com.correx.core.events.events.SteeringNoteAddedEvent).content) assertEquals("steering response", (steeringEvent as com.correx.core.events.events.SteeringNoteAddedEvent).content)
} }
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@@ -125,14 +142,15 @@ class RouterFacadeTest {
@Test @Test
fun `conversation history grows per call - user and router turns appended`(): Unit = runBlocking { fun `conversation history grows per call - user and router turns appended`(): Unit = runBlocking {
val capturedStates = mutableListOf<RouterState>() val capturedStates = mutableListOf<RouterState>()
val eventStore = mockEventStore()
val replayer = DefaultEventReplayer<RouterState>(
store = eventStore,
projection = RouterProjector(DefaultRouterReducer()),
)
val facade = DefaultRouterFacade( val facade = DefaultRouterFacade(
routerRepository = object : RouterRepository { routerRepository = object : RouterRepository {
override suspend fun getRouterState(sessionId: SessionId): RouterState = override suspend fun getRouterState(sessionId: SessionId): RouterState =
RouterState( replayer.rebuild(sessionId)
sessionId = sessionId,
workflowStatus = WorkflowStatus.RUNNING,
currentStageId = StageId("s1"),
)
}, },
routerContextBuilder = object : RouterContextBuilder { routerContextBuilder = object : RouterContextBuilder {
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack {
@@ -141,7 +159,7 @@ class RouterFacadeTest {
} }
}, },
inferenceRouter = mockInferenceRouter("router reply"), inferenceRouter = mockInferenceRouter("router reply"),
eventStore = mockEventStore(), eventStore = eventStore,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
) )
val sessionId = SessionId("history-session") val sessionId = SessionId("history-session")
@@ -162,10 +180,15 @@ class RouterFacadeTest {
@Test @Test
fun `conversation history is session-scoped - different sessions do not share history`(): Unit = runBlocking { fun `conversation history is session-scoped - different sessions do not share history`(): Unit = runBlocking {
val capturedStates = mutableListOf<RouterState>() val capturedStates = mutableListOf<RouterState>()
val eventStore = mockEventStore()
val replayer = DefaultEventReplayer<RouterState>(
store = eventStore,
projection = RouterProjector(DefaultRouterReducer()),
)
val facade = DefaultRouterFacade( val facade = DefaultRouterFacade(
routerRepository = object : RouterRepository { routerRepository = object : RouterRepository {
override suspend fun getRouterState(sessionId: SessionId): RouterState = override suspend fun getRouterState(sessionId: SessionId): RouterState =
RouterState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING) replayer.rebuild(sessionId)
}, },
routerContextBuilder = object : RouterContextBuilder { routerContextBuilder = object : RouterContextBuilder {
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack { override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack {
@@ -174,7 +197,7 @@ class RouterFacadeTest {
} }
}, },
inferenceRouter = mockInferenceRouter("response"), inferenceRouter = mockInferenceRouter("response"),
eventStore = mockEventStore(), eventStore = eventStore,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
) )
facade.onUserInput(sessionId = SessionId("session-A"), input = "message A") facade.onUserInput(sessionId = SessionId("session-A"), input = "message A")
@@ -453,7 +476,7 @@ class RouterFacadeTest {
assertNotNull(response) assertNotNull(response)
assertEquals("inference response", response.content) assertEquals("inference response", response.content)
assertTrue(response.steeringEmitted) assertTrue(response.steeringEmitted)
assertEquals(1, mockStore.appendedEvents.size) assertEquals(3, mockStore.appendedEvents.size)
} }
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------