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 deleted file mode 100644 index 6f06f920..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ApprovalDecision -import com.correx.apps.server.protocol.ClientMessage -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.ApprovalState -import com.correx.apps.tui.state.InputMode -import com.correx.core.events.types.ApprovalRequestId - -object ApprovalReducer { - fun reduce( - approval: ApprovalState, - inputMode: InputMode, - action: Action, - ): Pair> = when (action) { - is Action.ApproveActive -> { - val active = approval.active - if (active != null) { - ApprovalState(active = null) to listOf( - Effect.SendWs( - ClientMessage.ApprovalResponse( - requestId = ApprovalRequestId(active.requestId), - decision = ApprovalDecision.APPROVE, - steeringNote = null, - ), - ), - ) - } else { - approval to emptyList() - } - } - - is Action.RejectActive -> { - val active = approval.active - if (active != null) { - ApprovalState(active = null) to listOf( - Effect.SendWs( - ClientMessage.ApprovalResponse( - requestId = ApprovalRequestId(active.requestId), - decision = ApprovalDecision.REJECT, - steeringNote = null, - ), - ), - ) - } else { - approval to emptyList() - } - } - - is Action.SubmitInput -> if (inputMode == InputMode.STEER) { - ApprovalState(active = null) to emptyList() - } else { - approval to emptyList() - } - - else -> approval to emptyList() - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt index 87a01626..a11fbeeb 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt @@ -2,7 +2,6 @@ package com.correx.apps.tui.reducer import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.ApprovalState import com.correx.apps.tui.state.TuiState import org.slf4j.LoggerFactory @@ -46,33 +45,22 @@ object RootReducer { val quitEffects: List = if (action is Action.Quit) listOf(Effect.Quit) else emptyList() val prevInputMode = state.inputMode val prevInputBuffer = state.inputBuffer - val approvalAction = if ( - action is Action.ServerEventReceived && - action.message is ServerMessage.SessionSnapshot && - action.message.sessionId.value != state.sessions.selectedId - ) Action.NoOp else action log.debug("input state before reducers={}", state.input) val (afterInput, ie) = InputReducer.reduce(state, action) log.debug("sessions state before reducers={}", state.sessions) val (sessions, se) = SessionsReducer.reduce(afterInput.sessions, prevInputMode, prevInputBuffer, action, clock) - val selectedId = afterInput.sessions.selectedId - val bridgedApproval = ApprovalState( - active = afterInput.sessions.sessions.find { it.id == selectedId }?.pendingApproval, - ) - val (approval, ae) = ApprovalReducer.reduce(bridgedApproval, prevInputMode, approvalAction) - val approvalJustArrived = approval.active != null && bridgedApproval.active == null - val sessionsWithApproval = sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == selectedId) s.copy(pendingApproval = approval.active) else s - }, - ) log.debug("connection state before reducers={}", state.connection) val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action) log.debug("provider state before reducers={}", state.provider) val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action) + + val selectedPending = sessions.sessions.firstOrNull { it.id == sessions.selectedId }?.pendingApproval + val prevSelectedPending = afterInput.sessions.sessions.firstOrNull { it.id == afterInput.sessions.selectedId }?.pendingApproval + val approvalJustArrived = selectedPending != null && prevSelectedPending == null + val withSubReducers = afterInput.copy( - sessions = sessionsWithApproval, + sessions = sessions, connection = connection, provider = provider, approvalOverlayVisible = if (approvalJustArrived) true else afterInput.approvalOverlayVisible, @@ -90,6 +78,6 @@ object RootReducer { else -> withSubReducers } - return final to (quitEffects + ie + se + ae + ce + pe) + return final to (quitEffects + ie + se + ce + pe) } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ApprovalState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ApprovalState.kt deleted file mode 100644 index c5bc4e24..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ApprovalState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.correx.apps.tui.state - -data class ApprovalState( - val active: ApprovalInfo? = null, -) 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 deleted file mode 100644 index 63cd1e53..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ApprovalDecision -import com.correx.apps.server.protocol.ClientMessage -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 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 ApprovalReducerTest { - - private val activeInfo = ApprovalInfo( - requestId = "req-1", - sessionId = "sess-1", - tier = "HIGH", - riskSummary = "HIGH", - toolName = "bash", - preview = null, - ) - - private fun reduce( - approval: ApprovalState = ApprovalState(), - inputMode: InputMode = InputMode.ROUTER, - action: Action, - ) = ApprovalReducer.reduce(approval, inputMode, action) - - @Test - fun `ApproveActive emits APPROVE response and clears active`() { - val (state, effects) = reduce( - approval = ApprovalState(active = activeInfo), - action = Action.ApproveActive, - ) - assertNull(state.active) - assertEquals(1, effects.size) - val effect = effects[0] as Effect.SendWs - val msg = effect.message as ClientMessage.ApprovalResponse - assertEquals(ApprovalDecision.APPROVE, msg.decision) - assertEquals("req-1", msg.requestId.value) - assertNull(msg.steeringNote) - } - - @Test - fun `ApproveActive with no active approval is no-op`() { - val (state, effects) = reduce(action = Action.ApproveActive) - assertNull(state.active) - assertTrue(effects.isEmpty()) - } - - @Test - fun `RejectActive emits REJECT response and clears active`() { - val (state, effects) = reduce( - approval = ApprovalState(active = activeInfo), - action = Action.RejectActive, - ) - assertNull(state.active) - assertEquals(1, effects.size) - val effect = effects[0] as Effect.SendWs - val msg = effect.message as ClientMessage.ApprovalResponse - assertEquals(ApprovalDecision.REJECT, msg.decision) - } - - @Test - fun `SubmitInput in SteeringNote mode clears active approval`() { - val (state, effects) = reduce( - approval = ApprovalState(active = activeInfo), - inputMode = InputMode.STEER, - action = Action.SubmitInput, - ) - assertNull(state.active) - assertTrue(effects.isEmpty()) - } - - @Test - fun `SubmitInput in non-SteeringNote mode is no-op`() { - val (state, _) = reduce( - approval = ApprovalState(active = activeInfo), - inputMode = InputMode.NAVIGATE, - action = Action.SubmitInput, - ) - assertEquals(activeInfo, state.active) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt index 7597cecb..44a00b08 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt @@ -1,10 +1,17 @@ package com.correx.apps.tui.reducer 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.ConnectionState import com.correx.apps.tui.state.InputMode +import com.correx.apps.tui.state.SessionSummary +import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.TuiState +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.assertTrue import org.junit.jupiter.api.Test @@ -138,4 +145,48 @@ class RootReducerTest { val (s2, _) = RootReducer.reduce(s1, Action.Backspace, fixedClock) assertEquals("ab", s2.inputBuffer) } + + private fun approvalRequiredMsg(sessionId: String, requestId: String = "r1") = + ServerMessage.ApprovalRequired( + sessionId = SessionId(sessionId), + requestId = ApprovalRequestId(requestId), + tier = Tier.T2, + riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"), + toolName = "bash", + preview = null, + sequence = 1L, + sessionSequence = 1L, + ) + + private fun session(id: String) = SessionSummary(id = id, status = "ACTIVE", workflowId = "wf", lastEventAt = 0L) + + @Test + fun `ApprovalRequired on selected session auto-opens approvalOverlayVisible`() { + val initial = TuiState( + snapshotPhase = false, + sessions = SessionsState(sessions = listOf(session("s1")), selectedId = "s1"), + ) + val (state, _) = RootReducer.reduce( + initial, Action.ServerEventReceived(approvalRequiredMsg("s1")), fixedClock, + ) + assertEquals(true, state.approvalOverlayVisible) + assertEquals("r1", state.sessions.sessions[0].pendingApproval?.requestId) + } + + @Test + fun `ApprovalRequired on non-selected session does not open approvalOverlayVisible`() { + val initial = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1"), session("s2")), + selectedId = "s1", + ), + ) + val (state, _) = RootReducer.reduce( + initial, Action.ServerEventReceived(approvalRequiredMsg("s2")), fixedClock, + ) + assertEquals(false, state.approvalOverlayVisible) + assertEquals(null, state.sessions.sessions.find { it.id == "s1" }?.pendingApproval) + assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId) + } }