feat(server+tui): event bridge — live streaming and historical replay

- SessionEventBridge: replaySnapshot() sends all historical events on connect;
  streamLive(sessionId) subscribes to live event flow per session
- DomainEventMapper: maps 14 domain event types to ServerMessage; reads
  InferenceCompleted response text from ArtifactStore with empty-string fallback
- GlobalStreamHandler: wires bridge on connect for replay, launches per-session
  live-stream job on StartSession, cancels all jobs on disconnect; removes
  ProtocolError("ping") heartbeat hack
- SessionStreamHandler: fixes replay to use DomainEventMapper instead of
  emitting SessionStarted for every event; injects real artifactStore;
  removes ProtocolError("ping") heartbeat; adds structured logging throughout
- Main: startup log block showing port, model config, sandbox, stores, tools,
  providers
- SessionsReducer: StageCompleted/StageFailed now clears currentStage; adds
  5 missing tests (InferenceCompleted, StageStarted, ToolCompleted, SessionPaused)
- RouterPanel: new TUI widget rendering active session's lastResponseText
- TuiWsClient: remove println that was polluting the TUI on WS connect failure
This commit is contained in:
2026-05-19 00:03:53 +04:00
parent 03615dc5b9
commit f32d400138
14 changed files with 802 additions and 103 deletions
@@ -4,6 +4,7 @@ import com.correx.apps.tui.components.activeSessionWidget
import com.correx.apps.tui.components.approvalPanelWidget
import com.correx.apps.tui.components.filteredSessions
import com.correx.apps.tui.components.inputBarWidget
import com.correx.apps.tui.components.routerPanelWidget
import com.correx.apps.tui.components.sessionListWidget
import com.correx.apps.tui.components.statusBarWidget
import com.correx.apps.tui.input.Action
@@ -39,6 +40,7 @@ private const val STATUS_HEIGHT = 3
private const val ACTIVE_SESSION_HEIGHT = 5
private const val APPROVAL_HEIGHT = 6
private const val INPUT_HEIGHT = 3
private const val ROUTER_HEIGHT = 5
private const val KEYBINDS_HEIGHT = 3
private const val KEYBINDS_TEXT = "n new c cancel a approve r reject s steer / filter q quit"
@@ -105,6 +107,7 @@ private fun render(frame: Frame, state: TuiState, listState: ListState) {
add(Constraint.length(STATUS_HEIGHT))
add(Constraint.fill())
add(Constraint.length(ACTIVE_SESSION_HEIGHT))
add(Constraint.length(ROUTER_HEIGHT))
if (hasApproval) add(Constraint.length(APPROVAL_HEIGHT))
if (hasInput) add(Constraint.length(INPUT_HEIGHT))
add(Constraint.length(KEYBINDS_HEIGHT))
@@ -126,6 +129,8 @@ private fun render(frame: Frame, state: TuiState, listState: ListState) {
frame.renderWidget(activeSessionWidget(selectedSession), rects[idx++])
frame.renderWidget(routerPanelWidget(selectedSession), rects[idx++])
if (hasApproval) {
frame.renderWidget(approvalPanelWidget(state.approval.active!!), rects[idx++])
}
@@ -0,0 +1,24 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
fun routerPanelWidget(session: SessionSummary?): Paragraph {
val dimStyle = Style.create().dim().gray()
val text = session?.lastResponseText?.takeIf { it.isNotEmpty() }?.let { responseText ->
Text.from(responseText.lineSequence().map { Line.from(Span.styled(it, dimStyle)) }.toList())
} ?: Text.from(Line.from(Span.styled("(no response)", dimStyle)))
val block = Block.builder()
.title("router")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(text).block(block).build()
}
@@ -98,8 +98,16 @@ object SessionsReducer {
}
},
)
is ServerMessage.StageCompleted -> touchSession(sessions, msg.sessionId.value, null, clock)
is ServerMessage.StageFailed -> touchSession(sessions, msg.sessionId.value, null, clock)
is ServerMessage.StageCompleted -> sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s
},
)
is ServerMessage.StageFailed -> sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s
},
)
is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
is ServerMessage.ToolCompleted -> sessions.copy(
sessions = sessions.sessions.map { s ->
@@ -134,15 +142,11 @@ object SessionsReducer {
private fun touchSession(
sessions: SessionsState,
sessionId: String,
status: String?,
status: String,
clock: () -> Long,
): SessionsState = sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == sessionId) {
if (status != null) s.copy(status = status, lastEventAt = clock()) else s.copy(lastEventAt = clock())
} else {
s
}
if (s.id == sessionId) s.copy(status = status, lastEventAt = clock()) else s
},
)
}
@@ -73,7 +73,7 @@ class TuiWsClient(
}
}
}
}.onFailure { println("WS connect failed: ${it.message}") }
}.onFailure { }
session = null
_connection.tryEmit(ConnectionEvent.Disconnected)
attempt++
@@ -1,5 +1,6 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.InputMode
@@ -124,6 +125,34 @@ class SessionsReducerTest {
assertEquals("FAILED", state.sessions[0].status)
}
@Test
fun `ServerEventReceived StageCompleted clears currentStage`() {
val s = SessionsState(
sessions = listOf(session("s1").copy(currentStage = "stage-1")),
selectedId = "s1",
)
val msg = ServerMessage.StageCompleted(sessionId = SessionId("s1"), stageId = StageId("stage-1"))
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage)
assertEquals(1000L, state.sessions[0].lastEventAt)
}
@Test
fun `ServerEventReceived StageFailed clears currentStage`() {
val s = SessionsState(
sessions = listOf(session("s1").copy(currentStage = "stage-1")),
selectedId = "s1",
)
val msg = ServerMessage.StageFailed(
sessionId = SessionId("s1"),
stageId = StageId("stage-1"),
reason = "error",
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage)
assertEquals(1000L, state.sessions[0].lastEventAt)
}
@Test
fun `CancelSelectedSession emits CancelSession effect`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
@@ -140,4 +169,54 @@ class SessionsReducerTest {
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
assertTrue(effects.isEmpty())
}
@Test
fun `ServerEventReceived InferenceCompleted updates lastOutput and lastResponseText`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.InferenceCompleted(
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "summary", responseText = "full response",
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("summary", state.sessions[0].lastOutput)
assertEquals("full response", state.sessions[0].lastResponseText)
assertEquals(1000L, state.sessions[0].lastEventAt)
}
@Test
fun `ServerEventReceived InferenceCompleted with empty responseText stores null`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.InferenceCompleted(
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "", responseText = "",
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].lastResponseText)
}
@Test
fun `ServerEventReceived StageStarted sets currentStage`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.StageStarted(sessionId = SessionId("s1"), stageId = StageId("stage-1"))
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("stage-1", state.sessions[0].currentStage)
assertEquals(1000L, state.sessions[0].lastEventAt)
}
@Test
fun `ServerEventReceived ToolCompleted updates lastOutput`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.ToolCompleted(sessionId = SessionId("s1"), toolName = "file_write", outputSummary = "wrote 3 lines")
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("file_write: wrote 3 lines", state.sessions[0].lastOutput)
assertEquals(1000L, state.sessions[0].lastEventAt)
}
@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 (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("PAUSED awaiting approval", state.sessions[0].status)
}
}