import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.ToolInvokedEvent 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.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.router.DefaultRouterReducer import com.correx.core.router.model.StageOutcomeKind import com.correx.core.router.model.WorkflowStatus import com.correx.testing.fixtures.EventFixtures.stored import kotlinx.datetime.Instant import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class RouterReducerTest { private val reducer = DefaultRouterReducer() private val sessionId = SessionId("s1") private val stageId = StageId("stage-1") private val workflowId = "workflow-test" @Test fun `initial state has IDLE status and empty collections`() { val state = reducer.initial assertEquals(WorkflowStatus.IDLE, state.workflowStatus) assertNull(state.sessionId) assertNull(state.currentStageId) assertTrue(state.l2Memory.isEmpty()) assertTrue(state.conversationHistory.isEmpty()) } @Test fun `WorkflowStartedEvent sets sessionId, RUNNING status and startStageId`() { val state = reducer.initial val updated = reducer.reduce( state, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) assertEquals(sessionId, updated.sessionId) assertEquals(WorkflowStatus.RUNNING, updated.workflowStatus) assertEquals(stageId, updated.currentStageId) } @Test fun `WorkflowCompletedEvent sets COMPLETED and clears currentStageId`() { val initial = reducer.initial val started = reducer.reduce( initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val completed = reducer.reduce( started, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3)), ) assertEquals(WorkflowStatus.COMPLETED, completed.workflowStatus) assertNull(completed.currentStageId) assertEquals(sessionId, completed.sessionId) } @Test fun `WorkflowFailedEvent sets FAILED and clears currentStageId`() { val initial = reducer.initial val started = reducer.reduce( initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val failed = reducer.reduce( started, stored(payload = WorkflowFailedEvent(sessionId, stageId, "timeout", false)), ) assertEquals(WorkflowStatus.FAILED, failed.workflowStatus) assertNull(failed.currentStageId) } @Test fun `OrchestrationPausedEvent sets PAUSED status`() { val initial = reducer.initial val started = reducer.reduce( initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val paused = reducer.reduce( started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")), ) assertEquals(WorkflowStatus.PAUSED, paused.workflowStatus) assertEquals(sessionId, paused.sessionId) } @Test fun `OrchestrationResumedEvent sets RUNNING status from PAUSED`() { val initial = reducer.initial val started = reducer.reduce( initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val paused = reducer.reduce( started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")), ) val resumed = reducer.reduce( paused, stored(payload = OrchestrationResumedEvent(sessionId, stageId)), ) assertEquals(WorkflowStatus.RUNNING, resumed.workflowStatus) } @Test fun `StageCompletedEvent appends RouterL2Entry with SUCCESS to l2Memory`() { val state = reducer.initial val storedEvent = stored(payload = StageCompletedEvent(sessionId, stageId, StageId("t1"))) val updated = reducer.reduce(state, storedEvent) assertEquals(1, updated.l2Memory.size) val entry = updated.l2Memory[0] assertEquals(stageId, entry.stageId) assertEquals(StageOutcomeKind.SUCCESS, entry.outcome) assertTrue(entry.summary.contains("completed")) assertNotNull(entry.timestamp) } @Test fun `StageCompletedEvent preserves other state fields`() { val initial = reducer.initial val started = reducer.reduce( initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val updated = reducer.reduce( started, stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1"))), ) assertEquals(sessionId, updated.sessionId) assertEquals(WorkflowStatus.RUNNING, updated.workflowStatus) assertEquals(stageId, updated.currentStageId) } @Test fun `StageFailedEvent appends RouterL2Entry with FAILURE and clears currentStageId`() { val initial = reducer.initial val started = reducer.reduce( initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val storedEvent = stored(payload = StageFailedEvent(sessionId, stageId, StageId("t1"), "timeout")) val updated = reducer.reduce(started, storedEvent) assertEquals(1, updated.l2Memory.size) val entry = updated.l2Memory[0] assertEquals(stageId, entry.stageId) assertEquals(StageOutcomeKind.FAILURE, entry.outcome) assertEquals("timeout", entry.reason) assertNull(updated.currentStageId) } @Test fun `multiple StageCompletedEvents append to l2Memory sequentially`() { val state = reducer.initial val s1 = reducer.reduce(state, stored(payload = StageCompletedEvent(sessionId, StageId("a"), StageId("t1")))) val s2 = reducer.reduce(s1, stored(payload = StageCompletedEvent(sessionId, StageId("b"), StageId("t2")))) val s3 = reducer.reduce(s2, stored(payload = StageCompletedEvent(sessionId, StageId("c"), StageId("t3")))) assertEquals(3, s3.l2Memory.size) assertEquals(StageId("a"), s3.l2Memory[0].stageId) assertEquals(StageId("b"), s3.l2Memory[1].stageId) assertEquals(StageId("c"), s3.l2Memory[2].stageId) s3.l2Memory.forEach { assertEquals(StageOutcomeKind.SUCCESS, it.outcome) } } @Test fun `SteeringNoteAddedEvent accumulates into pendingSteeringNotes without touching conversation`() { val state = reducer.initial val updated = reducer.reduce( state, stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")), ) assertEquals(listOf("do something different"), updated.pendingSteeringNotes) assertTrue(updated.conversationHistory.isEmpty()) } @Test fun `SteeringNoteAddedEvent preserves workflow status and current stage`() { val initial = reducer.initial val started = reducer.reduce( initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val withNote = reducer.reduce( started, stored(payload = SteeringNoteAddedEvent(sessionId, "shift focus")), ) assertEquals(listOf("shift focus"), withNote.pendingSteeringNotes) assertEquals(WorkflowStatus.RUNNING, withNote.workflowStatus) assertEquals(stageId, withNote.currentStageId) } @Test fun `steering notes are harvested into the completing stage's L2 entry and then cleared`() { val initial = reducer.initial val started = reducer.reduce(initial, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId))) val steered = reducer.reduce(started, stored(payload = SteeringNoteAddedEvent(sessionId, "call it the runtime"))) val completed = reducer.reduce(steered, stored(payload = StageCompletedEvent(sessionId, stageId, StageId("t1")))) assertEquals(listOf("call it the runtime"), completed.l2Memory[0].steeringNotes) assertTrue(completed.pendingSteeringNotes.isEmpty()) } @Test fun `unknown event type returns unchanged state`() { val state = reducer.initial val updated = reducer.reduce(state, stored(payload = ToolInvokedEvent("test"))) assertEquals(state, updated) } @Test fun `l2Memory entry preserves timestamp from event metadata`() { val storedEvent = stored( payload = StageCompletedEvent(sessionId, stageId, StageId("t1")), timestamp = Instant.parse("2026-06-15T12:00:00Z"), ) val updated = reducer.reduce(reducer.initial, storedEvent) assertEquals(1, updated.l2Memory.size) assertNotNull(updated.l2Memory[0].timestamp) } @Test fun `full lifecycle start complete pause resume start another stage fail`() { val s0 = reducer.initial assertEquals(WorkflowStatus.IDLE, s0.workflowStatus) val s1 = reducer.reduce(s0, stored(payload = WorkflowStartedEvent(sessionId, workflowId, StageId("stage-A")))) assertEquals(WorkflowStatus.RUNNING, s1.workflowStatus) assertEquals(StageId("stage-A"), s1.currentStageId) val s2 = reducer.reduce(s1, stored(payload = StageCompletedEvent(sessionId, StageId("stage-A"), StageId("t1")))) assertEquals(1, s2.l2Memory.size) assertEquals(StageOutcomeKind.SUCCESS, s2.l2Memory[0].outcome) val s3 = reducer.reduce(s2, stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1))) assertEquals(WorkflowStatus.COMPLETED, s3.workflowStatus) assertNull(s3.currentStageId) val s4 = reducer.reduce( s3, stored(payload = OrchestrationPausedEvent(sessionId, StageId("stage-A"), "manual pause")), ) assertEquals(WorkflowStatus.PAUSED, s4.workflowStatus) val s5 = reducer.reduce(s4, stored(payload = OrchestrationResumedEvent(sessionId, StageId("stage-A")))) assertEquals(WorkflowStatus.RUNNING, s5.workflowStatus) val s6 = reducer.reduce( s5, stored(payload = StageFailedEvent(sessionId, StageId("stage-B"), StageId("t2"), "llm error")), ) assertEquals(StageOutcomeKind.FAILURE, s6.l2Memory[s6.l2Memory.size - 1].outcome) assertNull(s6.currentStageId) } }