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-status-pages:$ktor_version"
|
||||||
implementation "io.ktor:ktor-server-call-logging:$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-core:2.24.1"
|
||||||
implementation "org.apache.logging.log4j:log4j-slf4j2-impl:2.24.1"
|
implementation "org.apache.logging.log4j:log4j-slf4j2-impl:2.24.1"
|
||||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
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 com.correx.core.events.EventDispatcher
|
||||||
import io.ktor.server.engine.embeddedServer
|
import io.ktor.server.engine.embeddedServer
|
||||||
import io.ktor.server.netty.Netty
|
import io.ktor.server.netty.Netty
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger("com.correx.apps.server.Main")
|
||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
val artifactStore = InfrastructureModule.createArtifactStore()
|
val artifactStore = InfrastructureModule.createArtifactStore()
|
||||||
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore))
|
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore))
|
||||||
@@ -47,8 +50,6 @@ fun main() {
|
|||||||
val toolRegistry = InfrastructureModule.createToolRegistry(buildToolConfig(artifactStore, sandboxRoot))
|
val toolRegistry = InfrastructureModule.createToolRegistry(buildToolConfig(artifactStore, sandboxRoot))
|
||||||
val toolExecutor = InfrastructureModule.createToolExecutor(
|
val toolExecutor = InfrastructureModule.createToolExecutor(
|
||||||
registry = toolRegistry,
|
registry = toolRegistry,
|
||||||
approvalEngine = approvalEngine,
|
|
||||||
eventStore = eventStore,
|
|
||||||
eventDispatcher = EventDispatcher(eventStore),
|
eventDispatcher = EventDispatcher(eventStore),
|
||||||
workDir = sandboxRoot,
|
workDir = sandboxRoot,
|
||||||
)
|
)
|
||||||
@@ -72,10 +73,26 @@ fun main() {
|
|||||||
val module = ServerModule(
|
val module = ServerModule(
|
||||||
orchestrator = orchestrator,
|
orchestrator = orchestrator,
|
||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
|
artifactStore = artifactStore,
|
||||||
sessionRepository = repositories.sessionRepository,
|
sessionRepository = repositories.sessionRepository,
|
||||||
workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()),
|
workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()),
|
||||||
providerRegistry = infraRegistry.asServerRegistry(),
|
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)
|
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.ProviderRegistry
|
||||||
import com.correx.apps.server.registry.WorkflowRegistry
|
import com.correx.apps.server.registry.WorkflowRegistry
|
||||||
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
import com.correx.core.events.stores.EventStore
|
import com.correx.core.events.stores.EventStore
|
||||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||||
import com.correx.core.sessions.DefaultSessionRepository
|
import com.correx.core.sessions.DefaultSessionRepository
|
||||||
@@ -9,6 +10,7 @@ import com.correx.core.sessions.DefaultSessionRepository
|
|||||||
data class ServerModule(
|
data class ServerModule(
|
||||||
val orchestrator: DefaultSessionOrchestrator,
|
val orchestrator: DefaultSessionOrchestrator,
|
||||||
val eventStore: EventStore,
|
val eventStore: EventStore,
|
||||||
|
val artifactStore: ArtifactStore,
|
||||||
val sessionRepository: DefaultSessionRepository,
|
val sessionRepository: DefaultSessionRepository,
|
||||||
val workflowRegistry: WorkflowRegistry,
|
val workflowRegistry: WorkflowRegistry,
|
||||||
val providerRegistry: ProviderRegistry,
|
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
|
package com.correx.apps.server.ws
|
||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
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.ClientMessage
|
||||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||||
import com.correx.apps.server.protocol.ProviderHealthDto
|
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.server.websocket.DefaultWebSocketServerSession
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import org.slf4j.LoggerFactory
|
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)
|
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
|
||||||
|
|
||||||
class GlobalStreamHandler(private val module: ServerModule) {
|
class GlobalStreamHandler(private val module: ServerModule) {
|
||||||
|
|
||||||
suspend fun handle(session: DefaultWebSocketServerSession) {
|
suspend fun handle(session: DefaultWebSocketServerSession) {
|
||||||
log.info("client connected")
|
log.info("client connected")
|
||||||
sendInitialSnapshot(session)
|
|
||||||
|
|
||||||
val heartbeatJob = session.launch {
|
val sendFrame: suspend (ServerMessage) -> Unit = {
|
||||||
while (isActive) {
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(it)))
|
||||||
delay(HEARTBEAT_INTERVAL_MS)
|
|
||||||
val ping = ServerMessage.ProtocolError("ping")
|
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(ping)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bridge = SessionEventBridge(
|
||||||
|
eventStore = module.eventStore,
|
||||||
|
artifactStore = module.artifactStore,
|
||||||
|
send = sendFrame,
|
||||||
|
)
|
||||||
|
sendInitialSnapshot(session)
|
||||||
|
bridge.replaySnapshot()
|
||||||
|
|
||||||
|
val liveStreamJobs = mutableMapOf<SessionId, Job>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (frame in session.incoming) {
|
for (frame in session.incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
@@ -49,7 +53,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
.onSuccess { msg ->
|
.onSuccess { msg ->
|
||||||
log.debug("recv: {}", msg::class.simpleName)
|
log.debug("recv: {}", msg::class.simpleName)
|
||||||
runCatching {
|
runCatching {
|
||||||
handleClientMessage(session, msg)
|
handleClientMessage(session, msg, liveStreamJobs, sendFrame)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
log.error("handle failed", it)
|
log.error("handle failed", it)
|
||||||
}
|
}
|
||||||
@@ -64,7 +68,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
} catch (_: ClosedReceiveChannelException) {
|
} catch (_: ClosedReceiveChannelException) {
|
||||||
log.info("client disconnected")
|
log.info("client disconnected")
|
||||||
} finally {
|
} finally {
|
||||||
heartbeatJob.cancel()
|
liveStreamJobs.values.forEach { it.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,64 +92,76 @@ 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) {
|
when (msg) {
|
||||||
is ClientMessage.Ping -> Unit
|
is ClientMessage.Ping -> Unit
|
||||||
|
is ClientMessage.StartSession -> handleStartSession(session, msg, liveStreamJobs, sendFrame)
|
||||||
is ClientMessage.StartSession -> {
|
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
||||||
val graph = module.workflowRegistry.find(msg.workflowId)
|
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||||
log.info("find returned: {}", graph)
|
is ClientMessage.ApprovalResponse ->
|
||||||
if (graph == null) {
|
session.send(encodeError("ApprovalResponse not supported on global stream"))
|
||||||
val error = ServerMessage.ProtocolError("Unknown workflowId: ${msg.workflowId}")
|
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
|
|
||||||
log.info("starting session={} workflow={}", sessionId.value, msg.workflowId)
|
|
||||||
session.launch {
|
|
||||||
val config = OrchestrationConfig(
|
|
||||||
defaultSystemPromptPath = "~/.config/correx/prompts/default_system.md",
|
|
||||||
)
|
|
||||||
runCatching { module.orchestrator.run(sessionId, graph, config) }
|
|
||||||
.onFailure { ex ->
|
|
||||||
log.error("run failed for session={}: {}", sessionId.value, ex.message, ex)
|
|
||||||
module.eventStore.append(
|
|
||||||
NewEvent(
|
|
||||||
metadata = EventMetadata(
|
|
||||||
eventId = EventId(UUID.randomUUID().toString()),
|
|
||||||
sessionId = sessionId,
|
|
||||||
timestamp = Clock.System.now(),
|
|
||||||
schemaVersion = 1,
|
|
||||||
causationId = null,
|
|
||||||
correlationId = null,
|
|
||||||
),
|
|
||||||
payload = WorkflowFailedEvent(
|
|
||||||
sessionId = sessionId,
|
|
||||||
stageId = graph.start,
|
|
||||||
reason = ex.message ?: "unexpected orchestrator failure",
|
|
||||||
retryExhausted = false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
val error = ServerMessage.ProtocolError("Unknown workflowId: ${msg.workflowId}")
|
||||||
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
|
||||||
|
log.info("starting session={} workflow={}", sessionId.value, msg.workflowId)
|
||||||
|
session.launch {
|
||||||
|
val config = OrchestrationConfig(
|
||||||
|
defaultSystemPromptPath = "~/.config/correx/prompts/default_system.md",
|
||||||
|
)
|
||||||
|
runCatching { module.orchestrator.run(sessionId, graph, config) }
|
||||||
|
.onFailure { ex ->
|
||||||
|
log.error("run failed for session={}: {}", sessionId.value, ex.message, ex)
|
||||||
|
module.eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = WorkflowFailedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = graph.start,
|
||||||
|
reason = ex.message ?: "unexpected orchestrator failure",
|
||||||
|
retryExhausted = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.correx.apps.server.ws
|
package com.correx.apps.server.ws
|
||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
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.ApprovalDecision
|
||||||
import com.correx.apps.server.protocol.ClientMessage
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||||
@@ -19,37 +20,28 @@ import io.ktor.server.websocket.DefaultWebSocketServerSession
|
|||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.Clock
|
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) {
|
class SessionStreamHandler(private val module: ServerModule) {
|
||||||
|
|
||||||
|
private val mapper = DomainEventMapper(module.artifactStore)
|
||||||
|
|
||||||
suspend fun handle(session: DefaultWebSocketServerSession, sessionId: SessionId, lastEventId: Long?) {
|
suspend fun handle(session: DefaultWebSocketServerSession, sessionId: SessionId, lastEventId: Long?) {
|
||||||
|
log.info("client connected session={} lastEventId={}", sessionId.value, lastEventId)
|
||||||
|
|
||||||
val replayEvents = if (lastEventId != null) {
|
val replayEvents = if (lastEventId != null) {
|
||||||
module.eventStore.readFrom(sessionId, lastEventId)
|
module.eventStore.readFrom(sessionId, lastEventId)
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
log.debug("replaying {} event(s) for session={}", replayEvents.size, sessionId.value)
|
||||||
runCatching { module.sessionRepository.getSession(sessionId) }.onSuccess {
|
|
||||||
val snapshot = ServerMessage.SessionStarted(sessionId, sessionId.value)
|
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(snapshot)))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (event in replayEvents) {
|
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)))
|
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)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,17 +50,19 @@ class SessionStreamHandler(private val module: ServerModule) {
|
|||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val text = frame.readText()
|
val text = frame.readText()
|
||||||
runCatching { ProtocolSerializer.decodeClientMessage(text) }
|
runCatching { ProtocolSerializer.decodeClientMessage(text) }
|
||||||
.onSuccess { msg -> handleClientMessage(session, msg) }
|
.onSuccess { msg ->
|
||||||
|
log.debug("recv: {}", msg::class.simpleName)
|
||||||
|
handleClientMessage(session, msg)
|
||||||
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
|
log.warn("decode error: {}", it.message)
|
||||||
val error = ServerMessage.ProtocolError("Unknown message: ${it.message}")
|
val error = ServerMessage.ProtocolError("Unknown message: ${it.message}")
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: ClosedReceiveChannelException) {
|
} catch (_: ClosedReceiveChannelException) {
|
||||||
// client disconnected
|
log.info("client disconnected session={}", sessionId.value)
|
||||||
} finally {
|
|
||||||
heartbeatJob.cancel()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,13 +70,16 @@ class SessionStreamHandler(private val module: ServerModule) {
|
|||||||
when (msg) {
|
when (msg) {
|
||||||
is ClientMessage.Ping -> Unit
|
is ClientMessage.Ping -> Unit
|
||||||
is ClientMessage.CancelSession -> {
|
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 -> {
|
is ClientMessage.ApprovalResponse -> {
|
||||||
val domainDecision = msg.toDomainDecision(msg.requestId.value)
|
val domainDecision = msg.toDomainDecision(msg.requestId.value)
|
||||||
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
|
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
|
||||||
|
.onFailure { log.error("submitApprovalDecision failed for request={}: {}", msg.requestId.value, it.message, it) }
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
log.warn("unexpected message type={} in session stream", msg::class.simpleName)
|
||||||
val error = ServerMessage.ProtocolError("Unexpected message type in session stream")
|
val error = ServerMessage.ProtocolError("Unexpected message type in session stream")
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
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.approvalPanelWidget
|
||||||
import com.correx.apps.tui.components.filteredSessions
|
import com.correx.apps.tui.components.filteredSessions
|
||||||
import com.correx.apps.tui.components.inputBarWidget
|
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.sessionListWidget
|
||||||
import com.correx.apps.tui.components.statusBarWidget
|
import com.correx.apps.tui.components.statusBarWidget
|
||||||
import com.correx.apps.tui.input.Action
|
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 ACTIVE_SESSION_HEIGHT = 5
|
||||||
private const val APPROVAL_HEIGHT = 6
|
private const val APPROVAL_HEIGHT = 6
|
||||||
private const val INPUT_HEIGHT = 3
|
private const val INPUT_HEIGHT = 3
|
||||||
|
private const val ROUTER_HEIGHT = 5
|
||||||
private const val KEYBINDS_HEIGHT = 3
|
private const val KEYBINDS_HEIGHT = 3
|
||||||
|
|
||||||
private const val KEYBINDS_TEXT = "n new c cancel a approve r reject s steer / filter q quit"
|
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.length(STATUS_HEIGHT))
|
||||||
add(Constraint.fill())
|
add(Constraint.fill())
|
||||||
add(Constraint.length(ACTIVE_SESSION_HEIGHT))
|
add(Constraint.length(ACTIVE_SESSION_HEIGHT))
|
||||||
|
add(Constraint.length(ROUTER_HEIGHT))
|
||||||
if (hasApproval) add(Constraint.length(APPROVAL_HEIGHT))
|
if (hasApproval) add(Constraint.length(APPROVAL_HEIGHT))
|
||||||
if (hasInput) add(Constraint.length(INPUT_HEIGHT))
|
if (hasInput) add(Constraint.length(INPUT_HEIGHT))
|
||||||
add(Constraint.length(KEYBINDS_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(activeSessionWidget(selectedSession), rects[idx++])
|
||||||
|
|
||||||
|
frame.renderWidget(routerPanelWidget(selectedSession), rects[idx++])
|
||||||
|
|
||||||
if (hasApproval) {
|
if (hasApproval) {
|
||||||
frame.renderWidget(approvalPanelWidget(state.approval.active!!), rects[idx++])
|
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.StageCompleted -> sessions.copy(
|
||||||
is ServerMessage.StageFailed -> touchSession(sessions, msg.sessionId.value, null, clock)
|
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.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
|
||||||
is ServerMessage.ToolCompleted -> sessions.copy(
|
is ServerMessage.ToolCompleted -> sessions.copy(
|
||||||
sessions = sessions.sessions.map { s ->
|
sessions = sessions.sessions.map { s ->
|
||||||
@@ -134,15 +142,11 @@ object SessionsReducer {
|
|||||||
private fun touchSession(
|
private fun touchSession(
|
||||||
sessions: SessionsState,
|
sessions: SessionsState,
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
status: String?,
|
status: String,
|
||||||
clock: () -> Long,
|
clock: () -> Long,
|
||||||
): SessionsState = sessions.copy(
|
): SessionsState = sessions.copy(
|
||||||
sessions = sessions.sessions.map { s ->
|
sessions = sessions.sessions.map { s ->
|
||||||
if (s.id == sessionId) {
|
if (s.id == sessionId) s.copy(status = status, lastEventAt = clock()) else s
|
||||||
if (status != null) s.copy(status = status, lastEventAt = clock()) else s.copy(lastEventAt = clock())
|
|
||||||
} else {
|
|
||||||
s
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class TuiWsClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { println("WS connect failed: ${it.message}") }
|
}.onFailure { }
|
||||||
session = null
|
session = null
|
||||||
_connection.tryEmit(ConnectionEvent.Disconnected)
|
_connection.tryEmit(ConnectionEvent.Disconnected)
|
||||||
attempt++
|
attempt++
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.apps.tui.reducer
|
package com.correx.apps.tui.reducer
|
||||||
|
|
||||||
|
import com.correx.apps.server.protocol.PauseReason
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
import com.correx.apps.tui.input.Action
|
import com.correx.apps.tui.input.Action
|
||||||
import com.correx.apps.tui.state.InputMode
|
import com.correx.apps.tui.state.InputMode
|
||||||
@@ -124,6 +125,34 @@ class SessionsReducerTest {
|
|||||||
assertEquals("FAILED", state.sessions[0].status)
|
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
|
@Test
|
||||||
fun `CancelSelectedSession emits CancelSession effect`() {
|
fun `CancelSelectedSession emits CancelSession effect`() {
|
||||||
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
|
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
|
||||||
@@ -140,4 +169,54 @@ class SessionsReducerTest {
|
|||||||
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
|
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
|
||||||
assertTrue(effects.isEmpty())
|
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