Files
correx/testing/deterministic/src/test/kotlin/RouterReducerTest.kt
T
kami 1dc7eae8a1 feat(router): enrich L2 decision points with steering and failure reason
Fix broken stage-summary interpolation ($payload.stageId rendered the event's toString()). RouterL2Entry gains reason + steeringNotes; the reducer folds SteeringNoteAddedEvent into pendingSteeringNotes and harvests them into the completing stage's L2 entry, then clears (adr-0003 §4, pure field extraction, zero inference). RouterContextBuilder renders summary + reason + steering.
2026-06-02 13:58:41 +04:00

268 lines
11 KiB
Kotlin

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