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:
@@ -6,6 +6,7 @@ plugins {
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":core:events"))
|
||||
testImplementation(project(":core:router"))
|
||||
testImplementation(project(":core:sessions"))
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:validation"))
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import com.correx.core.context.model.CompressionMetadata
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.router.DefaultRouterContextBuilder
|
||||
import com.correx.core.router.model.RouterConfig
|
||||
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 com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RouterContextBuilderTest {
|
||||
|
||||
private val config = RouterConfig(conversationKeepLast = 3, tokenBudget = TokenBudget(limit = 200))
|
||||
private val builder = DefaultRouterContextBuilder(config)
|
||||
|
||||
private val sessionId = SessionId("test-session")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val clock = Clock.System
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Budget enforcement
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `build fits within budget when all entries are small`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "hi", clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
assertEquals(10000, pack.budgetLimit)
|
||||
assertTrue(pack.budgetUsed <= pack.budgetLimit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build drops entries when budget is exceeded`() {
|
||||
// Build a state where conversation + L2 entries together exceed the tight budget
|
||||
val longContent = "x".repeat(800) // ~400 tokens each
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, longContent, clock.now()),
|
||||
RouterTurn(TurnRole.ROUTER, longContent, clock.now()),
|
||||
RouterTurn(TurnRole.USER, longContent, clock.now()),
|
||||
),
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 100))
|
||||
// L0 entries (system prompt + workflow status) always fit; L1/L2 should be dropped
|
||||
assertTrue(pack.compressionMetadata.entriesDropped > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build drops entries oldest-first for L2 memory`() {
|
||||
val short = "ok"
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), short, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T00:00:00Z")),
|
||||
RouterL2Entry(StageId("s2"), short, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-02T00:00:00Z")),
|
||||
RouterL2Entry(StageId("s3"), short, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-03T00:00:00Z")),
|
||||
),
|
||||
)
|
||||
// L0 consumes ~60 tokens; budget 93 leaves ~33 for L2.
|
||||
// Each L2 entry is ~12 tokens; 2 fit (s1, s2), s3 is dropped
|
||||
val pack = builder.build(state, TokenBudget(limit = 93))
|
||||
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
||||
// Oldest-first eviction: s1 and s2 fit, s3 is the one dropped
|
||||
val remainingStageIds = l2Entries.map { it.sourceId }.toSet()
|
||||
assertEquals(2, l2Entries.size)
|
||||
assertTrue(remainingStageIds.contains("s1"))
|
||||
assertTrue(remainingStageIds.contains("s2"))
|
||||
assertEquals(0, l2Entries.count { it.sourceId == "s3" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build drops conversation turns oldest-first when conversationKeepLast exceeded`() {
|
||||
val configWide = RouterConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
|
||||
val builderWide = DefaultRouterContextBuilder(configWide)
|
||||
val longContent = "x".repeat(400)
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, longContent, Instant.parse("2026-01-01T00:00:00Z")),
|
||||
RouterTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-02T00:00:00Z")),
|
||||
RouterTurn(TurnRole.USER, longContent, Instant.parse("2026-01-03T00:00:00Z")),
|
||||
RouterTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-04T00:00:00Z")),
|
||||
),
|
||||
)
|
||||
// Budget allows L0 + 2 long conversation turns; oldest 2 turns are dropped
|
||||
val pack = builderWide.build(state, TokenBudget(limit = 500))
|
||||
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
||||
// Only the last 2 turns (oldest-first drop) should remain
|
||||
assertEquals(2, l1Entries.size)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// L0 immutability
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `L0 system prompt is always present`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.IDLE,
|
||||
currentStageId = null,
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 0))
|
||||
val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
|
||||
assertTrue(l0Entries.any { it.sourceType == "systemPrompt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `L0 workflow status is always present`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.FAILED,
|
||||
currentStageId = StageId("failed-stage"),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 0))
|
||||
val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
|
||||
assertTrue(l0Entries.any { it.sourceType == "workflowStatus" })
|
||||
val workflowEntry = l0Entries.find { it.sourceType == "workflowStatus" }
|
||||
assertNotNull(workflowEntry)
|
||||
assertTrue(workflowEntry!!.content.contains("failed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `L0 entries survive zero budget`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.COMPLETED,
|
||||
currentStageId = null,
|
||||
conversationHistory = emptyList(),
|
||||
l2Memory = emptyList(),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 0))
|
||||
val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
|
||||
assertEquals(2, l0Entries.size) // systemPrompt + workflowStatus
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `L0 entries survive budget that cannot cover L1 or L2`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "x".repeat(500), clock.now()),
|
||||
),
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 50))
|
||||
val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
|
||||
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
||||
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
||||
assertEquals(2, l0Entries.size)
|
||||
assertEquals(0, l1Entries.size)
|
||||
assertEquals(0, l2Entries.size)
|
||||
assertTrue(pack.compressionMetadata.entriesDropped > 0)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// L2 oldest-first eviction
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `L2 entries are evicted in insertion order (oldest first)`() {
|
||||
val configTight = RouterConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10))
|
||||
val builderTight = DefaultRouterContextBuilder(configTight)
|
||||
val baseTime = Instant.parse("2026-01-01T00:00:00Z")
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "old", StageOutcomeKind.SUCCESS, baseTime),
|
||||
RouterL2Entry(StageId("s2"), "mid", StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T01:00:00Z")),
|
||||
RouterL2Entry(StageId("s3"), "new", StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T02:00:00Z")),
|
||||
),
|
||||
)
|
||||
// L0 consumes ~60 tokens; budget 76 leaves ~16 for L2.
|
||||
// Each L2 entry is ~12 tokens; only 1 fits — oldest (s1) survives
|
||||
val pack = builderTight.build(state, TokenBudget(limit = 76))
|
||||
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
||||
// Oldest-first eviction: s1 fits, s2 and s3 are dropped
|
||||
assertEquals(1, l2Entries.size)
|
||||
assertEquals("s1", l2Entries[0].sourceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `L2 entries fit within budget are all retained`() {
|
||||
val config = RouterConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
|
||||
val builder = DefaultRouterContextBuilder(config)
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
||||
RouterL2Entry(StageId("s2"), "summary", StageOutcomeKind.FAILURE, clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
||||
assertEquals(2, l2Entries.size)
|
||||
assertEquals(0, pack.compressionMetadata.entriesDropped)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layer classification
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `system prompt is classified as L0`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.IDLE,
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l0Entries = pack.layers[ContextLayer.L0]
|
||||
assertNotNull(l0Entries)
|
||||
assertTrue(l0Entries!!.any { it.sourceType == "systemPrompt" && it.layer == ContextLayer.L0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workflow status is classified as L0`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l0Entries = pack.layers[ContextLayer.L0]
|
||||
assertTrue(l0Entries!!.any { it.sourceType == "workflowStatus" && it.layer == ContextLayer.L0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversation turns are classified as L1`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "hello", clock.now()),
|
||||
RouterTurn(TurnRole.ROUTER, "hi", clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l1Entries = pack.layers[ContextLayer.L1]
|
||||
assertNotNull(l1Entries)
|
||||
assertEquals(2, l1Entries!!.size)
|
||||
assertTrue(l1Entries.all { it.layer == ContextLayer.L1 })
|
||||
assertTrue(l1Entries.all { it.sourceType == "conversation" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `l2 memory entries are classified as L2`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l2Entries = pack.layers[ContextLayer.L2]
|
||||
assertNotNull(l2Entries)
|
||||
assertEquals(1, l2Entries!!.size)
|
||||
assertTrue(l2Entries.all { it.layer == ContextLayer.L2 })
|
||||
assertTrue(l2Entries.all { it.sourceType == "stageSummary" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no entries other than L0 L1 L2 appear in pack`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "hello", clock.now()),
|
||||
),
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val presentLayers = pack.layers.keys
|
||||
assertTrue(presentLayers.containsAll(listOf(ContextLayer.L0, ContextLayer.L1, ContextLayer.L2)))
|
||||
assertFalse(presentLayers.contains(ContextLayer.L3))
|
||||
assertFalse(presentLayers.contains(ContextLayer.L4))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Conversation history capping (conversationKeepLast)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `conversationHistory respects conversationKeepLast from config`() {
|
||||
val config = RouterConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
|
||||
val builder = DefaultRouterContextBuilder(config)
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "one", Instant.parse("2026-01-01T00:00:00Z")),
|
||||
RouterTurn(TurnRole.ROUTER, "two", Instant.parse("2026-01-02T00:00:00Z")),
|
||||
RouterTurn(TurnRole.USER, "three", Instant.parse("2026-01-03T00:00:00Z")),
|
||||
RouterTurn(TurnRole.ROUTER, "four", Instant.parse("2026-01-04T00:00:00Z")),
|
||||
RouterTurn(TurnRole.USER, "five", Instant.parse("2026-01-05T00:00:00Z")),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
||||
// conversationKeepLast=2, so only the last 2 turns should appear
|
||||
assertEquals(2, l1Entries.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversationHistory takeLast preserves ordering`() {
|
||||
val config = RouterConfig(conversationKeepLast = 3, tokenBudget = TokenBudget(limit = 10000))
|
||||
val builder = DefaultRouterContextBuilder(config)
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "first", Instant.parse("2026-01-01T00:00:00Z")),
|
||||
RouterTurn(TurnRole.ROUTER, "second", Instant.parse("2026-01-02T00:00:00Z")),
|
||||
RouterTurn(TurnRole.USER, "third", Instant.parse("2026-01-03T00:00:00Z")),
|
||||
RouterTurn(TurnRole.ROUTER, "fourth", Instant.parse("2026-01-04T00:00:00Z")),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
||||
assertEquals(3, l1Entries.size)
|
||||
// The last 3: second, third, fourth
|
||||
assertTrue(l1Entries.any { it.content.contains("second") })
|
||||
assertTrue(l1Entries.any { it.content.contains("third") })
|
||||
assertTrue(l1Entries.any { it.content.contains("fourth") })
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `build with empty state produces L0 only`() {
|
||||
val state = RouterState()
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l0Entries = pack.layers[ContextLayer.L0]
|
||||
assertNotNull(l0Entries)
|
||||
assertEquals(2, l0Entries!!.size)
|
||||
assertTrue(pack.layers[ContextLayer.L1].isNullOrEmpty())
|
||||
assertTrue(pack.layers[ContextLayer.L2].isNullOrEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pack contains correct metadata on empty state`() {
|
||||
val state = RouterState()
|
||||
val pack = builder.build(state, TokenBudget(limit = 5000))
|
||||
assertEquals(5000, pack.budgetLimit)
|
||||
assertEquals(0, pack.compressionMetadata.entriesDropped)
|
||||
assertEquals(listOf("L0Immutable", "Conversation"), pack.compressionMetadata.appliedStrategies)
|
||||
assertTrue(pack.compressionMetadata.truncatedLayers.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pack has correct context pack id and session info`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 1000))
|
||||
assertEquals("test-session-router-pack", pack.id.value)
|
||||
assertEquals(sessionId, pack.sessionId)
|
||||
assertEquals(stageId, pack.stageId)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Content formatting
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `system prompt has expected content`() {
|
||||
val state = RouterState()
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val systemEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "systemPrompt" }
|
||||
assertNotNull(systemEntry)
|
||||
assertEquals(
|
||||
"You are a routing assistant. Provide guidance based on workflow state and conversation context.",
|
||||
systemEntry!!.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workflow status entry contains status and stage`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.COMPLETED,
|
||||
currentStageId = null,
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val workflowEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "workflowStatus" }
|
||||
assertNotNull(workflowEntry)
|
||||
assertTrue(workflowEntry!!.content.contains("Status: Completed"))
|
||||
assertTrue(workflowEntry.content.contains("Stage: none"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversation turn entry is formatted with role prefix`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "user message", clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
||||
assertEquals(1, l1Entries.size)
|
||||
assertEquals("[USER] user message", l1Entries[0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `l2 entry is formatted with stage outcome and summary`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("stage-x"), "completed with 3 items", StageOutcomeKind.SUCCESS, clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
||||
assertEquals(1, l2Entries.size)
|
||||
val entry = l2Entries[0]
|
||||
assertEquals("stage-x", entry.sourceId)
|
||||
assertTrue(entry.content.contains("Stage stage-x"))
|
||||
assertTrue(entry.content.contains("SUCCESS"))
|
||||
assertTrue(entry.content.contains("completed with 3 items"))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Token budget accounting
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `budgetUsed equals sum of tokenEstimate of all retained entries`() {
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "hello world", clock.now()),
|
||||
),
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
val allEntries = pack.layers.values.flatten()
|
||||
val computedSum = allEntries.sumOf { it.tokenEstimate }
|
||||
assertEquals(computedSum, pack.budgetUsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compressionMetadata entriesDropped reflects dropped count`() {
|
||||
val config = RouterConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10))
|
||||
val builder = DefaultRouterContextBuilder(config)
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "x".repeat(200), clock.now()),
|
||||
),
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
|
||||
RouterL2Entry(StageId("s2"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 65))
|
||||
// L0 consumes ~60 tokens, leaving 5 — both L2 entries (each ~259 tokens) dropped
|
||||
// conversationKeepLast=0 means conversation entry is not included
|
||||
assertEquals(2, pack.compressionMetadata.entriesDropped)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Integration: full lifecycle
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `build with full state including all layers`() {
|
||||
val config = RouterConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
|
||||
val builder = DefaultRouterContextBuilder(config)
|
||||
val state = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stageId,
|
||||
conversationHistory = listOf(
|
||||
RouterTurn(TurnRole.USER, "first turn", Instant.parse("2026-01-01T00:00:00Z")),
|
||||
RouterTurn(TurnRole.ROUTER, "first reply", Instant.parse("2026-01-02T00:00:00Z")),
|
||||
RouterTurn(TurnRole.USER, "second turn", Instant.parse("2026-01-03T00:00:00Z")),
|
||||
RouterTurn(TurnRole.ROUTER, "second reply", Instant.parse("2026-01-04T00:00:00Z")),
|
||||
),
|
||||
l2Memory = listOf(
|
||||
RouterL2Entry(StageId("s1"), "stage one done", StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T10:00:00Z")),
|
||||
RouterL2Entry(StageId("s2"), "stage two failed", StageOutcomeKind.FAILURE, Instant.parse("2026-01-02T10:00:00Z")),
|
||||
),
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 10000))
|
||||
|
||||
// L0: system prompt + workflow status
|
||||
val l0 = pack.layers[ContextLayer.L0]!!
|
||||
assertEquals(2, l0.size)
|
||||
assertEquals("systemPrompt", l0[0].sourceType)
|
||||
assertEquals("workflowStatus", l0[1].sourceType)
|
||||
|
||||
// L1: last 2 conversation turns
|
||||
val l1 = pack.layers[ContextLayer.L1]!!
|
||||
assertEquals(2, l1.size)
|
||||
assertEquals("conversation", l1[0].sourceType)
|
||||
|
||||
// L2: both stage summaries fit
|
||||
val l2 = pack.layers[ContextLayer.L2]!!
|
||||
assertEquals(2, l2.size)
|
||||
assertEquals("stageSummary", l2[0].sourceType)
|
||||
|
||||
// Budget and metadata
|
||||
assertTrue(pack.budgetUsed > 0)
|
||||
assertTrue(pack.budgetUsed <= pack.budgetLimit)
|
||||
assertEquals(0, pack.compressionMetadata.entriesDropped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with null sessionId produces unknown pack id`() {
|
||||
val state = RouterState(
|
||||
workflowStatus = WorkflowStatus.IDLE,
|
||||
)
|
||||
val pack = builder.build(state, TokenBudget(limit = 1000))
|
||||
assertTrue(pack.id.value.contains("unknown"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.GenerationConfig
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.core.router.ChatMode
|
||||
import com.correx.core.router.DefaultRouterContextBuilder
|
||||
import com.correx.core.router.DefaultRouterFacade
|
||||
import com.correx.core.router.RouterContextBuilder
|
||||
import com.correx.core.router.RouterFacade
|
||||
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.context.model.ContextPack
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
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
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class RouterFacadeTest {
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CHAT mode
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `CHAT mode returns inference response content`() = runBlocking {
|
||||
val mockStore = mockEventStore()
|
||||
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
|
||||
val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello, world!")
|
||||
assertEquals("inference response", response.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CHAT mode sets steeringEmitted to false`() = runBlocking {
|
||||
val mockStore = mockEventStore()
|
||||
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
|
||||
val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
assertFalse(response.steeringEmitted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CHAT mode does not append to EventStore`() = runBlocking {
|
||||
val mockStore = mockEventStore()
|
||||
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
assertTrue(mockStore.appendedEvents.isEmpty())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// STEERING mode
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `STEERING mode sets steeringEmitted to true`() = runBlocking {
|
||||
val mockStore = mockEventStore()
|
||||
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
|
||||
val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
assertTrue(response.steeringEmitted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STEERING mode appends SteeringNoteAddedEvent with correct session id and user input`() = 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 event = mockStore.appendedEvents[0]
|
||||
assertTrue(event.payload is SteeringNoteAddedEvent)
|
||||
val steeringEvent = event.payload as SteeringNoteAddedEvent
|
||||
assertEquals(SessionId("session-xyz"), steeringEvent.sessionId)
|
||||
assertEquals("steer this way", steeringEvent.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STEERING mode appends SteeringNoteAddedEvent with stageId from state`() = runBlocking {
|
||||
val mockStore = mockEventStore()
|
||||
val stageId = StageId("stage-A")
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = stageId)
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter("response"),
|
||||
eventStore = mockStore,
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING)
|
||||
val steeringEvent = mockStore.appendedEvents[0].payload as SteeringNoteAddedEvent
|
||||
assertEquals(stageId, steeringEvent.stageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STEERING mode appends SteeringNoteAddedEvent with null stageId when state has none`() = runBlocking {
|
||||
val mockStore = mockEventStore()
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(sessionId = sessionId, workflowStatus = WorkflowStatus.IDLE, currentStageId = null)
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter("response"),
|
||||
eventStore = mockStore,
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING)
|
||||
val steeringEvent = mockStore.appendedEvents[0].payload as SteeringNoteAddedEvent
|
||||
assertNull(steeringEvent.stageId)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// In-memory conversation history
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `conversation history grows per call — user and router turns appended`() = runBlocking {
|
||||
val capturedStates = mutableListOf<RouterState>()
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = StageId("s1"))
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack {
|
||||
capturedStates.add(state)
|
||||
return emptyContextPack()
|
||||
}
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter("router reply"),
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
val sessionId = SessionId("history-session")
|
||||
facade.onUserInput(sessionId = sessionId, input = "first message")
|
||||
facade.onUserInput(sessionId = sessionId, input = "second message")
|
||||
|
||||
// Second call's context builder sees two user turns + one router turn from the first call
|
||||
val stateOnSecondCall = capturedStates[1]
|
||||
assertEquals(3, stateOnSecondCall.conversationHistory.size)
|
||||
assertEquals(TurnRole.USER, stateOnSecondCall.conversationHistory[0].role)
|
||||
assertEquals("first message", stateOnSecondCall.conversationHistory[0].content)
|
||||
assertEquals(TurnRole.ROUTER, stateOnSecondCall.conversationHistory[1].role)
|
||||
assertEquals("router reply", stateOnSecondCall.conversationHistory[1].content)
|
||||
assertEquals(TurnRole.USER, stateOnSecondCall.conversationHistory[2].role)
|
||||
assertEquals("second message", stateOnSecondCall.conversationHistory[2].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversation history is session-scoped — different sessions do not share history`() = runBlocking {
|
||||
val capturedStates = mutableListOf<RouterState>()
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING)
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack {
|
||||
capturedStates.add(state)
|
||||
return emptyContextPack()
|
||||
}
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter("response"),
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("session-A"), input = "message A")
|
||||
facade.onUserInput(sessionId = SessionId("session-B"), input = "message B")
|
||||
|
||||
val stateA = capturedStates[0]
|
||||
val stateB = capturedStates[1]
|
||||
assertEquals(1, stateA.conversationHistory.size)
|
||||
assertEquals(1, stateB.conversationHistory.size)
|
||||
assertEquals("message A", stateA.conversationHistory[0].content)
|
||||
assertEquals("message B", stateB.conversationHistory[0].content)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Orchestration
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `state is passed through to context builder`() = runBlocking {
|
||||
val capturedState = mutableListOf<RouterState>()
|
||||
val mockContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack {
|
||||
capturedState.add(state)
|
||||
return emptyContextPack()
|
||||
}
|
||||
}
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = StageId("s1"))
|
||||
},
|
||||
routerContextBuilder = mockContextBuilder,
|
||||
inferenceRouter = mockInferenceRouter("inference response"),
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
assertEquals(1, capturedState.size)
|
||||
assertEquals(SessionId("test-session"), capturedState[0].sessionId)
|
||||
assertEquals(WorkflowStatus.RUNNING, capturedState[0].workflowStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `budget is passed through to context builder`() = runBlocking {
|
||||
val capturedBudget = mutableListOf<TokenBudget>()
|
||||
val mockContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack {
|
||||
capturedBudget.add(budget)
|
||||
return emptyContextPack()
|
||||
}
|
||||
}
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState()
|
||||
},
|
||||
routerContextBuilder = mockContextBuilder,
|
||||
inferenceRouter = mockInferenceRouter("response"),
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 4200)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
assertEquals(1, capturedBudget.size)
|
||||
assertEquals(4200, capturedBudget[0].limit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stage ID uses state currentStageId`() = runBlocking {
|
||||
val capturedStageId = mutableListOf<StageId>()
|
||||
val mockInferenceRouter = object : InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
|
||||
capturedStageId.add(stageId)
|
||||
return mockProvider("response")
|
||||
}
|
||||
}
|
||||
val stateStageId = StageId("state-stage")
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.RUNNING,
|
||||
currentStageId = stateStageId,
|
||||
)
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter,
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
assertEquals(stateStageId, capturedStageId[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stage ID falls back to StageId("none") when state has no currentStageId`() = runBlocking {
|
||||
val capturedStageId = mutableListOf<StageId>()
|
||||
val mockInferenceRouter = object : InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
|
||||
capturedStageId.add(stageId)
|
||||
return mockProvider("response")
|
||||
}
|
||||
}
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState(
|
||||
sessionId = sessionId,
|
||||
workflowStatus = WorkflowStatus.IDLE,
|
||||
currentStageId = null,
|
||||
)
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter,
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
assertEquals(StageId("none"), capturedStageId[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new InferenceRequestId per call`() = runBlocking {
|
||||
val capturedRequestIds = mutableListOf<InferenceRequestId>()
|
||||
val mockInferenceRouter = object : InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
|
||||
return mockProviderWithCapture("response", capturedRequestIds)
|
||||
}
|
||||
}
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState()
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter,
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("s1"), input = "first")
|
||||
facade.onUserInput(sessionId = SessionId("s1"), input = "second")
|
||||
assertEquals(2, capturedRequestIds.size)
|
||||
assertFalse(capturedRequestIds[0] == capturedRequestIds[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GenerationConfig defaults temperature 0 7 topP 0 9 maxTokens 512`() = runBlocking {
|
||||
val capturedRequests = mutableListOf<InferenceRequest>()
|
||||
val mockInferenceRouter = object : InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
|
||||
return mockProviderWithRequestCapture("response", capturedRequests)
|
||||
}
|
||||
}
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState()
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter,
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
val req = capturedRequests[0]
|
||||
assertEquals(0.7, req.generationConfig.temperature)
|
||||
assertEquals(0.9, req.generationConfig.topP)
|
||||
assertEquals(512, req.generationConfig.maxTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `context pack is passed to inference provider`() = runBlocking {
|
||||
val capturedContextPacks = mutableListOf<ContextPack>()
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState()
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack {
|
||||
val pack = ContextPack(
|
||||
id = ContextPackId("test-pack"),
|
||||
sessionId = state.sessionId ?: SessionId("unknown"),
|
||||
stageId = StageId("test"),
|
||||
layers = emptyMap(),
|
||||
budgetUsed = 0,
|
||||
budgetLimit = budget.limit,
|
||||
)
|
||||
capturedContextPacks.add(pack)
|
||||
return pack
|
||||
}
|
||||
},
|
||||
inferenceRouter = object : InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
|
||||
return object : InferenceProvider {
|
||||
override val id = com.correx.core.events.types.ProviderId("mock")
|
||||
override val name = "Mock"
|
||||
override val tokenizer = com.correx.testing.fixtures.inference.MockTokenizer()
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||
assertEquals(1, capturedContextPacks.size)
|
||||
assertEquals(capturedContextPacks[0].id, request.contextPack.id)
|
||||
return InferenceResponse(
|
||||
requestId = request.requestId,
|
||||
text = "response",
|
||||
finishReason = com.correx.core.inference.FinishReason.Stop,
|
||||
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
|
||||
latencyMs = 0,
|
||||
)
|
||||
}
|
||||
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth =
|
||||
com.correx.core.inference.ProviderHealth.Healthy
|
||||
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = emptySet()
|
||||
}
|
||||
}
|
||||
},
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `responseFormat defaults to Text`() = runBlocking {
|
||||
val capturedRequests = mutableListOf<InferenceRequest>()
|
||||
val facade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState()
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = object : InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
|
||||
return mockProviderWithRequestCapture("response", capturedRequests)
|
||||
}
|
||||
},
|
||||
eventStore = mockEventStore(),
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
)
|
||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
val req = capturedRequests[0]
|
||||
assertTrue(req.responseFormat is com.correx.core.inference.ResponseFormat.Text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onUserInput returns RouterResponse with content and mode flag`() = runBlocking {
|
||||
val mockStore = mockEventStore()
|
||||
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
|
||||
val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||
assertNotNull(response)
|
||||
assertEquals("inference response", response.content)
|
||||
assertTrue(response.steeringEmitted)
|
||||
assertTrue(mockStore.appendedEvents.isNotEmpty())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private fun mockEventStore(): MapBackedEventStore = MapBackedEventStore()
|
||||
|
||||
private fun facadeWithMocks(
|
||||
eventStore: EventStore,
|
||||
chatMode: ChatMode = ChatMode.CHAT,
|
||||
): RouterFacade = DefaultRouterFacade(
|
||||
routerRepository = object : RouterRepository {
|
||||
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||
RouterState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = StageId("s1"))
|
||||
},
|
||||
routerContextBuilder = object : RouterContextBuilder {
|
||||
override fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||
},
|
||||
inferenceRouter = mockInferenceRouter("inference response"),
|
||||
eventStore = eventStore,
|
||||
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||
).let { impl ->
|
||||
object : RouterFacade {
|
||||
override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse =
|
||||
impl.onUserInput(sessionId = sessionId, input = input, mode = chatMode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mockInferenceRouter(responseText: String): InferenceRouter =
|
||||
object : InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider =
|
||||
mockProvider(responseText)
|
||||
}
|
||||
|
||||
private fun mockProvider(responseText: String): InferenceProvider =
|
||||
object : InferenceProvider {
|
||||
override val id = com.correx.core.events.types.ProviderId("mock")
|
||||
override val name = "Mock"
|
||||
override val tokenizer = com.correx.testing.fixtures.inference.MockTokenizer()
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse = InferenceResponse(
|
||||
requestId = request.requestId,
|
||||
text = responseText,
|
||||
finishReason = com.correx.core.inference.FinishReason.Stop,
|
||||
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
|
||||
latencyMs = 0,
|
||||
)
|
||||
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth =
|
||||
com.correx.core.inference.ProviderHealth.Healthy
|
||||
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> =
|
||||
setOf(com.correx.core.inference.CapabilityScore(ModelCapability.General, 1.0))
|
||||
}
|
||||
|
||||
private fun mockProviderWithCapture(
|
||||
responseText: String,
|
||||
requestIds: MutableList<InferenceRequestId>,
|
||||
): InferenceProvider =
|
||||
object : InferenceProvider {
|
||||
override val id = com.correx.core.events.types.ProviderId("mock")
|
||||
override val name = "Mock"
|
||||
override val tokenizer = com.correx.testing.fixtures.inference.MockTokenizer()
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||
requestIds.add(request.requestId)
|
||||
return InferenceResponse(
|
||||
requestId = request.requestId,
|
||||
text = responseText,
|
||||
finishReason = com.correx.core.inference.FinishReason.Stop,
|
||||
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
|
||||
latencyMs = 0,
|
||||
)
|
||||
}
|
||||
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth =
|
||||
com.correx.core.inference.ProviderHealth.Healthy
|
||||
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = emptySet()
|
||||
}
|
||||
|
||||
private fun mockProviderWithRequestCapture(
|
||||
responseText: String,
|
||||
requests: MutableList<InferenceRequest>,
|
||||
): InferenceProvider =
|
||||
object : InferenceProvider {
|
||||
override val id = com.correx.core.events.types.ProviderId("mock")
|
||||
override val name = "Mock"
|
||||
override val tokenizer = com.correx.testing.fixtures.inference.MockTokenizer()
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||
requests.add(request)
|
||||
return InferenceResponse(
|
||||
requestId = request.requestId,
|
||||
text = responseText,
|
||||
finishReason = com.correx.core.inference.FinishReason.Stop,
|
||||
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
|
||||
latencyMs = 0,
|
||||
)
|
||||
}
|
||||
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth =
|
||||
com.correx.core.inference.ProviderHealth.Healthy
|
||||
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = emptySet()
|
||||
}
|
||||
|
||||
private fun emptyContextPack(): ContextPack = ContextPack(
|
||||
id = ContextPackId("empty"),
|
||||
sessionId = SessionId("unknown"),
|
||||
stageId = StageId("none"),
|
||||
layers = emptyMap(),
|
||||
budgetUsed = 0,
|
||||
budgetLimit = 5000,
|
||||
)
|
||||
|
||||
private class MapBackedEventStore : EventStore {
|
||||
val appendedEvents = mutableListOf<NewEvent>()
|
||||
private val storedEvents = mutableMapOf<com.correx.core.events.types.EventId, StoredEvent>()
|
||||
private var nextSequence = 1L
|
||||
|
||||
override suspend fun append(event: NewEvent): StoredEvent {
|
||||
appendedEvents.add(event)
|
||||
val stored = StoredEvent(
|
||||
metadata = event.metadata,
|
||||
sequence = nextSequence++,
|
||||
payload = event.payload,
|
||||
)
|
||||
storedEvents[event.metadata.eventId] = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> =
|
||||
events.map { append(it) }
|
||||
|
||||
override fun read(sessionId: com.correx.core.events.types.SessionId): List<StoredEvent> =
|
||||
storedEvents.values.filter { it.metadata.sessionId == sessionId }.toList()
|
||||
|
||||
override fun readFrom(sessionId: com.correx.core.events.types.SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
read(sessionId).filter { it.sequence >= fromSequence }
|
||||
|
||||
override fun lastSequence(sessionId: com.correx.core.events.types.SessionId): Long? =
|
||||
read(sessionId).maxOfOrNull { it.sequence }
|
||||
|
||||
override fun subscribe(sessionId: com.correx.core.events.types.SessionId): kotlinx.coroutines.flow.Flow<StoredEvent> =
|
||||
throw UnsupportedOperationException("subscribe not implemented for mock")
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> =
|
||||
storedEvents.values.asSequence()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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.router.DefaultRouterReducer
|
||||
import com.correx.core.router.RouterProjector
|
||||
import com.correx.core.router.model.RouterState
|
||||
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.Assertions.assertTrue
|
||||
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() returns RouterState with IDLE status and empty collections`() {
|
||||
val state = projector.initial()
|
||||
assertEquals(WorkflowStatus.IDLE, state.workflowStatus)
|
||||
assertNull(state.sessionId)
|
||||
assertNull(state.currentStageId)
|
||||
assertTrue(state.l2Memory.isEmpty())
|
||||
assertTrue(state.conversationHistory.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial returns same state as reducer initial`() {
|
||||
val projectorState = projector.initial()
|
||||
val reducerState = reducer.initial
|
||||
assertEquals(reducerState, projectorState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(WorkflowStartedEvent) delegates to reducer and sets RUNNING`() {
|
||||
val state = projector.initial()
|
||||
val updated = projector.apply(
|
||||
state,
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
)
|
||||
assertEquals(sessionId, updated.sessionId)
|
||||
assertEquals(WorkflowStatus.RUNNING, updated.workflowStatus)
|
||||
assertEquals(stageId, updated.currentStageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(WorkflowCompletedEvent) delegates to reducer and sets COMPLETED`() {
|
||||
val started = projector.apply(
|
||||
projector.initial(),
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
)
|
||||
val updated = projector.apply(
|
||||
started,
|
||||
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3)),
|
||||
)
|
||||
assertEquals(WorkflowStatus.COMPLETED, updated.workflowStatus)
|
||||
assertNull(updated.currentStageId)
|
||||
assertEquals(sessionId, updated.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(WorkflowFailedEvent) delegates to reducer and sets FAILED`() {
|
||||
val started = projector.apply(
|
||||
projector.initial(),
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
)
|
||||
val updated = projector.apply(
|
||||
started,
|
||||
stored(payload = WorkflowFailedEvent(sessionId, stageId, "timeout", false)),
|
||||
)
|
||||
assertEquals(WorkflowStatus.FAILED, updated.workflowStatus)
|
||||
assertNull(updated.currentStageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(OrchestrationPausedEvent) delegates to reducer and sets PAUSED`() {
|
||||
val started = projector.apply(
|
||||
projector.initial(),
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
)
|
||||
val updated = projector.apply(
|
||||
started,
|
||||
stored(payload = OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")),
|
||||
)
|
||||
assertEquals(WorkflowStatus.PAUSED, updated.workflowStatus)
|
||||
assertEquals(sessionId, updated.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(OrchestrationResumedEvent) delegates to reducer and sets RUNNING`() {
|
||||
val started = projector.apply(
|
||||
projector.initial(),
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
)
|
||||
val paused = projector.apply(
|
||||
started,
|
||||
stored(payload = OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")),
|
||||
)
|
||||
val updated = projector.apply(
|
||||
paused,
|
||||
stored(payload = OrchestrationResumedEvent(sessionId, stageId)),
|
||||
)
|
||||
assertEquals(WorkflowStatus.RUNNING, updated.workflowStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(StageCompletedEvent) delegates to reducer and appends l2Memory entry`() {
|
||||
val state = projector.initial()
|
||||
val updated = projector.apply(
|
||||
state,
|
||||
stored(payload = StageCompletedEvent(sessionId, stageId, StageId("t1"))),
|
||||
)
|
||||
assertEquals(1, updated.l2Memory.size)
|
||||
val entry = updated.l2Memory[0]
|
||||
assertEquals(stageId, entry.stageId)
|
||||
assertEquals(StageOutcomeKind.SUCCESS, entry.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(StageFailedEvent) delegates to reducer and appends FAILURE entry`() {
|
||||
val state = projector.apply(
|
||||
projector.initial(),
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
)
|
||||
val updated = projector.apply(
|
||||
state,
|
||||
stored(payload = StageFailedEvent(sessionId, stageId, StageId("t1"), "timeout")),
|
||||
)
|
||||
assertEquals(1, updated.l2Memory.size)
|
||||
val entry = updated.l2Memory[0]
|
||||
assertEquals(stageId, entry.stageId)
|
||||
assertEquals(StageOutcomeKind.FAILURE, entry.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(SteeringNoteAddedEvent) leaves state unchanged`() {
|
||||
val state = projector.initial()
|
||||
val updated = projector.apply(
|
||||
state,
|
||||
stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")),
|
||||
)
|
||||
assertEquals(state, updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply(ToolInvokedEvent) leaves state unchanged`() {
|
||||
val state = projector.initial()
|
||||
val updated = projector.apply(
|
||||
state,
|
||||
stored(payload = ToolInvokedEvent("test")),
|
||||
)
|
||||
assertEquals(state, updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply produces same result as reducer reduce for every handled event`() {
|
||||
val state = projector.initial()
|
||||
val events = listOf(
|
||||
stored(payload = WorkflowStartedEvent(sessionId, StageId("a"))),
|
||||
stored(payload = StageCompletedEvent(sessionId, StageId("a"), StageId("t1"))),
|
||||
stored(payload = OrchestrationPausedEvent(sessionId, StageId("a"), "hold")),
|
||||
stored(payload = OrchestrationResumedEvent(sessionId, StageId("a"))),
|
||||
stored(payload = StageCompletedEvent(sessionId, StageId("b"), StageId("t2"))),
|
||||
stored(payload = StageFailedEvent(sessionId, StageId("b"), StageId("t3"), "error")),
|
||||
stored(payload = WorkflowCompletedEvent(sessionId, StageId("c"), 3)),
|
||||
)
|
||||
|
||||
var projected = state
|
||||
var reduced = state
|
||||
for (evt in events) {
|
||||
projected = projector.apply(projected, evt)
|
||||
reduced = reducer.reduce(reduced, evt)
|
||||
}
|
||||
assertEquals(reduced, projected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full lifecycle through projector mirrors reducer pipeline`() {
|
||||
var state: RouterState = projector.initial()
|
||||
|
||||
state = projector.apply(
|
||||
state,
|
||||
stored(payload = WorkflowStartedEvent(sessionId, StageId("stage-A"))),
|
||||
)
|
||||
assertEquals(WorkflowStatus.RUNNING, state.workflowStatus)
|
||||
|
||||
state = projector.apply(
|
||||
state,
|
||||
stored(payload = StageCompletedEvent(sessionId, StageId("stage-A"), StageId("t1"))),
|
||||
)
|
||||
assertEquals(1, state.l2Memory.size)
|
||||
|
||||
state = projector.apply(
|
||||
state,
|
||||
stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1)),
|
||||
)
|
||||
assertEquals(WorkflowStatus.COMPLETED, state.workflowStatus)
|
||||
assertNull(state.currentStageId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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.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.router.DefaultRouterReducer
|
||||
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 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")
|
||||
|
||||
@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, 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, 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, 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, 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, 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, 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, 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)
|
||||
assertTrue(entry.summary.contains("timeout"))
|
||||
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 does not change state`() {
|
||||
val state = reducer.initial
|
||||
val updated = reducer.reduce(
|
||||
state,
|
||||
stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")),
|
||||
)
|
||||
assertEquals(state, updated)
|
||||
assertTrue(updated.conversationHistory.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SteeringNoteAddedEvent on non-initial state does not change state`() {
|
||||
val initial = reducer.initial
|
||||
val started = reducer.reduce(
|
||||
initial,
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
)
|
||||
val withNote = reducer.reduce(
|
||||
started,
|
||||
stored(payload = SteeringNoteAddedEvent(sessionId, "shift focus")),
|
||||
)
|
||||
assertEquals(started, withNote)
|
||||
assertEquals(WorkflowStatus.RUNNING, withNote.workflowStatus)
|
||||
assertEquals(stageId, withNote.currentStageId)
|
||||
}
|
||||
|
||||
@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, 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user