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