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.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage 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.input.Action
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.ApprovalState import com.correx.apps.tui.state.ApprovalState
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(ApprovalReducer::class.simpleName)
object ApprovalReducer { object ApprovalReducer {
fun reduce( fun reduce(
@@ -58,43 +53,6 @@ object ApprovalReducer {
approval to emptyList() 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() 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.PauseReason
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action 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.InputMode
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.SessionsState
@@ -107,6 +108,7 @@ object SessionsReducer {
is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions) is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions)
is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg) is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg)
is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg) is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg)
is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg)
else -> sessions to emptyList() else -> sessions to emptyList()
}.also { log.debug("processed server message: {}", msg) } }.also { log.debug("processed server message: {}", msg) }
@@ -115,6 +117,17 @@ object SessionsReducer {
sessionState: SessionsState, sessionState: SessionsState,
msg: ServerMessage.SessionSnapshot, msg: ServerMessage.SessionSnapshot,
): Pair<SessionsState, List<Effect>> { ): 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( val summary = SessionSummary(
id = msg.sessionId.value, id = msg.sessionId.value,
status = when { status = when {
@@ -126,6 +139,7 @@ object SessionsReducer {
lastEventAt = clock(), lastEventAt = clock(),
currentStage = msg.currentStageId, currentStage = msg.currentStageId,
currentStageId = msg.currentStageId, currentStageId = msg.currentStageId,
pendingApproval = approvalInfo,
) )
val selected = sessionState.selectedId ?: msg.sessionId.value val selected = sessionState.selectedId ?: msg.sessionId.value
return sessionState.copy( return sessionState.copy(
@@ -134,6 +148,25 @@ object SessionsReducer {
) to emptyList() ) 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( private fun processToolRejectedMessage(
clock: () -> Long, clock: () -> Long,
sessions: SessionsState, 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.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage 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.input.Action
import com.correx.apps.tui.state.ApprovalInfo import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.ApprovalState import com.correx.apps.tui.state.ApprovalState
import com.correx.apps.tui.state.InputMode 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.assertEquals
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@@ -88,23 +83,4 @@ class ApprovalReducerTest {
) )
assertEquals(activeInfo, state.active) 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 package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.PauseReason 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.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState 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.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@@ -247,4 +250,87 @@ class SessionsReducerTest {
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("PAUSED awaiting approval", state.sessions[0].status) 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)
}
} }