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.
This commit is contained in:
2026-05-25 17:18:10 +04:00
parent c8a599c64f
commit 09f47bf8e3
4 changed files with 101 additions and 1 deletions
@@ -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),
@@ -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<SessionsState, List<Effect>> {
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<SessionSummary> =
if (sessions.filter.isBlank()) sessions.sessions
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
@@ -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(
@@ -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(