feat(server-01): extend wire protocol with sequence cursors and SnapshotComplete

Every event-derived ServerMessage gains sequence (global) and sessionSequence
(per-session) cursor fields. SessionSnapshot adds lastSequence and
lastSessionSequence at projection-read time. SnapshotComplete data object
terminates the snapshot phase and is registered in the polymorphic module.
replaySnapshot() now emits SnapshotComplete after all SessionSnapshot messages.
SessionStarted audit deferred with TODO comment. DomainEventMapper propagates
both cursors to all mapped messages including InferenceCompleted.
This commit is contained in:
2026-05-24 21:47:30 +04:00
parent fc7b879891
commit 0cfb784187
14 changed files with 569 additions and 105 deletions
@@ -98,6 +98,8 @@ class ApprovalReducerTest {
riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"),
toolName = "bash",
preview = "rm -rf /",
sequence = 1L,
sessionSequence = 1L,
)
val (state, effects) = reduce(action = Action.ServerEventReceived(msg))
assertEquals("req-2", state.active?.requestId)
@@ -43,11 +43,15 @@ class RootReducerTest {
val startMsg = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s1"),
workflowId = "wf",
sequence = 1L,
sessionSequence = 1L,
)
val (state1, _) = RootReducer.reduce(state0, Action.ServerEventReceived(startMsg), fixedClock)
val startMsg2 = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s2"),
workflowId = "wf",
sequence = 2L,
sessionSequence = 1L,
)
val (state2, _) = RootReducer.reduce(state1, Action.ServerEventReceived(startMsg2), fixedClock)
// selected is s1 (first); navigating down should move to s2
@@ -91,7 +91,7 @@ class SessionsReducerTest {
@Test
fun `ServerEventReceived SessionStarted appends session and sets selection`() {
val msg = ServerMessage.SessionStarted(sessionId = SessionId("s1"), workflowId = "wf1")
val msg = ServerMessage.SessionStarted(sessionId = SessionId("s1"), workflowId = "wf1", sequence = 1L, sessionSequence = 1L)
val (state, effects) = reduce(action = Action.ServerEventReceived(msg))
assertEquals(1, state.sessions.size)
assertEquals("s1", state.sessions[0].id)
@@ -103,7 +103,7 @@ class SessionsReducerTest {
@Test
fun `ServerEventReceived SessionStarted preserves existing selection`() {
val existing = SessionsState(sessions = listOf(session("existing")), selectedId = "existing")
val msg = ServerMessage.SessionStarted(sessionId = SessionId("new"), workflowId = "wf")
val msg = ServerMessage.SessionStarted(sessionId = SessionId("new"), workflowId = "wf", sequence = 1L, sessionSequence = 1L)
val (state, _) = reduce(sessions = existing, action = Action.ServerEventReceived(msg))
assertEquals("existing", state.selectedId)
assertEquals(2, state.sessions.size)
@@ -112,7 +112,7 @@ class SessionsReducerTest {
@Test
fun `ServerEventReceived SessionCompleted updates status to COMPLETED`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.SessionCompleted(sessionId = SessionId("s1"))
val msg = ServerMessage.SessionCompleted(sessionId = SessionId("s1"), sequence = 1L, sessionSequence = 1L)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("COMPLETED", state.sessions[0].status)
assertEquals(1000L, state.sessions[0].lastEventAt)
@@ -121,7 +121,7 @@ class SessionsReducerTest {
@Test
fun `ServerEventReceived SessionFailed updates status to FAILED`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.SessionFailed(sessionId = SessionId("s1"), reason = "oops")
val msg = ServerMessage.SessionFailed(sessionId = SessionId("s1"), reason = "oops", sequence = 1L, sessionSequence = 1L)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("FAILED", state.sessions[0].status)
}
@@ -136,6 +136,8 @@ class SessionsReducerTest {
sessionId = SessionId("s1"),
stageId = StageId("stage-1"),
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage)
@@ -153,6 +155,8 @@ class SessionsReducerTest {
stageId = StageId("stage-1"),
reason = "error",
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage)
@@ -183,6 +187,8 @@ class SessionsReducerTest {
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "summary", responseText = "full response",
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("summary", state.sessions[0].lastOutput)
@@ -197,6 +203,8 @@ class SessionsReducerTest {
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "", responseText = "",
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].lastResponseText)
@@ -209,6 +217,8 @@ class SessionsReducerTest {
sessionId = SessionId("s1"),
stageId = StageId("stage-1"),
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("stage-1", state.sessions[0].currentStage)
@@ -223,6 +233,8 @@ class SessionsReducerTest {
toolName = "file_write",
outputSummary = "wrote 3 lines",
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("file_write: wrote 3 lines", state.sessions[0].lastOutput)
@@ -232,7 +244,7 @@ class SessionsReducerTest {
@Test
fun `ServerEventReceived SessionPaused with APPROVAL_PENDING sets status label`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.SessionPaused(sessionId = SessionId("s1"), reason = PauseReason.APPROVAL_PENDING)
val msg = ServerMessage.SessionPaused(sessionId = SessionId("s1"), reason = PauseReason.APPROVAL_PENDING, sequence = 1L, sessionSequence = 1L)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("PAUSED awaiting approval", state.sessions[0].status)
}