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:
@@ -43,6 +43,8 @@ dependencies {
|
||||
implementation "io.ktor:ktor-server-status-pages:$ktor_version"
|
||||
implementation "io.ktor:ktor-server-call-logging:$ktor_version"
|
||||
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version"
|
||||
|
||||
implementation "org.apache.logging.log4j:log4j-core:2.24.1"
|
||||
implementation "org.apache.logging.log4j:log4j-slf4j2-impl:2.24.1"
|
||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||
|
||||
@@ -35,8 +35,11 @@ import com.correx.infrastructure.tools.ToolConfig
|
||||
import com.correx.core.events.EventDispatcher
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.netty.Netty
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.Paths
|
||||
|
||||
private val log = LoggerFactory.getLogger("com.correx.apps.server.Main")
|
||||
|
||||
fun main() {
|
||||
val artifactStore = InfrastructureModule.createArtifactStore()
|
||||
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore))
|
||||
@@ -47,8 +50,6 @@ fun main() {
|
||||
val toolRegistry = InfrastructureModule.createToolRegistry(buildToolConfig(artifactStore, sandboxRoot))
|
||||
val toolExecutor = InfrastructureModule.createToolExecutor(
|
||||
registry = toolRegistry,
|
||||
approvalEngine = approvalEngine,
|
||||
eventStore = eventStore,
|
||||
eventDispatcher = EventDispatcher(eventStore),
|
||||
workDir = sandboxRoot,
|
||||
)
|
||||
@@ -72,10 +73,26 @@ fun main() {
|
||||
val module = ServerModule(
|
||||
orchestrator = orchestrator,
|
||||
eventStore = eventStore,
|
||||
artifactStore = artifactStore,
|
||||
sessionRepository = repositories.sessionRepository,
|
||||
workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()),
|
||||
providerRegistry = infraRegistry.asServerRegistry(),
|
||||
)
|
||||
val modelId = System.getenv("CORREX_MODEL_ID") ?: "default"
|
||||
val modelPath = System.getenv("CORREX_MODEL_PATH") ?: "(not set)"
|
||||
val llamaUrl = System.getenv("CORREX_LLAMA_URL") ?: "http://127.0.0.1:10000"
|
||||
log.info("=== correx server starting ===")
|
||||
log.info(" port : 8080")
|
||||
log.info(" model id : {}", modelId)
|
||||
log.info(" model path : {}", modelPath)
|
||||
log.info(" llama url : {}", llamaUrl)
|
||||
log.info(" sandbox root : {}", sandboxRoot)
|
||||
log.info(" event store : {}", eventStore::class.simpleName)
|
||||
log.info(" artifact store: {}", artifactStore::class.simpleName)
|
||||
log.info(" tools : {}", toolRegistry.all().map { it.name })
|
||||
log.info(" providers : {} registered", infraRegistry.listAll().size)
|
||||
log.info("==============================")
|
||||
|
||||
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.correx.apps.server
|
||||
|
||||
import com.correx.apps.server.registry.ProviderRegistry
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
@@ -9,6 +10,7 @@ import com.correx.core.sessions.DefaultSessionRepository
|
||||
data class ServerModule(
|
||||
val orchestrator: DefaultSessionOrchestrator,
|
||||
val eventStore: EventStore,
|
||||
val artifactStore: ArtifactStore,
|
||||
val sessionRepository: DefaultSessionRepository,
|
||||
val workflowRegistry: WorkflowRegistry,
|
||||
val providerRegistry: ProviderRegistry,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.PauseReason
|
||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceStartedEvent
|
||||
import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
|
||||
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
|
||||
suspend fun map(event: StoredEvent): ServerMessage? =
|
||||
domainEventToServerMessage(event, artifactStore)
|
||||
}
|
||||
|
||||
private object NoopArtifactStore : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? = null
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
suspend fun domainEventToServerMessage(event: StoredEvent, artifactStore: ArtifactStore): ServerMessage? =
|
||||
when (val p = event.payload) {
|
||||
is WorkflowStartedEvent -> mapWorkflowStarted(p)
|
||||
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(sessionId = p.sessionId)
|
||||
is WorkflowFailedEvent -> ServerMessage.SessionFailed(sessionId = p.sessionId, reason = p.reason)
|
||||
is TransitionExecutedEvent -> ServerMessage.StageStarted(sessionId = p.sessionId, stageId = p.to)
|
||||
is StageCompletedEvent -> ServerMessage.StageCompleted(sessionId = p.sessionId, stageId = p.stageId)
|
||||
is StageFailedEvent -> ServerMessage.StageFailed(
|
||||
sessionId = p.sessionId, stageId = p.stageId, reason = p.reason,
|
||||
)
|
||||
is OrchestrationPausedEvent -> mapOrchestrationPaused(p)
|
||||
is InferenceStartedEvent -> ServerMessage.InferenceStarted(sessionId = p.sessionId, stageId = p.stageId)
|
||||
is InferenceCompletedEvent -> mapInferenceCompleted(p, artifactStore)
|
||||
is InferenceTimeoutEvent -> ServerMessage.InferenceTimedOut(
|
||||
sessionId = p.sessionId, stageId = p.stageId, elapsedMs = p.timeoutMs,
|
||||
)
|
||||
is ToolInvocationRequestedEvent -> ServerMessage.ToolStarted(
|
||||
sessionId = p.sessionId, toolName = p.toolName, tier = p.tier,
|
||||
)
|
||||
is ToolExecutionCompletedEvent -> ServerMessage.ToolCompleted(
|
||||
sessionId = p.sessionId, toolName = p.toolName, outputSummary = p.receipt.outputSummary,
|
||||
)
|
||||
is ToolExecutionFailedEvent -> ServerMessage.ToolFailed(
|
||||
sessionId = p.sessionId, toolName = p.toolName, reason = p.reason,
|
||||
)
|
||||
is ToolExecutionRejectedEvent -> ServerMessage.ToolRejected(
|
||||
sessionId = p.sessionId, toolName = p.toolName, reason = p.reason,
|
||||
)
|
||||
is ApprovalRequestedEvent -> mapApprovalRequested(p)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mapWorkflowStarted(p: WorkflowStartedEvent): ServerMessage =
|
||||
ServerMessage.SessionStarted(sessionId = p.sessionId, workflowId = p.startStageId.value)
|
||||
|
||||
private fun mapOrchestrationPaused(p: OrchestrationPausedEvent): ServerMessage {
|
||||
val reason = if (p.reason == "APPROVAL_PENDING") PauseReason.APPROVAL_PENDING else PauseReason.USER_REQUESTED
|
||||
return ServerMessage.SessionPaused(sessionId = p.sessionId, reason = reason)
|
||||
}
|
||||
|
||||
private suspend fun mapInferenceCompleted(p: InferenceCompletedEvent, artifactStore: ArtifactStore): ServerMessage {
|
||||
val text = runCatching {
|
||||
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
|
||||
}.getOrElse { "" }
|
||||
return ServerMessage.InferenceCompleted(
|
||||
sessionId = p.sessionId, stageId = p.stageId, outputSummary = text, responseText = text,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapApprovalRequested(p: ApprovalRequestedEvent): ServerMessage =
|
||||
ServerMessage.ApprovalRequired(
|
||||
sessionId = p.sessionId,
|
||||
requestId = p.requestId,
|
||||
tier = p.tier,
|
||||
riskSummary = RiskSummaryDto("unknown", emptyList(), ""),
|
||||
toolName = null,
|
||||
preview = null,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
class SessionEventBridge(
|
||||
private val eventStore: EventStore,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val send: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
suspend fun replaySnapshot() {
|
||||
eventStore.allEvents().toList()
|
||||
.mapNotNull { domainEventToServerMessage(it, artifactStore) }
|
||||
.forEach { send(it) }
|
||||
}
|
||||
|
||||
suspend fun streamLive(sessionId: SessionId) {
|
||||
eventStore.subscribe(sessionId).collect { event ->
|
||||
domainEventToServerMessage(event, artifactStore)?.let { send(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.correx.apps.server.ws
|
||||
|
||||
import com.correx.apps.server.ServerModule
|
||||
import com.correx.apps.server.bridge.SessionEventBridge
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||
import com.correx.apps.server.protocol.ProviderHealthDto
|
||||
@@ -16,31 +17,34 @@ import com.correx.core.utils.TypeId
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.UUID
|
||||
|
||||
private const val HEARTBEAT_INTERVAL_MS = 30_000L
|
||||
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
|
||||
|
||||
class GlobalStreamHandler(private val module: ServerModule) {
|
||||
|
||||
suspend fun handle(session: DefaultWebSocketServerSession) {
|
||||
log.info("client connected")
|
||||
sendInitialSnapshot(session)
|
||||
|
||||
val heartbeatJob = session.launch {
|
||||
while (isActive) {
|
||||
delay(HEARTBEAT_INTERVAL_MS)
|
||||
val ping = ServerMessage.ProtocolError("ping")
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(ping)))
|
||||
}
|
||||
val sendFrame: suspend (ServerMessage) -> Unit = {
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(it)))
|
||||
}
|
||||
|
||||
val bridge = SessionEventBridge(
|
||||
eventStore = module.eventStore,
|
||||
artifactStore = module.artifactStore,
|
||||
send = sendFrame,
|
||||
)
|
||||
sendInitialSnapshot(session)
|
||||
bridge.replaySnapshot()
|
||||
|
||||
val liveStreamJobs = mutableMapOf<SessionId, Job>()
|
||||
|
||||
try {
|
||||
for (frame in session.incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
@@ -49,7 +53,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
.onSuccess { msg ->
|
||||
log.debug("recv: {}", msg::class.simpleName)
|
||||
runCatching {
|
||||
handleClientMessage(session, msg)
|
||||
handleClientMessage(session, msg, liveStreamJobs, sendFrame)
|
||||
}.onFailure {
|
||||
log.error("handle failed", it)
|
||||
}
|
||||
@@ -64,7 +68,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
log.info("client disconnected")
|
||||
} finally {
|
||||
heartbeatJob.cancel()
|
||||
liveStreamJobs.values.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +92,31 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleClientMessage(session: DefaultWebSocketServerSession, msg: ClientMessage) {
|
||||
private suspend fun handleClientMessage(
|
||||
session: DefaultWebSocketServerSession,
|
||||
msg: ClientMessage,
|
||||
liveStreamJobs: MutableMap<SessionId, Job>,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
when (msg) {
|
||||
is ClientMessage.Ping -> Unit
|
||||
is ClientMessage.StartSession -> handleStartSession(session, msg, liveStreamJobs, sendFrame)
|
||||
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||
is ClientMessage.ApprovalResponse ->
|
||||
session.send(encodeError("ApprovalResponse not supported on global stream"))
|
||||
}
|
||||
}
|
||||
|
||||
is ClientMessage.StartSession -> {
|
||||
private fun encodeError(message: String): Frame.Text =
|
||||
Frame.Text(ProtocolSerializer.encodeServerMessage(ServerMessage.ProtocolError(message)))
|
||||
|
||||
private suspend fun handleStartSession(
|
||||
session: DefaultWebSocketServerSession,
|
||||
msg: ClientMessage.StartSession,
|
||||
liveStreamJobs: MutableMap<SessionId, Job>,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
val graph = module.workflowRegistry.find(msg.workflowId)
|
||||
log.info("find returned: {}", graph)
|
||||
if (graph == null) {
|
||||
@@ -129,23 +153,15 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
)
|
||||
}
|
||||
}
|
||||
liveStreamJobs.put(sessionId, session.launch {
|
||||
val bridge = SessionEventBridge(
|
||||
eventStore = module.eventStore,
|
||||
artifactStore = module.artifactStore,
|
||||
send = sendFrame,
|
||||
)
|
||||
bridge.streamLive(sessionId)
|
||||
})?.cancel()
|
||||
val started = ServerMessage.SessionStarted(sessionId = sessionId, workflowId = msg.workflowId)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
|
||||
}
|
||||
|
||||
is ClientMessage.CancelSession -> {
|
||||
module.orchestrator.cancel(msg.sessionId)
|
||||
}
|
||||
|
||||
is ClientMessage.ResumeSession -> {
|
||||
val error = ServerMessage.ProtocolError("ResumeSession not supported")
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||
}
|
||||
|
||||
is ClientMessage.ApprovalResponse -> {
|
||||
val error = ServerMessage.ProtocolError("ApprovalResponse not supported on global stream")
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.correx.apps.server.ws
|
||||
|
||||
import com.correx.apps.server.ServerModule
|
||||
import com.correx.apps.server.bridge.DomainEventMapper
|
||||
import com.correx.apps.server.protocol.ApprovalDecision
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||
@@ -19,38 +20,29 @@ import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
private const val HEARTBEAT_INTERVAL_MS = 30_000L
|
||||
private val log = LoggerFactory.getLogger(SessionStreamHandler::class.java)
|
||||
|
||||
class SessionStreamHandler(private val module: ServerModule) {
|
||||
|
||||
private val mapper = DomainEventMapper(module.artifactStore)
|
||||
|
||||
suspend fun handle(session: DefaultWebSocketServerSession, sessionId: SessionId, lastEventId: Long?) {
|
||||
log.info("client connected session={} lastEventId={}", sessionId.value, lastEventId)
|
||||
|
||||
val replayEvents = if (lastEventId != null) {
|
||||
module.eventStore.readFrom(sessionId, lastEventId)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
runCatching { module.sessionRepository.getSession(sessionId) }.onSuccess {
|
||||
val snapshot = ServerMessage.SessionStarted(sessionId, sessionId.value)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(snapshot)))
|
||||
}
|
||||
log.debug("replaying {} event(s) for session={}", replayEvents.size, sessionId.value)
|
||||
|
||||
for (event in replayEvents) {
|
||||
val msg = ServerMessage.SessionStarted(sessionId, event.metadata.eventId.value)
|
||||
mapper.map(event)?.let { msg ->
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg)))
|
||||
}
|
||||
|
||||
val heartbeatJob = session.launch {
|
||||
while (isActive) {
|
||||
delay(HEARTBEAT_INTERVAL_MS)
|
||||
val ping = ServerMessage.ProtocolError("ping")
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(ping)))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -58,17 +50,19 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
runCatching { ProtocolSerializer.decodeClientMessage(text) }
|
||||
.onSuccess { msg -> handleClientMessage(session, msg) }
|
||||
.onSuccess { msg ->
|
||||
log.debug("recv: {}", msg::class.simpleName)
|
||||
handleClientMessage(session, msg)
|
||||
}
|
||||
.onFailure {
|
||||
log.warn("decode error: {}", it.message)
|
||||
val error = ServerMessage.ProtocolError("Unknown message: ${it.message}")
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
// client disconnected
|
||||
} finally {
|
||||
heartbeatJob.cancel()
|
||||
log.info("client disconnected session={}", sessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,13 +70,16 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
when (msg) {
|
||||
is ClientMessage.Ping -> Unit
|
||||
is ClientMessage.CancelSession -> {
|
||||
module.orchestrator.cancel(msg.sessionId)
|
||||
runCatching { module.orchestrator.cancel(msg.sessionId) }
|
||||
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
||||
}
|
||||
is ClientMessage.ApprovalResponse -> {
|
||||
val domainDecision = msg.toDomainDecision(msg.requestId.value)
|
||||
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
|
||||
.onFailure { log.error("submitApprovalDecision failed for request={}: {}", msg.requestId.value, it.message, it) }
|
||||
}
|
||||
else -> {
|
||||
log.warn("unexpected message type={} in session stream", msg::class.simpleName)
|
||||
val error = ServerMessage.ProtocolError("Unexpected message type in session stream")
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.PauseReason
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceStartedEvent
|
||||
import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.SessionStartedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DomainEventMapperTest {
|
||||
|
||||
private val sessionId = SessionId("session-1")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
private val noopStore: ArtifactStore = object : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("art-1")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? = null
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
|
||||
private fun storedEvent(payload: com.correx.core.events.events.EventPayload): StoredEvent =
|
||||
StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("evt-1"),
|
||||
sessionId = sessionId,
|
||||
timestamp = timestamp,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = 1L,
|
||||
payload = payload,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `WorkflowStartedEvent maps to SessionStarted`(): Unit = runTest {
|
||||
val event = storedEvent(WorkflowStartedEvent(sessionId = sessionId, startStageId = stageId))
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowCompletedEvent maps to SessionCompleted`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
WorkflowCompletedEvent(sessionId = sessionId, terminalStageId = stageId, totalStages = 1),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.SessionCompleted(sessionId), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowFailedEvent maps to SessionFailed`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
WorkflowFailedEvent(sessionId = sessionId, stageId = stageId, reason = "boom", retryExhausted = false),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.SessionFailed(sessionId, "boom"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TransitionExecutedEvent maps to StageStarted with to stageId`(): Unit = runTest {
|
||||
val from = StageId("from-stage")
|
||||
val to = StageId("to-stage")
|
||||
val event = storedEvent(
|
||||
TransitionExecutedEvent(sessionId = sessionId, from = from, to = to, transitionId = TransitionId("t1")),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.StageStarted(sessionId, to), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageCompletedEvent maps to StageCompleted`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
StageCompletedEvent(sessionId = sessionId, stageId = stageId, transitionId = TransitionId("t1")),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.StageCompleted(sessionId, stageId), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageFailedEvent maps to StageFailed`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
StageFailedEvent(
|
||||
sessionId = sessionId, stageId = stageId, transitionId = TransitionId("t1"), reason = "err",
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.StageFailed(sessionId, stageId, "err"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OrchestrationPausedEvent with APPROVAL_PENDING maps to APPROVAL_PENDING`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
OrchestrationPausedEvent(sessionId = sessionId, stageId = stageId, reason = "APPROVAL_PENDING"),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.SessionPaused(sessionId, PauseReason.APPROVAL_PENDING), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OrchestrationPausedEvent with other reason maps to USER_REQUESTED`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
OrchestrationPausedEvent(sessionId = sessionId, stageId = stageId, reason = "USER_REQUESTED"),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.SessionPaused(sessionId, PauseReason.USER_REQUESTED), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `InferenceStartedEvent maps to InferenceStarted`(): Unit = runTest {
|
||||
val event = storedEvent(InferenceStartedEvent(
|
||||
requestId = InferenceRequestId("req-1"),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = ProviderId("p1"),
|
||||
promptArtifactId = ArtifactId("art-prompt"),
|
||||
))
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.InferenceStarted(sessionId, stageId), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `InferenceCompletedEvent reads artifact and maps to InferenceCompleted`(): Unit = runTest {
|
||||
val artifactId = ArtifactId("art-resp")
|
||||
val responseText = "hello world"
|
||||
val store = object : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("x")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? =
|
||||
if (id == artifactId) responseText.toByteArray(Charsets.UTF_8) else null
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
val event = storedEvent(InferenceCompletedEvent(
|
||||
requestId = InferenceRequestId("req-1"),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = ProviderId("p1"),
|
||||
tokensUsed = TokenUsage(10, 5),
|
||||
latencyMs = 100L,
|
||||
responseArtifactId = artifactId,
|
||||
))
|
||||
val result = domainEventToServerMessage(event, store)
|
||||
assertEquals(ServerMessage.InferenceCompleted(sessionId, stageId, responseText, responseText), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `InferenceCompletedEvent falls back to empty string when artifact missing`(): Unit = runTest {
|
||||
val event = storedEvent(InferenceCompletedEvent(
|
||||
requestId = InferenceRequestId("req-1"),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = ProviderId("p1"),
|
||||
tokensUsed = TokenUsage(10, 5),
|
||||
latencyMs = 100L,
|
||||
responseArtifactId = ArtifactId("missing"),
|
||||
))
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.InferenceCompleted(sessionId, stageId, "", ""), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `InferenceTimeoutEvent maps to InferenceTimedOut`(): Unit = runTest {
|
||||
val event = storedEvent(InferenceTimeoutEvent(
|
||||
requestId = InferenceRequestId("req-1"),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = ProviderId("p1"),
|
||||
timeoutMs = 5000L,
|
||||
))
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.InferenceTimedOut(sessionId, stageId, 5000L), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolInvocationRequestedEvent maps to ToolStarted`(): Unit = runTest {
|
||||
val invId = ToolInvocationId("inv-1")
|
||||
val event = storedEvent(ToolInvocationRequestedEvent(
|
||||
invocationId = invId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = "file_write",
|
||||
tier = Tier.T2,
|
||||
request = ToolRequest(
|
||||
invocationId = invId, sessionId = sessionId, stageId = stageId, toolName = "file_write",
|
||||
),
|
||||
))
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.ToolStarted(sessionId, "file_write", Tier.T2), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolExecutionCompletedEvent maps to ToolCompleted`(): Unit = runTest {
|
||||
val invId = ToolInvocationId("inv-1")
|
||||
val receipt = ToolReceipt(
|
||||
invocationId = invId,
|
||||
toolName = "file_write",
|
||||
exitCode = 0,
|
||||
outputSummary = "wrote 3 lines",
|
||||
durationMs = 10L,
|
||||
tier = Tier.T2,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
val event = storedEvent(
|
||||
ToolExecutionCompletedEvent(
|
||||
invocationId = invId, sessionId = sessionId, toolName = "file_write", receipt = receipt,
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolExecutionFailedEvent maps to ToolFailed`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
ToolExecutionFailedEvent(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
sessionId = sessionId,
|
||||
toolName = "file_write",
|
||||
reason = "disk full",
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.ToolFailed(sessionId, "file_write", "disk full"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolExecutionRejectedEvent maps to ToolRejected`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
ToolExecutionRejectedEvent(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
sessionId = sessionId,
|
||||
toolName = "file_write",
|
||||
tier = Tier.T3,
|
||||
reason = "policy denied",
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.ToolRejected(sessionId, "file_write", "policy denied"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApprovalRequestedEvent maps to ApprovalRequired`(): Unit = runTest {
|
||||
val requestId = ApprovalRequestId("req-1")
|
||||
val event = storedEvent(ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = Tier.T3,
|
||||
validationReportId = ValidationReportId("vr-1"),
|
||||
riskSummaryId = null,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
projectId = null,
|
||||
))
|
||||
val result = domainEventToServerMessage(event, noopStore) as ServerMessage.ApprovalRequired
|
||||
assertEquals(requestId, result.requestId)
|
||||
assertEquals(Tier.T3, result.tier)
|
||||
assertNull(result.toolName)
|
||||
assertNull(result.preview)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unmapped event returns null`(): Unit = runTest {
|
||||
val event = storedEvent(SessionStartedEvent(sessionId = sessionId))
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertNull(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SessionEventBridgeTest {
|
||||
|
||||
private val sessionId = SessionId("session-1")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
private val noopArtifactStore: ArtifactStore = object : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("art-1")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? = null
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
|
||||
private fun storedEvent(payload: EventPayload, seq: Long = 1L): StoredEvent =
|
||||
StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("evt-$seq"),
|
||||
sessionId = sessionId,
|
||||
timestamp = timestamp,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = seq,
|
||||
payload = payload,
|
||||
)
|
||||
|
||||
private fun fakeEventStore(
|
||||
allEventsList: List<StoredEvent> = emptyList(),
|
||||
liveFlow: Flow<StoredEvent> = flow {},
|
||||
): EventStore = object : EventStore {
|
||||
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> = allEventsList
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = allEventsList
|
||||
override fun lastSequence(sessionId: SessionId): Long? = null
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = liveFlow
|
||||
override fun allEvents(): Sequence<StoredEvent> = allEventsList.asSequence()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaySnapshot sends mapped messages for all events`() = runTest {
|
||||
val events = listOf(
|
||||
storedEvent(WorkflowStartedEvent(sessionId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
|
||||
assertEquals(2, sent.size)
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), sent[0])
|
||||
assertEquals(ServerMessage.SessionCompleted(sessionId), sent[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaySnapshot skips unmapped events`() = runTest {
|
||||
val events = listOf(
|
||||
storedEvent(OrchestrationResumedEvent(sessionId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
|
||||
assertEquals(1, sent.size)
|
||||
assertEquals(ServerMessage.SessionCompleted(sessionId), sent[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `streamLive sends mapped messages from live flow`() = runTest {
|
||||
val events = listOf(
|
||||
storedEvent(WorkflowStartedEvent(sessionId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
|
||||
)
|
||||
val liveFlow: Flow<StoredEvent> = flow { events.forEach { emit(it) } }
|
||||
val store = fakeEventStore(liveFlow = liveFlow)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
|
||||
bridge.streamLive(sessionId)
|
||||
|
||||
assertEquals(2, sent.size)
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), sent[0])
|
||||
assertEquals(ServerMessage.SessionCompleted(sessionId), sent[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `streamLive cancellation stops collection`() = runTest {
|
||||
val channel = Channel<StoredEvent>(Channel.UNLIMITED)
|
||||
val liveFlow: Flow<StoredEvent> = flow {
|
||||
for (event in channel) emit(event)
|
||||
}
|
||||
val store = fakeEventStore(liveFlow = liveFlow)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
|
||||
channel.send(storedEvent(WorkflowStartedEvent(sessionId, stageId), seq = 1L))
|
||||
|
||||
val job = launch { bridge.streamLive(sessionId) }
|
||||
channel.send(storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L))
|
||||
channel.close()
|
||||
job.join()
|
||||
|
||||
assertEquals(2, sent.size)
|
||||
}
|
||||
}
|
||||
@@ -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