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 text = state.inputBuffer.trim()
|
||||||
val active = state.selectedPendingApproval()
|
val active = state.selectedPendingApproval()
|
||||||
if (active != null && text.isNotBlank()) {
|
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(
|
Effect.SendWs(
|
||||||
ClientMessage.ApprovalResponse(
|
ClientMessage.ApprovalResponse(
|
||||||
requestId = ApprovalRequestId(active.requestId),
|
requestId = ApprovalRequestId(active.requestId),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.apps.tui.reducer
|
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.ClientMessage
|
||||||
import com.correx.apps.server.protocol.PauseReason
|
import com.correx.apps.server.protocol.PauseReason
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
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.ToolDisplayStatus
|
||||||
import com.correx.apps.tui.state.TuiEventEntry
|
import com.correx.apps.tui.state.TuiEventEntry
|
||||||
import com.correx.apps.tui.state.TuiToolRecord
|
import com.correx.apps.tui.state.TuiToolRecord
|
||||||
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import java.time.Instant
|
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)
|
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
|
||||||
else -> sessions to emptyList()
|
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> =
|
private fun filteredSessions(sessions: SessionsState): List<SessionSummary> =
|
||||||
if (sessions.filter.isBlank()) sessions.sessions
|
if (sessions.filter.isBlank()) sessions.sessions
|
||||||
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
|
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
|
||||||
|
|||||||
@@ -146,6 +146,20 @@ class InputReducerTest {
|
|||||||
assertEquals("req-1", msg.requestId.value)
|
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
|
@Test
|
||||||
fun `SubmitInput in FILTER mode clears input with no effects`() {
|
fun `SubmitInput in FILTER mode clears input with no effects`() {
|
||||||
val (state, effects) = reduce(
|
val (state, effects) = reduce(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.apps.tui.reducer
|
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.ApprovalDto
|
||||||
import com.correx.apps.server.protocol.ClientMessage
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
import com.correx.apps.server.protocol.PauseReason
|
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.server.protocol.SessionStateDto
|
||||||
import com.correx.apps.tui.input.Action
|
import com.correx.apps.tui.input.Action
|
||||||
import com.correx.apps.tui.state.InputMode
|
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.SessionSummary
|
||||||
import com.correx.apps.tui.state.SessionsState
|
import com.correx.apps.tui.state.SessionsState
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
@@ -306,6 +308,52 @@ class SessionsReducerTest {
|
|||||||
assertEquals("PAUSED awaiting approval", state.sessions[0].status)
|
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
|
@Test
|
||||||
fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() {
|
fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() {
|
||||||
val msg = ServerMessage.SessionSnapshot(
|
val msg = ServerMessage.SessionSnapshot(
|
||||||
|
|||||||
Reference in New Issue
Block a user