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.ArtifactValidatingEvent
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.InferenceCompletedEvent
import com.correx.core.events.events.InferenceFailedEvent
@@ -71,6 +72,7 @@ val eventModule = SerializersModule {
subclass(RetryAttemptedEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)
subclass(ChatTurnEvent::class)
}
}
@@ -1,11 +1,11 @@
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.NewEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
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.InferenceRequestId
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.router.model.RouterConfig
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 java.util.*
import java.util.concurrent.*
interface RouterFacade {
suspend fun onUserInput(
@@ -39,22 +36,19 @@ class DefaultRouterFacade(
private val validateSteering: (suspend (String) -> String?)? = null,
) : RouterFacade {
private val histories = ConcurrentHashMap<SessionId, MutableList<RouterTurn>>()
override suspend fun onUserInput(
sessionId: SessionId,
input: String,
mode: ChatMode,
): RouterResponse {
val state = routerRepository.getRouterState(sessionId)
// Emit USER turn event
emitChatTurn(sessionId, input, ChatTurnRole.USER)
val history = histories.getOrPut(sessionId) { mutableListOf() }
history.add(RouterTurn(role = TurnRole.USER, content = input, timestamp = Clock.System.now()))
// Rebuild state with user turn appended
val stateWithUserTurn = routerRepository.getRouterState(sessionId)
val effectiveStageId = stateWithUserTurn.currentStageId ?: StageId.NONE
val stateWithHistory = state.copy(conversationHistory = history.toList())
val effectiveStageId = state.currentStageId ?: StageId.NONE
val contextPack = routerContextBuilder.build(stateWithHistory, config.tokenBudget)
val contextPack = routerContextBuilder.build(stateWithUserTurn, config.tokenBudget)
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
val inferenceRequest = InferenceRequest(
requestId = InferenceRequestId(UUID.randomUUID().toString()),
@@ -67,36 +61,65 @@ class DefaultRouterFacade(
val inferenceResponse = provider.infer(inferenceRequest)
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) {
val validationError = validateSteering?.invoke(content)
if (validationError == null) {
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 },
),
),
)
emitSteeringNote(sessionId, content, effectiveStageId)
}
}
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 {
CHAT,
STEERING,
}
}
@@ -1,5 +1,6 @@
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.OrchestrationResumedEvent
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.router.model.RouterL2Entry
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.TurnRole
import com.correx.core.router.model.WorkflowStatus
import kotlinx.datetime.Instant
interface RouterReducer {
val initial: RouterState
@@ -37,6 +41,7 @@ class DefaultRouterReducer : RouterReducer {
is OrchestrationResumedEvent -> handleOrchestrationResumed(state)
is StageCompletedEvent -> handleStageCompleted(state, event)
is StageFailedEvent -> handleStageFailed(state, event)
is ChatTurnEvent -> handleChatTurn(state, event)
else -> state
}
}
@@ -102,4 +107,20 @@ class DefaultRouterReducer : RouterReducer {
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
)
}
}