From 09f47bf8e38c9bfd11e058382c36d548fba6f985 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 25 May 2026 17:18:10 +0400 Subject: [PATCH] fix(cleanup-04): wire ApproveActive/RejectActive and clear pendingApproval atomically The cleanup-04 spec's write side was missing: Action.ApproveActive and Action.RejectActive were dispatched by KeyResolver but had no reducer branch, making the approve/reject keybindings silent no-ops. STEER SubmitInput emitted an ApprovalResponse effect without clearing `pendingApproval` on the selected session, violating the spec invariant that the effect emission and the clear happen in the same tick. SessionsReducer now handles both actions via a private respondToSelectedApproval(sessions, decision, note) helper that reads state.selectedPendingApproval(), clears the field, and emits Effect.SendWs(ApprovalResponse(...)). InputReducer's STEER SubmitInput branch clears `pendingApproval` on the selected session in the same returned TuiState as the response effect. Adds four tests covering APPROVE, REJECT, no-pending no-op, and STEER atomicity. --- .../correx/apps/tui/reducer/InputReducer.kt | 12 ++++- .../apps/tui/reducer/SessionsReducer.kt | 28 +++++++++++ .../apps/tui/reducer/InputReducerTest.kt | 14 ++++++ .../apps/tui/reducer/SessionsReducerTest.kt | 48 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt index 8a708094..7b9781e1 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt @@ -55,7 +55,17 @@ object InputReducer { val text = state.inputBuffer.trim() val active = state.selectedPendingApproval() if (active != null && text.isNotBlank()) { - state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to listOf( + val selectedId = state.sessions.selectedId + val clearedSessions = state.sessions.copy( + sessions = state.sessions.sessions.map { s -> + if (s.id == selectedId) s.copy(pendingApproval = null) else s + }, + ) + state.copy( + inputMode = InputMode.ROUTER, + inputBuffer = "", + sessions = clearedSessions, + ) to listOf( Effect.SendWs( ClientMessage.ApprovalResponse( requestId = ApprovalRequestId(active.requestId), 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 71214e91..28a62e84 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 @@ -1,5 +1,6 @@ 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.PauseReason import com.correx.apps.server.protocol.ServerMessage @@ -11,6 +12,7 @@ import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.ToolDisplayStatus import com.correx.apps.tui.state.TuiEventEntry import com.correx.apps.tui.state.TuiToolRecord +import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.SessionId import org.slf4j.LoggerFactory import java.time.Instant @@ -62,10 +64,36 @@ object SessionsReducer { } } + is Action.ApproveActive -> respondToSelectedApproval(sessions, ApprovalDecision.APPROVE, note = null) + is Action.RejectActive -> respondToSelectedApproval(sessions, ApprovalDecision.REJECT, note = null) + is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock) else -> sessions to emptyList() } + private fun respondToSelectedApproval( + sessions: SessionsState, + decision: ApprovalDecision, + note: String?, + ): Pair> { + val selectedId = sessions.selectedId ?: return sessions to emptyList() + val selected = sessions.sessions.firstOrNull { it.id == selectedId } ?: return sessions to emptyList() + val pending = selected.pendingApproval ?: return sessions to emptyList() + val updated = sessions.copy( + sessions = sessions.sessions.map { s -> + if (s.id == selectedId) s.copy(pendingApproval = null) else s + }, + ) + val effect = Effect.SendWs( + ClientMessage.ApprovalResponse( + requestId = ApprovalRequestId(pending.requestId), + decision = decision, + steeringNote = note, + ), + ) + return updated to listOf(effect) + } + private fun filteredSessions(sessions: SessionsState): List = if (sessions.filter.isBlank()) sessions.sessions else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) } diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt index 52f76dbf..2d1519d7 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt @@ -146,6 +146,20 @@ class InputReducerTest { assertEquals("req-1", msg.requestId.value) } + @Test + fun `SubmitInput in STEER mode clears pendingApproval atomically with the response effect`() { + val (state, effects) = reduce( + state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "go left"), + action = Action.SubmitInput, + ) + assertEquals(null, state.sessions.sessions[0].pendingApproval) + assertEquals(1, effects.size) + val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse + assertEquals(ApprovalDecision.STEER, msg.decision) + assertEquals("go left", msg.steeringNote) + assertEquals("req-1", msg.requestId.value) + } + @Test fun `SubmitInput in FILTER mode clears input with no effects`() { val (state, effects) = reduce( 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 55a2fe69..ddff612d 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,5 +1,6 @@ package com.correx.apps.tui.reducer +import com.correx.apps.server.protocol.ApprovalDecision import com.correx.apps.server.protocol.ApprovalDto import com.correx.apps.server.protocol.ClientMessage import com.correx.apps.server.protocol.PauseReason @@ -8,6 +9,7 @@ import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.SessionStateDto import com.correx.apps.tui.input.Action import com.correx.apps.tui.state.InputMode +import com.correx.apps.tui.state.ApprovalInfo import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionsState import com.correx.core.approvals.Tier @@ -306,6 +308,52 @@ class SessionsReducerTest { assertEquals("PAUSED awaiting approval", state.sessions[0].status) } + private fun pendingApproval(requestId: String = "req-1", sessionId: String = "s1") = ApprovalInfo( + requestId = requestId, + sessionId = sessionId, + tier = "T2", + riskSummary = "HIGH", + toolName = "bash", + preview = null, + ) + + @Test + fun `ApproveActive emits ApprovalResponse APPROVE and clears pendingApproval`() { + val s = SessionsState( + sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())), + selectedId = "s1", + ) + val (state, effects) = reduce(sessions = s, action = Action.ApproveActive) + assertEquals(null, state.sessions[0].pendingApproval) + assertEquals(1, effects.size) + val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse + assertEquals(ApprovalDecision.APPROVE, msg.decision) + assertEquals("req-1", msg.requestId.value) + assertEquals(null, msg.steeringNote) + } + + @Test + fun `RejectActive emits ApprovalResponse REJECT and clears pendingApproval`() { + val s = SessionsState( + sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())), + selectedId = "s1", + ) + val (state, effects) = reduce(sessions = s, action = Action.RejectActive) + assertEquals(null, state.sessions[0].pendingApproval) + assertEquals(1, effects.size) + val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse + assertEquals(ApprovalDecision.REJECT, msg.decision) + assertEquals("req-1", msg.requestId.value) + } + + @Test + fun `ApproveActive is no-op when selected session has no pendingApproval`() { + val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") + val (state, effects) = reduce(sessions = s, action = Action.ApproveActive) + assertEquals(s, state) + assertTrue(effects.isEmpty()) + } + @Test fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() { val msg = ServerMessage.SessionSnapshot(