beb1d5c3b9
Two copies had drifted — the core:sessions one and the testing:projections one — and one had rotted into asserting the ACTIVE-forever bug as correct. Port the unique boundProfile + freestyle-planning cases into the comprehensive projections copy and delete the core:sessions duplicate.
459 lines
14 KiB
Kotlin
459 lines
14 KiB
Kotlin
import com.correx.core.events.events.OrchestrationPausedEvent
|
|
import com.correx.core.events.events.OrchestrationResumedEvent
|
|
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
|
import com.correx.core.events.events.StageCompletedEvent
|
|
import com.correx.core.events.events.StageFailedEvent
|
|
import com.correx.core.events.events.TransitionExecutedEvent
|
|
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.events.types.TransitionId
|
|
import com.correx.core.events.events.AgentInstructionsBoundEvent
|
|
import com.correx.core.events.events.OperatorProfileBoundEvent
|
|
import com.correx.core.events.events.ProjectProfileBoundEvent
|
|
import com.correx.core.sessions.BoundAgentInstructions
|
|
import com.correx.core.sessions.BoundProfile
|
|
import com.correx.core.sessions.BoundProjectProfile
|
|
import com.correx.core.sessions.BoundWorkspace
|
|
import com.correx.core.sessions.DefaultSessionReducer
|
|
import com.correx.core.sessions.SessionState
|
|
import com.correx.core.sessions.SessionStatus
|
|
import com.correx.testing.fixtures.EventFixtures.stored
|
|
import kotlinx.datetime.Instant
|
|
import org.junit.jupiter.api.Assertions.assertEquals
|
|
import org.junit.jupiter.api.Test
|
|
|
|
class DefaultSessionReducerTest {
|
|
|
|
private val reducer = DefaultSessionReducer()
|
|
|
|
private val sessionId = SessionId("session-1")
|
|
|
|
@Test
|
|
fun `WorkflowStartedEvent leaves CREATED status`() {
|
|
val state = initialState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = WorkflowStartedEvent(sessionId, workflowId = "test-wf", startStageId = StageId("st-1"))
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.CREATED, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `OrchestrationPausedEvent leaves ACTIVE status`() {
|
|
val state = activeState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = OrchestrationPausedEvent(sessionId, stageId = StageId("st-1"), reason = "APPROVAL_PENDING")
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.ACTIVE, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `OrchestrationResumedEvent leaves PAUSED status`() {
|
|
val state = pausedState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = OrchestrationResumedEvent(sessionId, stageId = StageId("st-1"))
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.PAUSED, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `WorkflowCompletedEvent flips to COMPLETED status`() {
|
|
val state = activeState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = WorkflowCompletedEvent(sessionId, terminalStageId = StageId("st-1"), totalStages = 0)
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.COMPLETED, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `WorkflowFailedEvent flips to FAILED status`() {
|
|
val state = activeState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = WorkflowFailedEvent(sessionId, stageId = StageId("st-1"), reason = "error", retryExhausted = false)
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.FAILED, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `TransitionExecutedEvent from StageStartedEvent sets ACTIVE status`() {
|
|
val state = pausedState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = TransitionExecutedEvent(
|
|
sessionId,
|
|
from = StageId("st-1"),
|
|
to = StageId("st-1"),
|
|
transitionId = TransitionId("transition-a")
|
|
)
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.ACTIVE, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `StageCompletedEvent sets ACTIVE status`() {
|
|
val state = pausedState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = StageCompletedEvent(
|
|
sessionId,
|
|
stageId = StageId("stage-a"),
|
|
transitionId = TransitionId("transition-a")
|
|
)
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.ACTIVE, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `StageFailedEvent sets FAILED status`() {
|
|
val state = activeState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = StageFailedEvent(
|
|
sessionId,
|
|
stageId = StageId("stage-a"),
|
|
transitionId = TransitionId("transition-a"),
|
|
reason = "boom"
|
|
)
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.FAILED, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `TransitionExecutedEvent sets ACTIVE status`() {
|
|
val state = pausedState()
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = TransitionExecutedEvent(
|
|
sessionId,
|
|
from = StageId("stage-a"),
|
|
to = StageId("stage-b"),
|
|
transitionId = TransitionId("transition-a"),
|
|
)
|
|
)
|
|
)
|
|
|
|
assertEquals(SessionStatus.ACTIVE, result.status)
|
|
}
|
|
|
|
@Test
|
|
fun `createdAt is initialized once`() {
|
|
val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
|
|
|
val result = reducer.reduce(
|
|
state = initialState(),
|
|
event = stored(
|
|
payload = WorkflowStartedEvent(sessionId, workflowId = "test-wf", startStageId = StageId("st-1")),
|
|
timestamp = timestamp
|
|
)
|
|
)
|
|
|
|
assertEquals(timestamp, result.createdAt)
|
|
}
|
|
|
|
@Test
|
|
fun `createdAt is preserved after initialization`() {
|
|
val createdAt = Instant.parse("2026-01-01T00:00:00Z")
|
|
val updatedAt = Instant.parse("2026-01-02T00:00:00Z")
|
|
|
|
val state = activeState().copy(
|
|
createdAt = createdAt
|
|
)
|
|
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
payload = OrchestrationPausedEvent(sessionId, stageId = StageId("st-1"), reason = "APPROVAL_PENDING"),
|
|
timestamp = updatedAt
|
|
)
|
|
)
|
|
|
|
assertEquals(createdAt, result.createdAt)
|
|
}
|
|
|
|
@Test
|
|
fun `updatedAt always reflects latest event timestamp`() {
|
|
val timestamp = Instant.parse("2026-01-02T00:00:00Z")
|
|
|
|
val result = reducer.reduce(
|
|
state = activeState(),
|
|
event = stored(
|
|
payload = OrchestrationPausedEvent(sessionId, stageId = StageId("st-1"), reason = "APPROVAL_PENDING"),
|
|
timestamp = timestamp
|
|
)
|
|
)
|
|
|
|
assertEquals(timestamp, result.updatedAt)
|
|
}
|
|
|
|
@Test
|
|
fun `SessionWorkspaceBoundEvent sets boundWorkspace in state`() {
|
|
val result = reducer.reduce(
|
|
state = initialState(),
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = SessionWorkspaceBoundEvent(
|
|
sessionId = sessionId,
|
|
workspaceRoot = "/home/user/project",
|
|
allowedPaths = listOf("/home/user/project"),
|
|
)
|
|
)
|
|
)
|
|
|
|
assertEquals(
|
|
BoundWorkspace(
|
|
workspaceRoot = "/home/user/project",
|
|
allowedPaths = listOf("/home/user/project"),
|
|
),
|
|
result.boundWorkspace,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `boundWorkspace is preserved by subsequent unrelated events`() {
|
|
val withWorkspace = initialState().copy(
|
|
boundWorkspace = BoundWorkspace(
|
|
workspaceRoot = "/home/user/project",
|
|
allowedPaths = listOf("/home/user/project"),
|
|
)
|
|
)
|
|
|
|
val result = reducer.reduce(
|
|
state = withWorkspace,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1"))
|
|
)
|
|
)
|
|
|
|
assertEquals(
|
|
BoundWorkspace(
|
|
workspaceRoot = "/home/user/project",
|
|
allowedPaths = listOf("/home/user/project"),
|
|
),
|
|
result.boundWorkspace,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `ProjectProfileBoundEvent reduces into boundProjectProfile`() {
|
|
val result = reducer.reduce(
|
|
state = initialState(),
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = ProjectProfileBoundEvent(
|
|
sessionId = sessionId,
|
|
workspaceRoot = "/repo",
|
|
about = "kernel project",
|
|
conventions = listOf("no bare try-catch"),
|
|
commands = mapOf("test" to "./gradlew check"),
|
|
),
|
|
),
|
|
)
|
|
|
|
assertEquals(
|
|
BoundProjectProfile(
|
|
about = "kernel project",
|
|
conventions = listOf("no bare try-catch"),
|
|
commands = mapOf("test" to "./gradlew check"),
|
|
),
|
|
result.boundProjectProfile,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `boundProjectProfile is preserved by subsequent unrelated events`() {
|
|
val withProfile = initialState().copy(
|
|
boundProjectProfile = BoundProjectProfile(
|
|
about = "kernel project",
|
|
conventions = listOf("no bare try-catch"),
|
|
commands = emptyMap(),
|
|
),
|
|
)
|
|
|
|
val result = reducer.reduce(
|
|
state = withProfile,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1")),
|
|
),
|
|
)
|
|
|
|
assertEquals("kernel project", result.boundProjectProfile?.about)
|
|
}
|
|
|
|
@Test
|
|
fun `AgentInstructionsBoundEvent reduces into boundAgentInstructions`() {
|
|
val result = reducer.reduce(
|
|
state = initialState(),
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = AgentInstructionsBoundEvent(
|
|
sessionId = sessionId,
|
|
workspaceRoot = "/repo",
|
|
sources = listOf("CLAUDE.md", "AGENTS.md"),
|
|
content = "# CLAUDE.md\n\nbe careful",
|
|
),
|
|
),
|
|
)
|
|
|
|
assertEquals(
|
|
BoundAgentInstructions(
|
|
sources = listOf("CLAUDE.md", "AGENTS.md"),
|
|
content = "# CLAUDE.md\n\nbe careful",
|
|
),
|
|
result.boundAgentInstructions,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `boundAgentInstructions is preserved by subsequent unrelated events`() {
|
|
val withInstructions = initialState().copy(
|
|
boundAgentInstructions = BoundAgentInstructions(
|
|
sources = listOf("CLAUDE.md"),
|
|
content = "# CLAUDE.md\n\nrules",
|
|
),
|
|
)
|
|
|
|
val result = reducer.reduce(
|
|
state = withInstructions,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1")),
|
|
),
|
|
)
|
|
|
|
assertEquals(listOf("CLAUDE.md"), result.boundAgentInstructions?.sources)
|
|
}
|
|
|
|
private fun initialState() =
|
|
SessionState(
|
|
status = SessionStatus.CREATED
|
|
)
|
|
|
|
@Test
|
|
fun `OperatorProfileBoundEvent populates boundProfile`() {
|
|
val result = reducer.reduce(
|
|
state = activeState(),
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = OperatorProfileBoundEvent(
|
|
sessionId = sessionId,
|
|
about = "Expert Kotlin engineer",
|
|
approvalMode = "auto",
|
|
preferredModels = listOf("model-x"),
|
|
conventions = listOf("no var"),
|
|
),
|
|
),
|
|
)
|
|
|
|
val profile = result.boundProfile
|
|
assertEquals("Expert Kotlin engineer", profile?.about)
|
|
assertEquals("auto", profile?.approvalMode)
|
|
assertEquals(listOf("model-x"), profile?.preferredModels)
|
|
assertEquals(listOf("no var"), profile?.conventions)
|
|
}
|
|
|
|
@Test
|
|
fun `unrelated event leaves boundProfile unchanged`() {
|
|
val state = activeState().copy(
|
|
boundProfile = BoundProfile(
|
|
about = "original",
|
|
approvalMode = "",
|
|
preferredModels = emptyList(),
|
|
conventions = emptyList(),
|
|
),
|
|
)
|
|
val result = reducer.reduce(
|
|
state = state,
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = SessionWorkspaceBoundEvent(
|
|
sessionId = sessionId,
|
|
workspaceRoot = "/ws",
|
|
allowedPaths = listOf("/ws"),
|
|
),
|
|
),
|
|
)
|
|
|
|
assertEquals("original", result.boundProfile?.about)
|
|
}
|
|
|
|
@Test
|
|
fun `WorkflowCompletedEvent for freestyle planning stays ACTIVE (hands off to execution)`() {
|
|
val result = reducer.reduce(
|
|
state = activeState(),
|
|
event = stored(
|
|
sessionId = sessionId,
|
|
payload = WorkflowCompletedEvent(
|
|
sessionId,
|
|
terminalStageId = StageId("architect"),
|
|
totalStages = 2,
|
|
workflowId = "freestyle_planning",
|
|
),
|
|
),
|
|
)
|
|
|
|
assertEquals(SessionStatus.ACTIVE, result.status)
|
|
}
|
|
|
|
private fun activeState() =
|
|
SessionState(
|
|
status = SessionStatus.ACTIVE
|
|
)
|
|
|
|
private fun pausedState() =
|
|
SessionState(
|
|
status = SessionStatus.PAUSED
|
|
)
|
|
}
|