chore(damn): detekt, build, tests, formatting
fixed detekt issues where possible. fixed disttar failing build because tools is added twice in the server module. added workflowId where required. fixed some tests not being recognized because of runBlocking without explicit return type. formatting + imports.
This commit is contained in:
@@ -8,6 +8,13 @@ application {
|
||||
mainClass = 'com.correx.apps.server.MainKt'
|
||||
}
|
||||
|
||||
tasks.named('distTar') {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
tasks.named('distZip') {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:config')
|
||||
implementation project(':core:events')
|
||||
|
||||
@@ -9,7 +9,6 @@ import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.calllogging.CallLogging
|
||||
import org.slf4j.event.Level
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.plugins.statuspages.StatusPages
|
||||
import io.ktor.server.response.respond
|
||||
@@ -18,6 +17,7 @@ import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.slf4j.event.Level
|
||||
|
||||
fun Application.configureServer(module: ServerModule) {
|
||||
install(CallLogging) { level = Level.INFO }
|
||||
|
||||
@@ -49,9 +49,20 @@ import java.nio.file.Paths
|
||||
private val log = LoggerFactory.getLogger("com.correx.apps.server.Main")
|
||||
|
||||
fun main() {
|
||||
log.info("=== correx server starting ===")
|
||||
log.info(" port : 8080")
|
||||
val artifactStore = InfrastructureModule.createArtifactStore()
|
||||
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore))
|
||||
|
||||
logStoresInfo(eventStore, artifactStore)
|
||||
|
||||
val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(buildLlamaProvider()))
|
||||
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"
|
||||
|
||||
logModelInfo(modelId, modelPath, llamaUrl, infraRegistry)
|
||||
|
||||
val repositories = buildRepositories(eventStore)
|
||||
val approvalEngine = DefaultApprovalEngine()
|
||||
val correxConfig = ConfigLoader.load()
|
||||
@@ -78,6 +89,9 @@ fun main() {
|
||||
eventDispatcher = EventDispatcher(eventStore),
|
||||
workDir = sandboxRoot,
|
||||
)
|
||||
|
||||
logToolInfo(sandboxRoot, workingDir, shellAllowedExecutables, toolRegistry)
|
||||
|
||||
val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
|
||||
val engines = OrchestratorEngines(
|
||||
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
|
||||
@@ -116,52 +130,46 @@ fun main() {
|
||||
defaultOrchestrationConfig = defaultOrchestrationConfig,
|
||||
routerFacade = routerFacade,
|
||||
)
|
||||
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"
|
||||
logServerStartup(
|
||||
modelId,
|
||||
modelPath,
|
||||
llamaUrl,
|
||||
sandboxRoot,
|
||||
workingDir,
|
||||
shellAllowedExecutables,
|
||||
eventStore,
|
||||
artifactStore,
|
||||
toolRegistry,
|
||||
infraRegistry,
|
||||
)
|
||||
log.info("==============================")
|
||||
|
||||
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
|
||||
}
|
||||
|
||||
private fun logServerStartup(
|
||||
private fun logModelInfo(
|
||||
modelId: String,
|
||||
modelPath: String,
|
||||
llamaUrl: String,
|
||||
sandboxRoot: Path?,
|
||||
workingDir: Path?,
|
||||
shellAllowedExecutables: Set<String>,
|
||||
eventStore: LoggingEventStore,
|
||||
artifactStore: ArtifactStore,
|
||||
toolRegistry: ToolRegistry,
|
||||
infraRegistry: DefaultProviderRegistry,
|
||||
) {
|
||||
log.info("=== correx server starting ===")
|
||||
log.info(" port : 8080")
|
||||
log.info("==========MODEL INFO==========")
|
||||
log.info(" model id : {}", modelId)
|
||||
log.info(" model path : {}", modelPath)
|
||||
log.info(" llama url : {}", llamaUrl)
|
||||
log.info(" providers : {} registered", infraRegistry.listAll().size)
|
||||
}
|
||||
|
||||
private fun logToolInfo(
|
||||
sandboxRoot: Path?,
|
||||
workingDir: Path?,
|
||||
shellAllowedExecutables: Set<String>,
|
||||
toolRegistry: ToolRegistry,
|
||||
) {
|
||||
log.info("==========TOOLS INFO==========")
|
||||
log.info(" sandbox root : {}", sandboxRoot)
|
||||
log.info(" working dir : {}", workingDir)
|
||||
if (shellAllowedExecutables.isEmpty()) {
|
||||
log.warn(" shell tool : no executable allowlist configured — all executables allowed")
|
||||
}
|
||||
log.info(" tools : {}", toolRegistry.all().map { it.name })
|
||||
}
|
||||
|
||||
private fun logStoresInfo(
|
||||
eventStore: LoggingEventStore,
|
||||
artifactStore: ArtifactStore,
|
||||
) {
|
||||
log.info("==========STORE INFO==========")
|
||||
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("==============================")
|
||||
}
|
||||
|
||||
private fun buildLlamaProvider(): LlamaCppInferenceProvider = InfrastructureModule.createLlamaCppProvider(
|
||||
|
||||
@@ -6,8 +6,8 @@ import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
|
||||
data class ServerModule(
|
||||
val orchestrator: DefaultSessionOrchestrator,
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.UserSteering
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
@@ -25,7 +24,8 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.*
|
||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||
|
||||
class ApprovalCoordinator(
|
||||
private val orchestrator: ApprovalGateway,
|
||||
|
||||
@@ -5,13 +5,13 @@ 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.StoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
@@ -20,7 +20,7 @@ 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
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
|
||||
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
|
||||
suspend fun map(event: StoredEvent): ServerMessage? =
|
||||
@@ -39,47 +39,80 @@ suspend fun domainEventToServerMessage(event: StoredEvent, artifactStore: Artifa
|
||||
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 TransitionExecutedEvent -> ServerMessage.StageStarted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.to,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
)
|
||||
|
||||
is StageCompletedEvent -> ServerMessage.StageCompleted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
)
|
||||
|
||||
is StageFailedEvent -> ServerMessage.StageFailed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
reason = p.reason,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
)
|
||||
|
||||
is OrchestrationPausedEvent -> mapOrchestrationPaused(p)
|
||||
is InferenceStartedEvent -> ServerMessage.InferenceStarted(sessionId = p.sessionId, stageId = p.stageId)
|
||||
is InferenceCompletedEvent -> mapInferenceCompleted(p, artifactStore)
|
||||
is InferenceCompletedEvent -> mapInferenceCompleted(event, 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,
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
outputSummary = p.receipt.outputSummary,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
)
|
||||
|
||||
is ToolExecutionFailedEvent -> ServerMessage.ToolFailed(
|
||||
sessionId = p.sessionId, toolName = p.toolName, reason = p.reason,
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
reason = p.reason,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
)
|
||||
|
||||
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)
|
||||
ServerMessage.SessionStarted(sessionId = p.sessionId, workflowId = p.workflowId)
|
||||
|
||||
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 {
|
||||
private suspend fun mapInferenceCompleted(
|
||||
event: StoredEvent,
|
||||
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,
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
outputSummary = text,
|
||||
responseText = text,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.correx.apps.server.protocol
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class ProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@ sealed class ServerMessage {
|
||||
data class SessionFailed(val sessionId: SessionId, val reason: String) : ServerMessage()
|
||||
|
||||
@Serializable
|
||||
data class StageStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
|
||||
data class StageStarted(val sessionId: SessionId, val stageId: StageId, val occurredAt: Long) : ServerMessage()
|
||||
|
||||
@Serializable
|
||||
data class StageCompleted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
|
||||
data class StageCompleted(val sessionId: SessionId, val stageId: StageId, val occurredAt: Long) : ServerMessage()
|
||||
|
||||
@Serializable
|
||||
data class StageFailed(val sessionId: SessionId, val stageId: StageId, val reason: String) :
|
||||
data class StageFailed(val sessionId: SessionId, val stageId: StageId, val reason: String, val occurredAt: Long) :
|
||||
ServerMessage()
|
||||
|
||||
@Serializable
|
||||
@@ -39,6 +39,7 @@ sealed class ServerMessage {
|
||||
val stageId: StageId,
|
||||
val outputSummary: String,
|
||||
val responseText: String = "",
|
||||
val occurredAt: Long,
|
||||
) : ServerMessage()
|
||||
|
||||
@Serializable
|
||||
@@ -50,11 +51,15 @@ sealed class ServerMessage {
|
||||
ServerMessage()
|
||||
|
||||
@Serializable
|
||||
data class ToolCompleted(val sessionId: SessionId, val toolName: String, val outputSummary: String) :
|
||||
ServerMessage()
|
||||
data class ToolCompleted(
|
||||
val sessionId: SessionId,
|
||||
val toolName: String,
|
||||
val outputSummary: String,
|
||||
val occurredAt: Long,
|
||||
) : ServerMessage()
|
||||
|
||||
@Serializable
|
||||
data class ToolFailed(val sessionId: SessionId, val toolName: String, val reason: String) :
|
||||
data class ToolFailed(val sessionId: SessionId, val toolName: String, val reason: String, val occurredAt: Long) :
|
||||
ServerMessage()
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.correx.apps.server.registry
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.events.types.ProviderId
|
||||
|
||||
interface ProviderRegistry {
|
||||
fun listAll(): List<InferenceProvider>
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.utils.TypeId
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import org.slf4j.LoggerFactory
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
@@ -21,7 +20,8 @@ import io.ktor.server.websocket.webSocket
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class StartSessionRequest(val workflowId: String, val config: SessionConfigDto? = null)
|
||||
|
||||
@@ -21,7 +21,7 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.UUID
|
||||
import java.util.*
|
||||
|
||||
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
|
||||
|
||||
|
||||
@@ -10,12 +10,10 @@ import com.correx.apps.server.protocol.ServerMessage.RouterResponseMessage
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.UserSteering
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.utils.TypeId
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
@@ -24,6 +22,7 @@ import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||
|
||||
private val log = LoggerFactory.getLogger(SessionStreamHandler::class.java)
|
||||
|
||||
@@ -75,11 +74,20 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
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) }
|
||||
.onFailure {
|
||||
log.error(
|
||||
"submitApprovalDecision failed for request={}: {}",
|
||||
msg.requestId.value,
|
||||
it.message,
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ClientMessage.ChatInput -> {
|
||||
runCatching {
|
||||
module.routerFacade.onUserInput(
|
||||
@@ -88,21 +96,24 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
mode = msg.mode,
|
||||
)
|
||||
}.onSuccess { response ->
|
||||
session.send(Frame.Text(
|
||||
ProtocolSerializer.encodeServerMessage(
|
||||
RouterResponseMessage(
|
||||
sessionId = msg.sessionId,
|
||||
content = response.content,
|
||||
steeringEmitted = response.steeringEmitted,
|
||||
)
|
||||
)
|
||||
))
|
||||
session.send(
|
||||
Frame.Text(
|
||||
ProtocolSerializer.encodeServerMessage(
|
||||
RouterResponseMessage(
|
||||
sessionId = msg.sessionId,
|
||||
content = response.content,
|
||||
steeringEmitted = response.steeringEmitted,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}.onFailure {
|
||||
log.error("routerFacade.onUserInput failed for session={}: {}", msg.sessionId.value, it.message, it)
|
||||
val error = ServerMessage.ProtocolError("Router error: ${it.message}")
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
log.warn("unexpected message type={} in session stream", msg::class.simpleName)
|
||||
val error = ServerMessage.ProtocolError("Unexpected message type in session stream")
|
||||
|
||||
+86
-62
@@ -24,8 +24,8 @@ 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.ArtifactId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
@@ -35,8 +35,8 @@ 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.Instant
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -45,7 +45,9 @@ class DomainEventMapperTest {
|
||||
|
||||
private val sessionId = SessionId("session-1")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val workflowId = "workflow-test"
|
||||
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
private val occurredAt = timestamp.toEpochMilliseconds()
|
||||
private val noopStore: ArtifactStore = object : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("art-1")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? = null
|
||||
@@ -68,15 +70,21 @@ class DomainEventMapperTest {
|
||||
|
||||
@Test
|
||||
fun `WorkflowStartedEvent maps to SessionStarted`(): Unit = runTest {
|
||||
val event = storedEvent(WorkflowStartedEvent(sessionId = sessionId, startStageId = stageId))
|
||||
val event =
|
||||
storedEvent(WorkflowStartedEvent(sessionId = sessionId, startStageId = stageId, workflowId = workflowId))
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), result)
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, workflowId), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowCompletedEvent maps to SessionCompleted`(): Unit = runTest {
|
||||
val event = storedEvent(
|
||||
WorkflowCompletedEvent(sessionId = sessionId, terminalStageId = stageId, totalStages = 1),
|
||||
WorkflowCompletedEvent(
|
||||
sessionId = sessionId,
|
||||
terminalStageId = stageId,
|
||||
totalStages = 1,
|
||||
workflowId = workflowId,
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.SessionCompleted(sessionId), result)
|
||||
@@ -99,7 +107,7 @@ class DomainEventMapperTest {
|
||||
TransitionExecutedEvent(sessionId = sessionId, from = from, to = to, transitionId = TransitionId("t1")),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.StageStarted(sessionId, to), result)
|
||||
assertEquals(ServerMessage.StageStarted(sessionId, to, occurredAt), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,7 +116,7 @@ class DomainEventMapperTest {
|
||||
StageCompletedEvent(sessionId = sessionId, stageId = stageId, transitionId = TransitionId("t1")),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.StageCompleted(sessionId, stageId), result)
|
||||
assertEquals(ServerMessage.StageCompleted(sessionId, stageId, occurredAt), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -119,7 +127,7 @@ class DomainEventMapperTest {
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.StageFailed(sessionId, stageId, "err"), result)
|
||||
assertEquals(ServerMessage.StageFailed(sessionId, stageId, "err", occurredAt), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -142,13 +150,15 @@ class DomainEventMapperTest {
|
||||
|
||||
@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 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)
|
||||
}
|
||||
@@ -161,45 +171,55 @@ class DomainEventMapperTest {
|
||||
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 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)
|
||||
assertEquals(
|
||||
ServerMessage.InferenceCompleted(sessionId, stageId, responseText, responseText, occurredAt),
|
||||
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 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)
|
||||
assertEquals(ServerMessage.InferenceCompleted(sessionId, stageId, "", "", occurredAt), 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 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)
|
||||
}
|
||||
@@ -207,16 +227,18 @@ class DomainEventMapperTest {
|
||||
@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 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)
|
||||
}
|
||||
@@ -239,7 +261,7 @@ class DomainEventMapperTest {
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines"), result)
|
||||
assertEquals(ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines", occurredAt), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -253,7 +275,7 @@ class DomainEventMapperTest {
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
assertEquals(ServerMessage.ToolFailed(sessionId, "file_write", "disk full"), result)
|
||||
assertEquals(ServerMessage.ToolFailed(sessionId, "file_write", "disk full", occurredAt), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -274,15 +296,17 @@ class DomainEventMapperTest {
|
||||
@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 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)
|
||||
|
||||
+11
-10
@@ -5,8 +5,8 @@ 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.StoredEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
@@ -27,6 +27,7 @@ class SessionEventBridgeTest {
|
||||
|
||||
private val sessionId = SessionId("session-1")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val workflowId = "workflow-test"
|
||||
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
private val noopArtifactStore: ArtifactStore = object : ArtifactStore {
|
||||
@@ -65,8 +66,8 @@ class SessionEventBridgeTest {
|
||||
@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),
|
||||
storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
@@ -75,7 +76,7 @@ class SessionEventBridgeTest {
|
||||
bridge.replaySnapshot()
|
||||
|
||||
assertEquals(2, sent.size)
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), sent[0])
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, workflowId), sent[0])
|
||||
assertEquals(ServerMessage.SessionCompleted(sessionId), sent[1])
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ class SessionEventBridgeTest {
|
||||
fun `replaySnapshot skips unmapped events`() = runTest {
|
||||
val events = listOf(
|
||||
storedEvent(OrchestrationResumedEvent(sessionId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
@@ -98,8 +99,8 @@ class SessionEventBridgeTest {
|
||||
@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),
|
||||
storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
|
||||
)
|
||||
val liveFlow: Flow<StoredEvent> = flow { events.forEach { emit(it) } }
|
||||
val store = fakeEventStore(liveFlow = liveFlow)
|
||||
@@ -109,7 +110,7 @@ class SessionEventBridgeTest {
|
||||
bridge.streamLive(sessionId)
|
||||
|
||||
assertEquals(2, sent.size)
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), sent[0])
|
||||
assertEquals(ServerMessage.SessionStarted(sessionId, workflowId), sent[0])
|
||||
assertEquals(ServerMessage.SessionCompleted(sessionId), sent[1])
|
||||
}
|
||||
|
||||
@@ -123,10 +124,10 @@ class SessionEventBridgeTest {
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
|
||||
channel.send(storedEvent(WorkflowStartedEvent(sessionId, stageId), seq = 1L))
|
||||
channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
|
||||
|
||||
val job = launch { bridge.streamLive(sessionId) }
|
||||
channel.send(storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L))
|
||||
channel.send(storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L))
|
||||
channel.close()
|
||||
job.join()
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ import dev.tamboui.terminal.Frame
|
||||
import dev.tamboui.tui.EventHandler
|
||||
import dev.tamboui.tui.Renderer
|
||||
import dev.tamboui.tui.TuiRunner
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
|
||||
private const val DEFAULT_PORT = 8080
|
||||
private const val STATUS_HEIGHT = 1
|
||||
@@ -70,7 +70,7 @@ fun main(args: Array<String>) {
|
||||
|
||||
val handler = EventHandler { event, _ ->
|
||||
if (event is TambouiKeyEvent) {
|
||||
mapKey(event, state.inputMode)
|
||||
mapKey(event)
|
||||
?.let { KeyResolver.resolve(it, state.inputMode, state.inputBuffer) }
|
||||
?.let(::dispatch)
|
||||
}
|
||||
|
||||
@@ -23,50 +23,11 @@ fun inputBarWidget(state: TuiState): Paragraph {
|
||||
?: "(no session)"
|
||||
|
||||
val (row1, row2) = when (state.inputMode) {
|
||||
InputMode.ROUTER -> {
|
||||
val cursor = if (state.inputBuffer.isNotEmpty()) {
|
||||
Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer))
|
||||
} else {
|
||||
Line.from(Span.raw("▌ "), Span.styled("Ask anything…", dimStyle))
|
||||
}
|
||||
val hints = buildList<String> {
|
||||
add("$sessionName · router")
|
||||
add("tab navigate")
|
||||
if (hasApproval) { add("ctrl+a approve"); add("ctrl+r reject") }
|
||||
if (hasSession) add("ctrl+s steer")
|
||||
add("ctrl+e events")
|
||||
add("ctrl+q")
|
||||
}.joinToString(" ")
|
||||
cursor to Line.from(Span.styled(hints, dimStyle))
|
||||
}
|
||||
InputMode.NAVIGATE -> {
|
||||
val row1Line = Line.from(Span.styled(" ↑↓ sessions enter select", dimStyle))
|
||||
val hints = buildList<String> {
|
||||
add("$sessionName · navigate")
|
||||
add("tab router")
|
||||
if (hasApproval) { add("ctrl+a approve"); add("ctrl+r reject") }
|
||||
if (hasSession) add("ctrl+s steer")
|
||||
add("ctrl+e events")
|
||||
add("ctrl+q")
|
||||
}.joinToString(" ")
|
||||
row1Line to Line.from(Span.styled(hints, dimStyle))
|
||||
}
|
||||
InputMode.FILTER -> {
|
||||
val cursor = if (state.inputBuffer.isNotEmpty()) {
|
||||
Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer))
|
||||
} else {
|
||||
Line.from(Span.raw("▌ "), Span.styled("/filter…", dimStyle))
|
||||
}
|
||||
cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle))
|
||||
}
|
||||
InputMode.STEER -> {
|
||||
val cursor = if (state.inputBuffer.isNotEmpty()) {
|
||||
Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer))
|
||||
} else {
|
||||
Line.from(Span.raw("▌ "), Span.styled("Steer the session…", dimStyle))
|
||||
}
|
||||
cursor to Line.from(Span.styled("$sessionName · steering esc cancel enter send", dimStyle))
|
||||
}
|
||||
InputMode.ROUTER -> createRowsForRouter(state, dimStyle, sessionName, hasApproval, hasSession)
|
||||
InputMode.NAVIGATE -> createRowsForNavigate(dimStyle, sessionName, hasApproval, hasSession)
|
||||
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
|
||||
InputMode.STEER -> createRowsForSteer(state, dimStyle, sessionName)
|
||||
|
||||
}
|
||||
|
||||
val block = Block.builder()
|
||||
@@ -77,3 +38,73 @@ fun inputBarWidget(state: TuiState): Paragraph {
|
||||
|
||||
return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build()
|
||||
}
|
||||
|
||||
private fun createRowsForSteer(
|
||||
state: TuiState,
|
||||
dimStyle: Style?,
|
||||
sessionName: String,
|
||||
): Pair<Line?, Line?> {
|
||||
val cursor = if (state.inputBuffer.isNotEmpty()) {
|
||||
Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer))
|
||||
} else {
|
||||
Line.from(Span.raw("▌ "), Span.styled("Steer the session…", dimStyle))
|
||||
}
|
||||
return cursor to Line.from(Span.styled("$sessionName · steering esc cancel enter send", dimStyle))
|
||||
}
|
||||
|
||||
private fun createRowsForFilter(
|
||||
state: TuiState,
|
||||
dimStyle: Style?,
|
||||
): Pair<Line?, Line?> {
|
||||
val cursor = if (state.inputBuffer.isNotEmpty()) {
|
||||
Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer))
|
||||
} else {
|
||||
Line.from(Span.raw("▌ "), Span.styled("/filter…", dimStyle))
|
||||
}
|
||||
return cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle))
|
||||
}
|
||||
|
||||
private fun createRowsForNavigate(
|
||||
dimStyle: Style?,
|
||||
sessionName: String,
|
||||
hasApproval: Boolean,
|
||||
hasSession: Boolean,
|
||||
): Pair<Line?, Line?> {
|
||||
val row1Line = Line.from(Span.styled(" ↑↓ sessions enter select", dimStyle))
|
||||
val hints = buildList<String> {
|
||||
add("$sessionName · navigate")
|
||||
add("tab router")
|
||||
if (hasApproval) {
|
||||
add("ctrl+a approve"); add("ctrl+r reject")
|
||||
}
|
||||
if (hasSession) add("ctrl+s steer")
|
||||
add("ctrl+e events")
|
||||
add("ctrl+q")
|
||||
}.joinToString(" ")
|
||||
return row1Line to Line.from(Span.styled(hints, dimStyle))
|
||||
}
|
||||
|
||||
private fun createRowsForRouter(
|
||||
state: TuiState,
|
||||
dimStyle: Style?,
|
||||
sessionName: String,
|
||||
hasApproval: Boolean,
|
||||
hasSession: Boolean,
|
||||
): Pair<Line?, Line?> {
|
||||
val cursor = if (state.inputBuffer.isNotEmpty()) {
|
||||
Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer))
|
||||
} else {
|
||||
Line.from(Span.raw("▌ "), Span.styled("Ask anything…", dimStyle))
|
||||
}
|
||||
val hints = buildList<String> {
|
||||
add("$sessionName · router")
|
||||
add("tab navigate")
|
||||
if (hasApproval) {
|
||||
add("ctrl+a approve"); add("ctrl+r reject")
|
||||
}
|
||||
if (hasSession) add("ctrl+s steer")
|
||||
add("ctrl+e events")
|
||||
add("ctrl+q")
|
||||
}.joinToString(" ")
|
||||
return cursor to Line.from(Span.styled(hints, dimStyle))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import dev.tamboui.style.Color
|
||||
import dev.tamboui.style.Style
|
||||
import dev.tamboui.text.Line
|
||||
import dev.tamboui.text.Span
|
||||
|
||||
@@ -28,7 +28,6 @@ fun sessionListWidget(state: TuiState): Paragraph {
|
||||
val isSelected = s.id == state.sessions.selectedId
|
||||
val prefix = if (isSelected) Span.styled("▶ ", Style.create().blue()) else Span.raw(" ")
|
||||
val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", dimStyle)
|
||||
val statusStr = s.status.uppercase().padEnd(STATUS_WIDTH)
|
||||
val statusSpan = statusSpan(s)
|
||||
val rawName = s.name.ifEmpty { s.workflowId }
|
||||
val truncated = if (rawName.length > NAME_MAX) rawName.take(NAME_MAX - 1) + "…" else rawName
|
||||
@@ -61,6 +60,7 @@ private fun statusSpan(s: SessionSummary): Span {
|
||||
|
||||
fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
|
||||
val f = sessions.filter
|
||||
return if (f.isEmpty()) sessions.sessions
|
||||
else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) }
|
||||
val sorted = sessions.sessions.sortedByDescending { it.lastEventAt }
|
||||
return if (f.isEmpty()) sorted
|
||||
else sorted.filter { it.workflowId.contains(f, ignoreCase = true) }
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.tui.KeyEvent
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import dev.tamboui.tui.event.KeyCode
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "ReturnCount")
|
||||
fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? {
|
||||
if (event.isCtrlC()) return KeyEvent.Quit
|
||||
fun mapKey(event: TambouiKeyEvent): KeyEvent? {
|
||||
if (event.isCtrlC) return KeyEvent.Quit
|
||||
if (event.hasAlt()) return null
|
||||
if (event.hasCtrl()) {
|
||||
return when (event.character()) {
|
||||
'q' -> KeyEvent.Quit
|
||||
'n' -> KeyEvent.NewSession
|
||||
'c' -> KeyEvent.Cancel
|
||||
'a' -> KeyEvent.Approve
|
||||
'r' -> KeyEvent.Reject
|
||||
's' -> KeyEvent.Steer
|
||||
'h' -> KeyEvent.ToggleApprovalOverlay
|
||||
'e' -> KeyEvent.ToggleEventOverlay
|
||||
return when {
|
||||
event.isChar('q') -> KeyEvent.Quit
|
||||
event.isChar('n') -> KeyEvent.NewSession
|
||||
event.isChar('c') -> KeyEvent.Cancel
|
||||
event.isChar('a') -> KeyEvent.Approve
|
||||
event.isChar('r') -> KeyEvent.Reject
|
||||
event.isChar('s') -> KeyEvent.Steer
|
||||
event.isChar('h') -> KeyEvent.ToggleApprovalOverlay
|
||||
event.isChar('e') -> KeyEvent.ToggleEventOverlay
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ object RootReducer {
|
||||
val (approval, ae) = ApprovalReducer.reduce(afterInput.approval, prevInputMode, action)
|
||||
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
|
||||
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
|
||||
val approvalJustArrived = approval.active != null && afterInput.approval.active == null
|
||||
val withSubReducers = afterInput.copy(
|
||||
sessions = sessions,
|
||||
approval = approval,
|
||||
connection = connection,
|
||||
provider = provider,
|
||||
approvalOverlayVisible = if (approvalJustArrived) true else afterInput.approvalOverlayVisible,
|
||||
)
|
||||
val final = when (action) {
|
||||
is Action.ToggleApprovalOverlay -> withSubReducers.copy(
|
||||
|
||||
@@ -25,8 +25,18 @@ object SessionsReducer {
|
||||
action: Action,
|
||||
clock: () -> Long = System::currentTimeMillis,
|
||||
): Pair<SessionsState, List<Effect>> = when (action) {
|
||||
is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) navigateUp(sessions) to emptyList() else sessions to emptyList()
|
||||
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) navigateDown(sessions) to emptyList() else sessions to emptyList()
|
||||
is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) {
|
||||
navigateUp(sessions) to emptyList()
|
||||
} else {
|
||||
sessions to emptyList()
|
||||
}
|
||||
|
||||
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) {
|
||||
navigateDown(sessions) to emptyList()
|
||||
} else {
|
||||
sessions to emptyList()
|
||||
}
|
||||
|
||||
is Action.SubmitInput -> if (inputMode == InputMode.FILTER) {
|
||||
sessions.copy(filter = inputText) to emptyList()
|
||||
} else {
|
||||
@@ -52,8 +62,12 @@ object SessionsReducer {
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
|
||||
private fun filteredSessions(sessions: SessionsState): List<SessionSummary> =
|
||||
if (sessions.filter.isBlank()) sessions.sessions
|
||||
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
|
||||
|
||||
private fun navigateUp(sessions: SessionsState): SessionsState {
|
||||
val list = sessions.sessions
|
||||
val list = filteredSessions(sessions)
|
||||
if (list.isEmpty()) return sessions
|
||||
val idx = list.indexOfFirst { it.id == sessions.selectedId }
|
||||
val newIdx = if (idx <= 0) list.lastIndex else idx - 1
|
||||
@@ -61,7 +75,7 @@ object SessionsReducer {
|
||||
}
|
||||
|
||||
private fun navigateDown(sessions: SessionsState): SessionsState {
|
||||
val list = sessions.sessions
|
||||
val list = filteredSessions(sessions)
|
||||
if (list.isEmpty()) return sessions
|
||||
val idx = list.indexOfFirst { it.id == sessions.selectedId }
|
||||
val newIdx = if (idx >= list.lastIndex) 0 else idx + 1
|
||||
@@ -77,194 +91,244 @@ object SessionsReducer {
|
||||
msg: ServerMessage,
|
||||
clock: () -> Long,
|
||||
): Pair<SessionsState, List<Effect>> = when (msg) {
|
||||
is ServerMessage.SessionStarted -> {
|
||||
val summary = SessionSummary(
|
||||
id = msg.sessionId.value,
|
||||
status = "ACTIVE",
|
||||
workflowId = msg.workflowId,
|
||||
name = msg.workflowId,
|
||||
lastEventAt = clock(),
|
||||
currentStage = null,
|
||||
lastOutput = null,
|
||||
)
|
||||
val selected = sessions.selectedId ?: msg.sessionId.value
|
||||
sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to
|
||||
listOf(Effect.ConnectSession(msg.sessionId))
|
||||
}
|
||||
|
||||
is ServerMessage.SessionPaused -> {
|
||||
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
|
||||
"PAUSED awaiting approval"
|
||||
} else {
|
||||
"PAUSED"
|
||||
}
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.SessionCompleted -> touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to listOf(
|
||||
Effect.DisconnectSession,
|
||||
)
|
||||
|
||||
is ServerMessage.SessionFailed -> touchSession(
|
||||
sessions,
|
||||
msg.sessionId.value,
|
||||
"FAILED",
|
||||
clock,
|
||||
) to listOf(Effect.DisconnectSession)
|
||||
|
||||
is ServerMessage.StageStarted -> {
|
||||
val now = clock()
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val entry = TuiEventEntry(formatTime(now), "StageStarted", msg.stageId.value)
|
||||
s.copy(
|
||||
currentStage = msg.stageId.value,
|
||||
currentStageId = msg.stageId.value,
|
||||
tools = emptyList(),
|
||||
recentEvents = (s.recentEvents + entry).takeLast(7),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.StageCompleted -> {
|
||||
val now = clock()
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val entry = TuiEventEntry(formatTime(now), "StageCompleted", msg.stageId.value)
|
||||
s.copy(
|
||||
currentStage = null,
|
||||
recentEvents = (s.recentEvents + entry).takeLast(7),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.StageFailed -> {
|
||||
val now = clock()
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val entry = TuiEventEntry(formatTime(now), "StageFailed", msg.stageId.value)
|
||||
s.copy(
|
||||
currentStage = null,
|
||||
recentEvents = (s.recentEvents + entry).takeLast(7),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.ToolStarted -> {
|
||||
val now = clock()
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val record = TuiToolRecord(msg.toolName, msg.tier.level, ToolDisplayStatus.STARTED, null)
|
||||
s.copy(
|
||||
tools = (s.tools + record).takeLast(8),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
|
||||
is ServerMessage.ToolCompleted -> {
|
||||
val now = clock()
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val updatedTools = s.tools.map { t ->
|
||||
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
|
||||
t.copy(status = ToolDisplayStatus.COMPLETED)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
val entry = TuiEventEntry(formatTime(now), "ToolCompleted", msg.toolName)
|
||||
s.copy(
|
||||
tools = updatedTools,
|
||||
lastOutput = "${msg.toolName}: ${msg.outputSummary}",
|
||||
recentEvents = (s.recentEvents + entry).takeLast(7),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.ToolFailed -> {
|
||||
val now = clock()
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val updatedTools = s.tools.map { t ->
|
||||
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
|
||||
t.copy(status = ToolDisplayStatus.FAILED)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
s.copy(tools = updatedTools, lastEventAt = now)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.ToolRejected -> {
|
||||
val now = clock()
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val updatedTools = s.tools.map { t ->
|
||||
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
|
||||
t.copy(status = ToolDisplayStatus.REJECTED)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
s.copy(tools = updatedTools, lastEventAt = now)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions)
|
||||
is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock)
|
||||
is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock)
|
||||
is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock)
|
||||
is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions)
|
||||
is ServerMessage.StageCompleted -> processStageCompletedMessage(msg, sessions)
|
||||
is ServerMessage.StageFailed -> processStageFailedMessage(msg, sessions)
|
||||
is ServerMessage.ToolStarted -> processToolStartedMessage(clock, sessions, msg)
|
||||
is ServerMessage.InferenceCompleted -> processInferenceCompletedMessage(sessions, msg)
|
||||
is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions)
|
||||
is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions)
|
||||
is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg)
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
|
||||
private fun applyInferenceCompleted(
|
||||
sessions: SessionsState,
|
||||
msg: ServerMessage.InferenceCompleted,
|
||||
private fun processToolRejectedMessage(
|
||||
clock: () -> Long,
|
||||
sessions: SessionsState,
|
||||
msg: ServerMessage.ToolRejected,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val now = clock()
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val updatedTools = s.tools.map { t ->
|
||||
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
|
||||
t.copy(status = ToolDisplayStatus.REJECTED)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
s.copy(tools = updatedTools, lastEventAt = now)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processToolFailedMessage(
|
||||
msg: ServerMessage.ToolFailed,
|
||||
sessions: SessionsState,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val now = msg.occurredAt
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val updatedTools = s.tools.map { t ->
|
||||
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
|
||||
t.copy(status = ToolDisplayStatus.FAILED)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
s.copy(tools = updatedTools, lastEventAt = now)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processToolCompletedMessage(
|
||||
msg: ServerMessage.ToolCompleted,
|
||||
sessions: SessionsState,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val now = msg.occurredAt
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val updatedTools = s.tools.map { t ->
|
||||
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
|
||||
t.copy(status = ToolDisplayStatus.COMPLETED)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
val entry = TuiEventEntry(formatTime(now), "ToolCompleted", msg.toolName)
|
||||
s.copy(
|
||||
tools = updatedTools,
|
||||
lastOutput = "${msg.toolName}: ${msg.outputSummary}",
|
||||
recentEvents = (s.recentEvents + entry).takeLast(7),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processToolStartedMessage(
|
||||
clock: () -> Long,
|
||||
sessions: SessionsState,
|
||||
msg: ServerMessage.ToolStarted,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val now = clock()
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val record = TuiToolRecord(msg.toolName, msg.tier.level, ToolDisplayStatus.STARTED, null)
|
||||
s.copy(
|
||||
tools = (s.tools + record).takeLast(8),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processStageFailedMessage(
|
||||
msg: ServerMessage.StageFailed,
|
||||
sessions: SessionsState,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val now = msg.occurredAt
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val entry = TuiEventEntry(formatTime(now), "StageFailed", msg.stageId.value)
|
||||
s.copy(
|
||||
currentStage = null,
|
||||
recentEvents = (s.recentEvents + entry).takeLast(7),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processStageCompletedMessage(
|
||||
msg: ServerMessage.StageCompleted,
|
||||
sessions: SessionsState,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val now = msg.occurredAt
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val entry = TuiEventEntry(formatTime(now), "StageCompleted", msg.stageId.value)
|
||||
s.copy(
|
||||
currentStage = null,
|
||||
recentEvents = (s.recentEvents + entry).takeLast(7),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processStageStartedMessage(
|
||||
msg: ServerMessage.StageStarted,
|
||||
sessions: SessionsState,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val now = msg.occurredAt
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
val entry = TuiEventEntry(formatTime(now), "StageStarted", msg.stageId.value)
|
||||
s.copy(
|
||||
currentStage = msg.stageId.value,
|
||||
currentStageId = msg.stageId.value,
|
||||
tools = emptyList(),
|
||||
recentEvents = (s.recentEvents + entry).takeLast(7),
|
||||
lastEventAt = now,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processSessionFailedMessage(
|
||||
sessions: SessionsState,
|
||||
msg: ServerMessage.SessionFailed,
|
||||
clock: () -> Long,
|
||||
): Pair<SessionsState, List<Effect.DisconnectSession>> = touchSession(
|
||||
sessions,
|
||||
msg.sessionId.value,
|
||||
"FAILED",
|
||||
clock,
|
||||
) to listOf(Effect.DisconnectSession)
|
||||
|
||||
private fun processSessionCompletedMessage(
|
||||
sessions: SessionsState,
|
||||
msg: ServerMessage.SessionCompleted,
|
||||
clock: () -> Long,
|
||||
): Pair<SessionsState, List<Effect.DisconnectSession>> =
|
||||
touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to listOf(
|
||||
Effect.DisconnectSession,
|
||||
)
|
||||
|
||||
private fun processSessionPausedMessage(
|
||||
msg: ServerMessage.SessionPaused,
|
||||
sessions: SessionsState,
|
||||
clock: () -> Long,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
|
||||
"PAUSED awaiting approval"
|
||||
} else {
|
||||
"PAUSED"
|
||||
}
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processSessionStartedMessage(
|
||||
msg: ServerMessage.SessionStarted,
|
||||
clock: () -> Long,
|
||||
sessions: SessionsState,
|
||||
): Pair<SessionsState, List<Effect.ConnectSession>> {
|
||||
val summary = SessionSummary(
|
||||
id = msg.sessionId.value,
|
||||
status = "ACTIVE",
|
||||
workflowId = msg.workflowId,
|
||||
name = msg.workflowId,
|
||||
lastEventAt = clock(),
|
||||
currentStage = null,
|
||||
lastOutput = null,
|
||||
)
|
||||
val selected = sessions.selectedId ?: msg.sessionId.value
|
||||
return sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to
|
||||
listOf(Effect.ConnectSession(msg.sessionId))
|
||||
}
|
||||
|
||||
private fun processInferenceCompletedMessage(
|
||||
sessions: SessionsState,
|
||||
msg: ServerMessage.InferenceCompleted,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val now = msg.occurredAt
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.tui.KeyEvent
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import dev.tamboui.tui.event.KeyCode
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
import dev.tamboui.tui.event.KeyModifiers
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
|
||||
class TambouiKeyMapperTest {
|
||||
|
||||
@@ -15,156 +14,156 @@ class TambouiKeyMapperTest {
|
||||
|
||||
@Test
|
||||
fun `ctrl-c maps to Quit in InputMode ROUTER`() {
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-c maps to Quit in InputMode FILTER`() {
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.FILTER))
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-c maps to Quit in InputMode NAVIGATE`() {
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.NAVIGATE))
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-c maps to Quit in InputMode STEER`() {
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.STEER))
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
|
||||
}
|
||||
|
||||
// --- Ctrl+key → keybind events ---
|
||||
|
||||
@Test
|
||||
fun `ctrl-q maps to Quit`() {
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-a maps to Approve`() {
|
||||
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-r maps to Reject`() {
|
||||
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'r'.code), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'r'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-n maps to NewSession`() {
|
||||
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-s maps to Steer`() {
|
||||
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-h maps to ToggleApprovalOverlay`() {
|
||||
assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-e maps to ToggleEventOverlay`() {
|
||||
assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl+unbound key maps to null`() {
|
||||
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'z'.code), InputMode.ROUTER))
|
||||
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'z'.code)))
|
||||
}
|
||||
|
||||
// --- Alt always → null ---
|
||||
|
||||
@Test
|
||||
fun `alt modifier maps to null`() {
|
||||
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code), InputMode.ROUTER))
|
||||
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code)))
|
||||
}
|
||||
|
||||
// --- Bare chars always → CharInput (never trigger keybinds) ---
|
||||
|
||||
@Test
|
||||
fun `bare a maps to CharInput in ROUTER`() {
|
||||
assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a')))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare q maps to CharInput in ROUTER`() {
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare s maps to CharInput in ROUTER`() {
|
||||
assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s')))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare x maps to CharInput in ROUTER`() {
|
||||
assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x')))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare q maps to CharInput in FILTER`() {
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.FILTER))
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare q maps to CharInput in STEER`() {
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.STEER))
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare q maps to CharInput in NAVIGATE`() {
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.NAVIGATE))
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
|
||||
}
|
||||
|
||||
// --- KeyCode mappings ---
|
||||
|
||||
@Test
|
||||
fun `UP maps to NavUp`() {
|
||||
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DOWN maps to NavDown`() {
|
||||
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ENTER maps to Enter`() {
|
||||
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ESCAPE maps to Escape`() {
|
||||
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BACKSPACE maps to Backspace`() {
|
||||
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TAB maps to Filter`() {
|
||||
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.ROUTER))
|
||||
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TAB maps to Filter in FILTER mode`() {
|
||||
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.FILTER))
|
||||
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
|
||||
}
|
||||
|
||||
// --- ISO control chars → null ---
|
||||
|
||||
@Test
|
||||
fun `ESC codepoint as CHAR event maps to null`() {
|
||||
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B), InputMode.ROUTER))
|
||||
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NUL codepoint as CHAR event maps to null`() {
|
||||
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00), InputMode.ROUTER))
|
||||
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,11 @@ class RootReducerTest {
|
||||
)
|
||||
val (state2, _) = RootReducer.reduce(state1, Action.ServerEventReceived(startMsg2), fixedClock)
|
||||
// selected is s1 (first); navigating down should move to s2
|
||||
val (state3, _) = RootReducer.reduce(state2.copy(inputMode = InputMode.NAVIGATE), Action.NavigateDown, fixedClock)
|
||||
val (state3, _) = RootReducer.reduce(
|
||||
state2.copy(inputMode = InputMode.NAVIGATE),
|
||||
Action.NavigateDown,
|
||||
fixedClock,
|
||||
)
|
||||
assertEquals("s2", state3.sessions.selectedId)
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,11 @@ class SessionsReducerTest {
|
||||
sessions = listOf(session("s1").copy(currentStage = "stage-1")),
|
||||
selectedId = "s1",
|
||||
)
|
||||
val msg = ServerMessage.StageCompleted(sessionId = SessionId("s1"), stageId = StageId("stage-1"))
|
||||
val msg = ServerMessage.StageCompleted(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-1"),
|
||||
occurredAt = fixedClock(),
|
||||
)
|
||||
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
|
||||
assertEquals(null, state.sessions[0].currentStage)
|
||||
assertEquals(1000L, state.sessions[0].lastEventAt)
|
||||
@@ -148,6 +152,7 @@ class SessionsReducerTest {
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-1"),
|
||||
reason = "error",
|
||||
occurredAt = fixedClock(),
|
||||
)
|
||||
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
|
||||
assertEquals(null, state.sessions[0].currentStage)
|
||||
@@ -177,6 +182,7 @@ class SessionsReducerTest {
|
||||
val msg = ServerMessage.InferenceCompleted(
|
||||
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
|
||||
outputSummary = "summary", responseText = "full response",
|
||||
occurredAt = fixedClock(),
|
||||
)
|
||||
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
|
||||
assertEquals("summary", state.sessions[0].lastOutput)
|
||||
@@ -190,6 +196,7 @@ class SessionsReducerTest {
|
||||
val msg = ServerMessage.InferenceCompleted(
|
||||
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
|
||||
outputSummary = "", responseText = "",
|
||||
occurredAt = fixedClock(),
|
||||
)
|
||||
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
|
||||
assertEquals(null, state.sessions[0].lastResponseText)
|
||||
@@ -198,7 +205,11 @@ class SessionsReducerTest {
|
||||
@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 msg = ServerMessage.StageStarted(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-1"),
|
||||
occurredAt = fixedClock(),
|
||||
)
|
||||
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
|
||||
assertEquals("stage-1", state.sessions[0].currentStage)
|
||||
assertEquals(1000L, state.sessions[0].lastEventAt)
|
||||
@@ -207,7 +218,12 @@ class SessionsReducerTest {
|
||||
@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 msg = ServerMessage.ToolCompleted(
|
||||
sessionId = SessionId("s1"),
|
||||
toolName = "file_write",
|
||||
outputSummary = "wrote 3 lines",
|
||||
occurredAt = fixedClock(),
|
||||
)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user