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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user