refactor(cleanup-02): move ApprovalRequired write-side into SessionsReducer

SessionsReducer now handles ServerMessage.ApprovalRequired and populates
SessionSummary.pendingApproval directly on the matched session. The
SessionSnapshot handler also propagates approval fields to pendingApproval.
ApprovalReducer's ServerEventReceived branch is removed; approval truth is
now strictly scoped to a single SessionSummary.
This commit is contained in:
2026-05-25 14:52:26 +04:00
parent d2c6789c4d
commit 1c67993f5d
4 changed files with 119 additions and 66 deletions
@@ -2,15 +2,10 @@ package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.ApprovalState
import com.correx.apps.tui.state.InputMode
import com.correx.core.events.types.ApprovalRequestId
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(ApprovalReducer::class.simpleName)
object ApprovalReducer {
fun reduce(
@@ -58,43 +53,6 @@ object ApprovalReducer {
approval to emptyList()
}
is Action.ServerEventReceived -> when (val msg = action.message) {
is ServerMessage.ApprovalRequired -> {
log.debug(
"ApprovalRequired received requestId={} sessionId={} toolName={}",
msg.requestId.value, msg.sessionId.value, msg.toolName,
)
val info = ApprovalInfo(
requestId = msg.requestId.value,
sessionId = msg.sessionId.value,
tier = msg.tier.name,
riskSummary = msg.riskSummary.level,
toolName = msg.toolName,
preview = msg.preview,
)
ApprovalState(active = info) to emptyList()
}
is ServerMessage.SessionSnapshot -> {
val requestId = msg.approvalRequestId
if (msg.pendingApproval && requestId != null) {
val info = ApprovalInfo(
requestId = requestId,
sessionId = msg.sessionId.value,
tier = msg.approvalTier ?: "T2",
riskSummary = "unknown",
toolName = msg.approvalToolName,
preview = msg.approvalPreview,
)
ApprovalState(active = info) to emptyList()
} else {
approval to emptyList()
}
}
else -> approval to emptyList()
}
else -> approval to emptyList()
}
}
@@ -4,6 +4,7 @@ import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
@@ -107,6 +108,7 @@ object SessionsReducer {
is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions)
is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg)
is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg)
is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg)
else -> sessions to emptyList()
}.also { log.debug("processed server message: {}", msg) }
@@ -115,6 +117,17 @@ object SessionsReducer {
sessionState: SessionsState,
msg: ServerMessage.SessionSnapshot,
): Pair<SessionsState, List<Effect>> {
val approvalRequestId = msg.approvalRequestId
val approvalInfo = if (msg.pendingApproval && approvalRequestId != null) {
ApprovalInfo(
requestId = approvalRequestId,
sessionId = msg.sessionId.value,
tier = msg.approvalTier ?: "T2",
riskSummary = "unknown",
toolName = msg.approvalToolName,
preview = msg.approvalPreview,
)
} else null
val summary = SessionSummary(
id = msg.sessionId.value,
status = when {
@@ -126,6 +139,7 @@ object SessionsReducer {
lastEventAt = clock(),
currentStage = msg.currentStageId,
currentStageId = msg.currentStageId,
pendingApproval = approvalInfo,
)
val selected = sessionState.selectedId ?: msg.sessionId.value
return sessionState.copy(
@@ -134,6 +148,25 @@ object SessionsReducer {
) to emptyList()
}
private fun processApprovalRequiredMessage(
sessions: SessionsState,
msg: ServerMessage.ApprovalRequired,
): Pair<SessionsState, List<Effect>> {
val info = ApprovalInfo(
requestId = msg.requestId.value,
sessionId = msg.sessionId.value,
tier = msg.tier.name,
riskSummary = msg.riskSummary.level,
toolName = msg.toolName,
preview = msg.preview,
)
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(pendingApproval = info) else s
},
) to emptyList()
}
private fun processToolRejectedMessage(
clock: () -> Long,
sessions: SessionsState,
@@ -2,15 +2,10 @@ package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.ApprovalState
import com.correx.apps.tui.state.InputMode
import com.correx.core.approvals.Tier
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
@@ -88,23 +83,4 @@ class ApprovalReducerTest {
)
assertEquals(activeInfo, state.active)
}
@Test
fun `ServerEventReceived ApprovalRequired sets active approval`() {
val msg = ServerMessage.ApprovalRequired(
sessionId = SessionId("sess-1"),
requestId = ApprovalRequestId("req-2"),
tier = Tier.T2,
riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"),
toolName = "bash",
preview = "rm -rf /",
sequence = 1L,
sessionSequence = 1L,
)
val (state, effects) = reduce(action = Action.ServerEventReceived(msg))
assertEquals("req-2", state.active?.requestId)
assertEquals("sess-1", state.active?.sessionId)
assertEquals("bash", state.active?.toolName)
assertTrue(effects.isEmpty())
}
}
@@ -1,11 +1,14 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.core.approvals.Tier
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals
@@ -247,4 +250,87 @@ class SessionsReducerTest {
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("PAUSED awaiting approval", state.sessions[0].status)
}
@Test
fun `ApprovalRequired sets pendingApproval on matching session`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.ApprovalRequired(
sessionId = SessionId("s1"),
requestId = ApprovalRequestId("r1"),
tier = Tier.T2,
riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"),
toolName = "bash",
preview = "rm -rf /",
sequence = 1L,
sessionSequence = 1L,
)
val (state, effects) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("r1", state.sessions[0].pendingApproval?.requestId)
assertEquals("s1", state.sessions[0].pendingApproval?.sessionId)
assertEquals("bash", state.sessions[0].pendingApproval?.toolName)
assertTrue(effects.isEmpty())
}
@Test
fun `ApprovalRequired for unknown session is no-op`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.ApprovalRequired(
sessionId = SessionId("s2"),
requestId = ApprovalRequestId("r1"),
tier = Tier.T2,
riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"),
toolName = "bash",
preview = null,
sequence = 1L,
sessionSequence = 1L,
)
val (state, effects) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(s.sessions, state.sessions)
assertTrue(effects.isEmpty())
}
@Test
fun `SessionSnapshot with pendingApproval populates pendingApproval on summary`() {
val msg = ServerMessage.SessionSnapshot(
sessionId = SessionId("s1"),
workflowId = "wf",
status = "PAUSED",
currentStageId = null,
pauseReason = null,
pendingApproval = true,
approvalRequestId = "r9",
approvalTier = "T2",
approvalToolName = "bash",
approvalPreview = "preview text",
state = com.correx.apps.server.protocol.SessionStateDto("PAUSED", null, null),
pendingApprovals = emptyList(),
lastSequence = 1L,
lastSessionSequence = 1L,
)
val (state, _) = reduce(action = Action.ServerEventReceived(msg))
assertEquals("r9", state.sessions[0].pendingApproval?.requestId)
assertEquals("bash", state.sessions[0].pendingApproval?.toolName)
}
@Test
fun `SessionSnapshot without pendingApproval leaves pendingApproval null`() {
val msg = ServerMessage.SessionSnapshot(
sessionId = SessionId("s1"),
workflowId = "wf",
status = "ACTIVE",
currentStageId = null,
pauseReason = null,
pendingApproval = false,
approvalRequestId = null,
approvalTier = null,
approvalToolName = null,
approvalPreview = null,
state = com.correx.apps.server.protocol.SessionStateDto("PAUSED", null, null),
pendingApprovals = emptyList(),
lastSequence = 1L,
lastSessionSequence = 1L,
)
val (state, _) = reduce(action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].pendingApproval)
}
}