From e5df09ef9305b9a7f52c125c834026cb23935ee0 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 25 May 2026 11:42:07 +0400 Subject: [PATCH] feat(tui-03): wire SnapshotPhaseReducer into RootReducer with replay and cursor tracking SnapshotComplete now flips snapshotPhase, replays buffered events through SnapshotPhaseReducer for dedup/cursor updates, then through sub-reducers. --- .../correx/apps/tui/reducer/RootReducer.kt | 28 ++++++++++++++++ .../apps/tui/reducer/RootReducerTest.kt | 32 ++++++++++++++++++- .../tui/reducer/SnapshotPhaseReducerTest.kt | 29 +++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) 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 88c89f34..60489bff 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 @@ -12,6 +12,34 @@ object RootReducer { state: TuiState, action: Action, clock: () -> Long = System::currentTimeMillis, + ): Pair> { + val phase = SnapshotPhaseReducer.process(state, action) + var current = phase.state + val allEffects = mutableListOf() + val isReplay = action is Action.ServerEventReceived && + action.message is ServerMessage.SnapshotComplete + for (a in phase.actionsToDispatch) { + if (isReplay) { + val inner = SnapshotPhaseReducer.process(current, a) + current = inner.state + for (ia in inner.actionsToDispatch) { + val (next, effects) = reduceThroughSubReducers(current, ia, clock) + current = next + allEffects += effects + } + } else { + val (next, effects) = reduceThroughSubReducers(current, a, clock) + current = next + allEffects += effects + } + } + return current to allEffects + } + + private fun reduceThroughSubReducers( + state: TuiState, + action: Action, + clock: () -> Long, ): Pair> { log.debug("action={}", action::class.simpleName) val quitEffects: List = if (action is Action.Quit) listOf(Effect.Quit) else emptyList() 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 ad09b380..1232c77d 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 @@ -47,7 +47,7 @@ class RootReducerTest { @Test fun `NavigateUp with sessions adjusts selected id`() { - val state0 = TuiState() + val state0 = TuiState(snapshotPhase = false) val startMsg = com.correx.apps.server.protocol.ServerMessage.SessionStarted( sessionId = com.correx.core.events.types.SessionId("s1"), workflowId = "wf", @@ -71,6 +71,36 @@ class RootReducerTest { assertEquals("s2", state3.sessions.selectedId) } + @Test + fun `SnapshotComplete flips snapshotPhase and replays buffered events in order`() { + val startMsg1 = com.correx.apps.server.protocol.ServerMessage.SessionStarted( + sessionId = com.correx.core.events.types.SessionId("s1"), + workflowId = "wf", + sequence = 1L, + sessionSequence = 1L, + ) + val startMsg2 = com.correx.apps.server.protocol.ServerMessage.SessionStarted( + sessionId = com.correx.core.events.types.SessionId("s2"), + workflowId = "wf", + sequence = 2L, + sessionSequence = 1L, + ) + val initial = TuiState( + snapshotPhase = true, + pendingEvents = listOf(startMsg1, startMsg2), + ) + val (result, _) = RootReducer.reduce( + initial, + Action.ServerEventReceived(com.correx.apps.server.protocol.ServerMessage.SnapshotComplete), + fixedClock, + ) + assertEquals(false, result.snapshotPhase) + assertTrue(result.pendingEvents.isEmpty()) + assertEquals(2, result.sessions.sessions.size) + assertEquals(1L, result.cursors["s1"]) + assertEquals(1L, result.cursors["s2"]) + } + @Test fun `AppendChar followed by Backspace returns to original buffer`() { val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab") diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducerTest.kt index 70d44381..8ed49ef7 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducerTest.kt @@ -1,5 +1,6 @@ package com.correx.apps.tui.reducer +import com.correx.apps.server.protocol.SessionStateDto import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.tui.input.Action import com.correx.apps.tui.state.TuiState @@ -31,4 +32,32 @@ class SnapshotPhaseReducerTest { assertTrue(allDispatched.isEmpty()) assertTrue(state.sessions.sessions.isEmpty()) } + + @Test + fun `during snapshotPhase SessionSnapshot installs cursor and is forwarded to sub-reducers`() { + val state = TuiState(snapshotPhase = true) + val snapshot = ServerMessage.SessionSnapshot( + sessionId = SessionId("s1"), + workflowId = "wf", + status = "ACTIVE", + currentStageId = null, + pauseReason = null, + pendingApproval = false, + approvalRequestId = null, + approvalTier = null, + approvalToolName = null, + approvalPreview = null, + state = SessionStateDto(status = "ACTIVE", currentStageId = null, pauseReason = null), + pendingApprovals = emptyList(), + lastSequence = 1L, + lastSessionSequence = 42L, + ) + val action = Action.ServerEventReceived(snapshot) + + val result = SnapshotPhaseReducer.process(state, action) + + assertEquals(42L, result.state.cursors["s1"]) + assertEquals(listOf(action), result.actionsToDispatch) + assertTrue(result.state.pendingEvents.isEmpty()) + } }