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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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