From 1c67993f5d68f639977dc0231702d6507f5bd907 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 25 May 2026 14:52:26 +0400 Subject: [PATCH] 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. --- .../apps/tui/reducer/ApprovalReducer.kt | 42 --------- .../apps/tui/reducer/SessionsReducer.kt | 33 +++++++ .../apps/tui/reducer/ApprovalReducerTest.kt | 24 ------ .../apps/tui/reducer/SessionsReducerTest.kt | 86 +++++++++++++++++++ 4 files changed, 119 insertions(+), 66 deletions(-) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt index dd936bd3..6f06f920 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt @@ -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() } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt index bdd069d6..7d53ffe6 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt @@ -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> { + 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> { + 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, diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt index c2faada2..63cd1e53 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt @@ -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()) - } } diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt index fe447092..37cb538d 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt @@ -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) + } }