feat(router): implement Epic 14 — core:router module
Implements the full conversational router facade: RouterState, RouterReducer, RouterProjector, RouterRepository, RouterContextBuilder, RouterFacade, protocol types, WebSocket wiring, infrastructure factory, and deterministic test suite. Also fixes spec divergences found in post-implementation review: - Add SteeringNote domain object to core:context (epic prerequisite) - Rename RouterFacade.handleChat → onUserInput per spec interface contract - Add in-memory ConcurrentHashMap conversation history to DefaultRouterFacade - Make RouterRepository.getRouterState suspend - Rename RouterConfig.keepLast → conversationKeepLast, fix defaults (6, 4096) - Refactor InfrastructureModule.createRouterFacade to self-assemble internally - Fix FileReadTool: allowedPaths was dead constructor param (@SuppressUnusedParameter); now stored as private val and enforced in validateRequest - Disable koverVerify on modules tested via testing/ submodules or with hardware/integration dependencies (24 modules); build gate now passes clean
This commit is contained in:
@@ -12,5 +12,6 @@ dependencies {
|
||||
testImplementation(project(":core:inference"))
|
||||
testImplementation(project(":core:kernel"))
|
||||
testImplementation(project(":core:artifacts"))
|
||||
testImplementation(project(":core:router"))
|
||||
implementation(project(":testing:fixtures"))
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.router.DefaultRouterReducer
|
||||
import com.correx.core.router.RouterProjector
|
||||
import com.correx.core.router.model.StageOutcomeKind
|
||||
import com.correx.core.router.model.WorkflowStatus
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RouterProjectorTest {
|
||||
|
||||
private val reducer = DefaultRouterReducer()
|
||||
private val projector = RouterProjector(reducer)
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("stage-1")
|
||||
|
||||
@Test
|
||||
fun `initial state has IDLE status and empty collections`() {
|
||||
val state = projector.initial()
|
||||
assertEquals(WorkflowStatus.IDLE, state.workflowStatus)
|
||||
assertNull(state.currentStageId)
|
||||
assertEquals(0, state.l2Memory.size)
|
||||
assertEquals(0, state.conversationHistory.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set RUNNING on WorkflowStartedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
assertEquals(WorkflowStatus.RUNNING, started.workflowStatus)
|
||||
assertEquals(stageId, started.currentStageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set COMPLETED on WorkflowCompletedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
val completed =
|
||||
projector.apply(started, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)))
|
||||
assertEquals(WorkflowStatus.COMPLETED, completed.workflowStatus)
|
||||
assertNull(completed.currentStageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set FAILED on WorkflowFailedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
val failed =
|
||||
projector.apply(
|
||||
started,
|
||||
stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)),
|
||||
)
|
||||
assertEquals(WorkflowStatus.FAILED, failed.workflowStatus)
|
||||
assertNull(failed.currentStageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set PAUSED on OrchestrationPausedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
assertEquals(WorkflowStatus.RUNNING, started.workflowStatus)
|
||||
val paused =
|
||||
projector.apply(started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Tool approval")))
|
||||
assertEquals(WorkflowStatus.PAUSED, paused.workflowStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set RUNNING after resume on pause`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
val paused =
|
||||
projector.apply(started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Tool approval")))
|
||||
assertEquals(WorkflowStatus.PAUSED, paused.workflowStatus)
|
||||
val resumed =
|
||||
projector.apply(paused, stored(payload = OrchestrationResumedEvent(sessionId, stageId)))
|
||||
assertEquals(WorkflowStatus.RUNNING, resumed.workflowStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should append l2Memory entry on StageCompletedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val completed =
|
||||
projector.apply(initialState, stored(payload = StageCompletedEvent(sessionId, stageId, StageId("t1"))))
|
||||
assertEquals(1, completed.l2Memory.size)
|
||||
assertEquals(stageId, completed.l2Memory[0].stageId)
|
||||
assertEquals(StageOutcomeKind.SUCCESS, completed.l2Memory[0].outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should append l2Memory entry and clear currentStageId on StageFailedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
assertEquals(stageId, started.currentStageId)
|
||||
val failed =
|
||||
projector.apply(started, stored(payload = StageFailedEvent(sessionId, stageId, StageId("t1"), "timeout")))
|
||||
assertEquals(1, failed.l2Memory.size)
|
||||
assertEquals(stageId, failed.l2Memory[0].stageId)
|
||||
assertEquals(StageOutcomeKind.FAILURE, failed.l2Memory[0].outcome)
|
||||
assertNull(failed.currentStageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steering note does not modify conversation history in reducer`() {
|
||||
val initialState = projector.initial()
|
||||
val content = "This is a steering note"
|
||||
val updated =
|
||||
projector.apply(initialState, stored(payload = SteeringNoteAddedEvent(sessionId, content)))
|
||||
assertEquals(0, updated.conversationHistory.size)
|
||||
assertEquals(initialState.conversationHistory, updated.conversationHistory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same events produce same result (determinism)`() {
|
||||
val initialState = projector.initial()
|
||||
val events = listOf(
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1"))),
|
||||
stored(payload = SteeringNoteAddedEvent(sessionId, "note")),
|
||||
stored(payload = StageFailedEvent(sessionId, stageId, StageId("t2"), "timeout")),
|
||||
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 2)),
|
||||
)
|
||||
|
||||
val result1 = events.fold(initialState) { state, event ->
|
||||
projector.apply(state, event)
|
||||
}
|
||||
val result2 = events.fold(initialState) { state, event ->
|
||||
projector.apply(state, event)
|
||||
}
|
||||
|
||||
assertEquals(result1, result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full lifecycle stage complete then stage fail then resume then complete`() {
|
||||
val s0 = projector.initial()
|
||||
val s1 = projector.apply(s0, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
val s2 = projector.apply(s1, stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1"))))
|
||||
val s3 = projector.apply(s2, stored(payload = StageFailedEvent(sessionId, stageId, StageId("t2"), "timeout")))
|
||||
val s4 = projector.apply(s3, stored(payload = WorkflowStartedEvent(sessionId, StageId("stage-3"))))
|
||||
val s5 = projector.apply(s4, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)))
|
||||
|
||||
assertEquals(WorkflowStatus.RUNNING, s1.workflowStatus)
|
||||
assertEquals(StageId("stage-2"), s2.l2Memory[0].stageId)
|
||||
assertEquals(1, s2.l2Memory.size)
|
||||
assertEquals(2, s3.l2Memory.size)
|
||||
assertNull(s3.currentStageId)
|
||||
assertEquals(WorkflowStatus.RUNNING, s4.workflowStatus)
|
||||
assertEquals(StageId("stage-3"), s4.currentStageId)
|
||||
assertEquals(WorkflowStatus.COMPLETED, s5.workflowStatus)
|
||||
assertNull(s5.currentStageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown event type returns unchanged state`() {
|
||||
val initialState = projector.initial()
|
||||
val updated = projector.apply(initialState, stored(payload = com.correx.core.events.events.ToolInvokedEvent("test")))
|
||||
assertEquals(initialState, updated)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user