feat(tui): event-derived ordering + retire Kotlin TUI

Rebuild the Go TUI transcript from the event stream instead of
optimistic local echoes. ChatTurnEvent now maps to ServerMessage.ChatTurn
(chat.turn); the TUI auto-vivifies sessions and renders user+router turns
from events. SessionStarted is de-special-cased into SessionAnnounced
(still carries workflowId), dropping the router.response shadow and
optimistic echoes.

Delete the unused Kotlin TUI (apps/tui) and add a kotlinx<->Go
golden-fixture test to lock the wire protocol. Gitignore server runtime
logs.
This commit is contained in:
2026-06-02 23:38:20 +04:00
parent b2f60d09bb
commit 1017bfffef
60 changed files with 231 additions and 4813 deletions
+3
View File
@@ -78,3 +78,6 @@ apps/tui-go/bin/
# TUI runtime logs (Kotlin TUI, pre-Go rewrite) # TUI runtime logs (Kotlin TUI, pre-Go rewrite)
apps/tui/logs/ apps/tui/logs/
# Server runtime logs
apps/server/logs/
@@ -10,6 +10,7 @@ import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.InferenceStartedEvent import com.correx.core.events.events.InferenceStartedEvent
@@ -54,20 +55,29 @@ suspend fun domainEventToServerMessage(
): ServerMessage? { ): ServerMessage? {
val seq = event.sequence val seq = event.sequence
return when (val p = event.payload) { return when (val p = event.payload) {
is ChatSessionStartedEvent -> ServerMessage.SessionStarted( is ChatSessionStartedEvent -> ServerMessage.SessionAnnounced(
sessionId = p.sessionId, sessionId = p.sessionId,
workflowId = "chat", workflowId = "chat",
sequence = seq, sequence = seq,
sessionSequence = sessionSequence, sessionSequence = sessionSequence,
) )
is WorkflowStartedEvent -> ServerMessage.SessionStarted( is WorkflowStartedEvent -> ServerMessage.SessionAnnounced(
sessionId = p.sessionId, sessionId = p.sessionId,
workflowId = p.workflowId, workflowId = p.workflowId,
sequence = seq, sequence = seq,
sessionSequence = sessionSequence, sessionSequence = sessionSequence,
) )
is ChatTurnEvent -> ServerMessage.ChatTurn(
sessionId = p.sessionId,
turnId = p.turnId,
role = p.role.name,
content = p.content,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted( is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(
sessionId = p.sessionId, sessionId = p.sessionId,
sequence = seq, sequence = seq,
@@ -33,18 +33,34 @@ sealed interface ServerMessage {
// -- Session lifecycle -- // -- Session lifecycle --
/** /**
* @see SessionStartedEvent — emitted when a session begins. * Event-derived session announcement: carries the session's [workflowId] so the client
* TODO(cleanup-NN): retire SessionStarted once TUI migrates to event-derived ordering. * can name it. Derived from WorkflowStartedEvent / ChatSessionStartedEvent. The client
* applies it through the same auto-vivify path as any other event (no special-casing).
*/ */
@Serializable @Serializable
@SerialName("session.started") @SerialName("session.announced")
data class SessionStarted( data class SessionAnnounced(
val sessionId: SessionId, val sessionId: SessionId,
val workflowId: String, val workflowId: String,
override val sequence: Long, override val sequence: Long,
override val sessionSequence: Long, override val sessionSequence: Long,
) : ServerMessage, SessionMessage ) : ServerMessage, SessionMessage
/**
* Event-derived conversation turn (USER or ROUTER), from ChatTurnEvent. The client
* rebuilds the chat transcript from these, ordered by [sessionSequence].
*/
@Serializable
@SerialName("chat.turn")
data class ChatTurn(
val sessionId: SessionId,
val turnId: String,
val role: String,
val content: String,
override val sequence: Long,
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
@Serializable @Serializable
@SerialName("session.paused") @SerialName("session.paused")
data class SessionPaused( data class SessionPaused(
@@ -193,14 +193,11 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame) is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame) is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
is ClientMessage.ChatInput -> { is ClientMessage.ChatInput -> {
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
// and reach the client as chat.turn frames via streamGlobal — no direct
// RouterResponseMessage (the conversation is event-derived).
runCatching { runCatching {
module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode) module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode)
}.onSuccess { response ->
sendFrame(ServerMessage.RouterResponseMessage(
sessionId = msg.sessionId,
content = response.content,
steeringEmitted = response.steeringEmitted,
))
}.onFailure { }.onFailure {
log.error("routerFacade.onUserInput failed: {}", it.message) log.error("routerFacade.onUserInput failed: {}", it.message)
sendFrame(ServerMessage.ProtocolError("Router error: ${it.message}")) sendFrame(ServerMessage.ProtocolError("Router error: ${it.message}"))
@@ -327,7 +324,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
log.info("starting chat session={}", sessionId.value) log.info("starting chat session={}", sessionId.value)
// Emit ChatSessionStartedEvent for the new session (no graph, no orchestrator). // Emit ChatSessionStartedEvent for the new session (no graph, no orchestrator).
// streamGlobal picks this up and maps it to SessionStarted with the real sequence. // streamGlobal maps it to a session.announced frame with the real sequence, and the
// USER/ROUTER turns from onUserInput stream as chat.turn frames — all event-derived.
module.eventStore.append(NewEvent( module.eventStore.append(NewEvent(
metadata = EventMetadata( metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()), eventId = EventId(UUID.randomUUID().toString()),
@@ -340,15 +338,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
payload = ChatSessionStartedEvent(sessionId = sessionId), payload = ChatSessionStartedEvent(sessionId = sessionId),
)) ))
// Call router and send response.
runCatching { runCatching {
module.routerFacade.onUserInput(sessionId, msg.text) module.routerFacade.onUserInput(sessionId, msg.text)
}.onSuccess { response ->
sendFrame(ServerMessage.RouterResponseMessage(
sessionId = sessionId,
content = response.content,
steeringEmitted = response.steeringEmitted,
))
}.onFailure { }.onFailure {
log.error("routerFacade.onUserInput failed: {}", it.message) log.error("routerFacade.onUserInput failed: {}", it.message)
sendFrame(ServerMessage.ProtocolError( sendFrame(ServerMessage.ProtocolError(
@@ -381,9 +372,9 @@ class GlobalStreamHandler(private val module: ServerModule) {
// Send StageToolManifest FIRST. If the WS is already closed, this throws and the // Send StageToolManifest FIRST. If the WS is already closed, this throws and the
// orchestrator never launches — no silent data loss. The exception propagates to the // orchestrator never launches — no silent data loss. The exception propagates to the
// runCatching in handleClientMessage (caller) and is logged there. // runCatching in handleClientMessage (caller) and is logged there.
// SessionStarted is not sent here — it is derived from WorkflowStartedEvent // No session announce is sent here — it is derived from WorkflowStartedEvent by
// by streamGlobal via DomainEventMapper, ensuring exactly one SessionStarted // streamGlobal via DomainEventMapper (one session.announced per session with the
// per session with the correct sequence number (P0-5). // correct sequence number).
val stages = graph.stages.map { (stageId, stageConfig) -> val stages = graph.stages.map { (stageId, stageConfig) ->
val tools = stageConfig.allowedTools.mapNotNull { toolName -> val tools = stageConfig.allowedTools.mapNotNull { toolName ->
module.toolRegistry.resolve(toolName)?.let { tool -> module.toolRegistry.resolve(toolName)?.let { tool ->
@@ -9,6 +9,8 @@ import com.correx.core.approvals.Tier
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ChatTurnRole
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceStartedEvent import com.correx.core.events.events.InferenceStartedEvent
@@ -88,12 +90,30 @@ class DomainEventMapperTest {
) )
@Test @Test
fun `WorkflowStartedEvent maps to SessionStarted`(): Unit = runTest { fun `WorkflowStartedEvent maps to SessionAnnounced`(): Unit = runTest {
val event = val event =
storedEvent(WorkflowStartedEvent(sessionId = sessionId, startStageId = stageId, workflowId = workflowId)) storedEvent(WorkflowStartedEvent(sessionId = sessionId, startStageId = stageId, workflowId = workflowId))
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals( assertEquals(
ServerMessage.SessionStarted(sessionId, workflowId, event.sequence, 0L), ServerMessage.SessionAnnounced(sessionId, workflowId, event.sequence, 0L),
result,
)
}
@Test
fun `ChatTurnEvent maps to ChatTurn`(): Unit = runTest {
val event = storedEvent(
ChatTurnEvent(
sessionId = sessionId,
turnId = "turn-1",
role = ChatTurnRole.ROUTER,
content = "hello",
timestampMs = 1L,
),
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 3L)
assertEquals(
ServerMessage.ChatTurn(sessionId, "turn-1", "ROUTER", "hello", event.sequence, 3L),
result, result,
) )
} }
@@ -13,21 +13,38 @@ class ServerMessageSerializationTest {
} }
@Test @Test
fun `SessionStarted encodes sequence and sessionSequence`() { fun `SessionAnnounced encodes sequence and sessionSequence`() {
val msg = ServerMessage.SessionStarted( val msg = ServerMessage.SessionAnnounced(
sessionId = SessionId("sess-1"), sessionId = SessionId("sess-1"),
workflowId = "wf-1", workflowId = "wf-1",
sequence = 42, sequence = 42,
sessionSequence = 7, sessionSequence = 7,
) )
val jsonStr = ProtocolSerializer.encodeServerMessage(msg) val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
assert(jsonStr.contains("\"type\":\"session.started\"")) { "expected type=session.started" } assert(jsonStr.contains("\"type\":\"session.announced\"")) { "expected type=session.announced" }
assert(jsonStr.contains("\"sessionId\":\"sess-1\"")) { "expected sessionId" } assert(jsonStr.contains("\"sessionId\":\"sess-1\"")) { "expected sessionId" }
assert(jsonStr.contains("\"workflowId\":\"wf-1\"")) { "expected workflowId" } assert(jsonStr.contains("\"workflowId\":\"wf-1\"")) { "expected workflowId" }
assert(jsonStr.contains("\"sequence\":42")) { "expected sequence=42" } assert(jsonStr.contains("\"sequence\":42")) { "expected sequence=42" }
assert(jsonStr.contains("\"sessionSequence\":7")) { "expected sessionSequence=7" } assert(jsonStr.contains("\"sessionSequence\":7")) { "expected sessionSequence=7" }
} }
@Test
fun `ChatTurn encodes role content and cursors`() {
val msg = ServerMessage.ChatTurn(
sessionId = SessionId("sess-1"),
turnId = "turn-9",
role = "ROUTER",
content = "hi there",
sequence = 5,
sessionSequence = 2,
)
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
assert(jsonStr.contains("\"type\":\"chat.turn\"")) { "expected type=chat.turn" }
assert(jsonStr.contains("\"turnId\":\"turn-9\"")) { "expected turnId" }
assert(jsonStr.contains("\"role\":\"ROUTER\"")) { "expected role" }
assert(jsonStr.contains("\"content\":\"hi there\"")) { "expected content" }
}
@Test @Test
fun `SnapshotComplete encodes with only type field`() { fun `SnapshotComplete encodes with only type field`() {
val msg = ServerMessage.SnapshotComplete val msg = ServerMessage.SnapshotComplete
@@ -57,15 +74,15 @@ class ServerMessageSerializationTest {
} }
@Test @Test
fun `SessionStarted round-trips through JSON`() { fun `SessionAnnounced round-trips through JSON`() {
val original = ServerMessage.SessionStarted( val original = ServerMessage.SessionAnnounced(
sessionId = SessionId("sess-42"), sessionId = SessionId("sess-42"),
workflowId = "wf-abc", workflowId = "wf-abc",
sequence = 200, sequence = 200,
sessionSequence = 33, sessionSequence = 33,
) )
val jsonStr = ProtocolSerializer.encodeServerMessage(original) val jsonStr = ProtocolSerializer.encodeServerMessage(original)
val decoded = json.decodeFromString<ServerMessage.SessionStarted>(jsonStr) val decoded = json.decodeFromString<ServerMessage.SessionAnnounced>(jsonStr)
assertEquals(original.sessionId, decoded.sessionId) assertEquals(original.sessionId, decoded.sessionId)
assertEquals(original.workflowId, decoded.workflowId) assertEquals(original.workflowId, decoded.workflowId)
assertEquals(200L, decoded.sequence) assertEquals(200L, decoded.sequence)
@@ -158,7 +158,7 @@ class GlobalStreamHandlerTest {
// Then 3 WorkflowStarted messages (seq 6, 7, 8) // Then 3 WorkflowStarted messages (seq 6, 7, 8)
val live = messages.drop(2) val live = messages.drop(2)
assertEquals(3, live.size) assertEquals(3, live.size)
live.forEach { assertInstanceOf(ServerMessage.SessionStarted::class.java, it) } live.forEach { assertInstanceOf(ServerMessage.SessionAnnounced::class.java, it) }
} }
@Test @Test
@@ -198,7 +198,7 @@ class GlobalStreamHandlerTest {
} }
val completeIdx = messages.indexOf(ServerMessage.SnapshotComplete) val completeIdx = messages.indexOf(ServerMessage.SnapshotComplete)
val afterComplete = messages.drop(completeIdx + 1) val afterComplete = messages.drop(completeIdx + 1)
assert(afterComplete.any { msg -> msg is ServerMessage.SessionStarted }) { assert(afterComplete.any { msg -> msg is ServerMessage.SessionAnnounced }) {
"Race event (seq=6) was lost in iteration $it! Messages: $messages" "Race event (seq=6) was lost in iteration $it! Messages: $messages"
} }
} }
@@ -232,7 +232,7 @@ class GlobalStreamHandlerTest {
assertEquals(ServerMessage.SnapshotComplete, messages[0]) assertEquals(ServerMessage.SnapshotComplete, messages[0])
assertEquals(2, messages.size) assertEquals(2, messages.size)
assertInstanceOf(ServerMessage.SessionStarted::class.java, messages[1]) assertInstanceOf(ServerMessage.SessionAnnounced::class.java, messages[1])
} }
@Test @Test
+13
View File
@@ -237,6 +237,19 @@ func (m Model) session(id string) *Session {
return nil return nil
} }
// ensureSession returns the session with id, creating an ACTIVE entry if absent.
// Session existence is derived from the event stream (any event for an unknown
// session vivifies it) rather than a dedicated control frame.
func (m *Model) ensureSession(id string) *Session {
if s := m.session(id); s != nil {
return s
}
m.sessions = append(m.sessions, Session{
ID: id, Status: "ACTIVE", LastEventAt: nowMillis(),
})
return &m.sessions[len(m.sessions)-1]
}
// filteredSessions applies the workflow-id filter. // filteredSessions applies the workflow-id filter.
func (m Model) filteredSessions() []Session { func (m Model) filteredSessions() []Session {
if m.filter == "" { if m.filter == "" {
+29 -24
View File
@@ -50,9 +50,24 @@ func inferCategory(t string) string {
// applyServer mutates the model for a single (non-buffered) server message. // applyServer mutates the model for a single (non-buffered) server message.
func (m *Model) applyServer(msg protocol.ServerMessage) { func (m *Model) applyServer(msg protocol.ServerMessage) {
debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason) debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason)
// Auto-vivify: any event for an unknown session creates it, so session
// existence is derived from the event stream, not a dedicated control frame.
if msg.IsEventBearing() && msg.SessionID != "" {
m.ensureSession(msg.SessionID)
}
switch msg.Type { switch msg.Type {
case protocol.TypeSessionStarted: case protocol.TypeSessionAnnounced:
m.onSessionStarted(msg) m.onSessionAnnounced(msg)
case protocol.TypeChatTurn:
m.routerConnected = true
role := "router"
if msg.Role == "USER" {
role = "user"
}
m.appendRouter(msg.SessionID, RouterEntry{role, msg.Content})
if s := m.session(msg.SessionID); s != nil {
s.LastEventAt = nowMillis()
}
case protocol.TypeSessionPaused: case protocol.TypeSessionPaused:
label := "PAUSED" label := "PAUSED"
if msg.Reason == "APPROVAL_PENDING" { if msg.Reason == "APPROVAL_PENDING" {
@@ -183,9 +198,6 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
} else { } else {
m.providerType = "REMOTE" m.providerType = "REMOTE"
} }
case protocol.TypeRouterResponse:
m.routerConnected = true
m.appendRouter(msg.SessionID, RouterEntry{"router", msg.Content})
case protocol.TypeWorkflowList: case protocol.TypeWorkflowList:
m.workflows = m.workflows[:0] m.workflows = m.workflows[:0]
for _, w := range msg.Workflows { for _, w := range msg.Workflows {
@@ -227,30 +239,23 @@ func sessionIDOf(msg protocol.ServerMessage) string {
} }
} }
func (m *Model) onSessionStarted(msg protocol.ServerMessage) { // onSessionAnnounced fills in a session's workflow identity (the announce is the
hadOptimistic := false // only event carrying workflowId) and applies auto-focus. The session entry itself
cleaned := m.sessions[:0:0] // was already created by the auto-vivify path in applyServer.
for _, s := range m.sessions { func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) {
if s.Status == "STARTING" { s := m.ensureSession(msg.SessionID)
hadOptimistic = true s.Status = "ACTIVE"
continue if msg.WorkflowID != "" {
} s.WorkflowID = msg.WorkflowID
cleaned = append(cleaned, s) s.Name = msg.WorkflowID
}
cleaned = append(cleaned, Session{
ID: msg.SessionID, Status: "ACTIVE", WorkflowID: msg.WorkflowID,
Name: msg.WorkflowID, LastEventAt: nowMillis(),
})
m.sessions = cleaned
if hadOptimistic || m.selectedID == "" {
m.selectedID = msg.SessionID
} }
s.LastEventAt = nowMillis()
if m.pendingWorkflowFocus { if m.pendingWorkflowFocus {
m.selectedID = msg.SessionID m.selectedID = msg.SessionID
m.sessionEntered = true m.sessionEntered = true
m.pendingWorkflowFocus = false m.pendingWorkflowFocus = false
} else { } else if m.selectedID == "" {
m.sessionEntered = true m.selectedID = msg.SessionID
} }
} }
+6 -7
View File
@@ -545,15 +545,14 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
if text == "" { if text == "" {
return m, nil return m, nil
} }
// New optimistic chat session. // New chat session. The client owns the id, so create the entry locally and
// focus it; the USER/ROUTER turns arrive as chat.turn events (no optimistic echo).
id := newSessionID() id := newSessionID()
m.sessions = append(m.sessions, Session{ s := m.ensureSession(id)
ID: id, Status: "STARTING", WorkflowID: "chat", Name: "chat", s.WorkflowID = "chat"
LastEventAt: nowMillis(), s.Name = "chat"
})
m.selectedID = id m.selectedID = id
m.sessionEntered = true m.sessionEntered = true
m.appendRouter(id, RouterEntry{"user", text})
m.client.Send(protocol.StartChatSession(id, text)) m.client.Send(protocol.StartChatSession(id, text))
m.clearInput() m.clearInput()
return m, nil return m, nil
@@ -569,7 +568,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
hist = hist[len(hist)-50:] hist = hist[len(hist)-50:]
} }
m.history[sid] = hist m.history[sid] = hist
m.appendRouter(sid, RouterEntry{"user", text}) // No optimistic echo — the USER turn arrives as a chat.turn event.
m.client.Send(protocol.ChatInput(sid, text, m.chatMode)) m.client.Send(protocol.ChatInput(sid, text, m.chatMode))
m.clearInput() m.clearInput()
return m, nil return m, nil
@@ -0,0 +1,86 @@
package protocol
import "testing"
// Golden wire frames as emitted by the Kotlin server (kotlinx.serialization). These
// strings are the cross-language contract: the Kotlin side pins the encode in
// ServerMessageSerializationTest; this test pins the Go decode of the exact same bytes,
// so a discriminator (@SerialName) or field-name drift fails loudly on one side.
func TestDecodeGoldenServerFrames(t *testing.T) {
cases := []struct {
name string
json string
wantType string
eventBear bool
check func(t *testing.T, m ServerMessage)
}{
{
name: "session.announced",
json: `{"type":"session.announced","sessionId":"s1","workflowId":"healthcheck","sequence":6,"sessionSequence":1}`,
wantType: TypeSessionAnnounced,
eventBear: true,
check: func(t *testing.T, m ServerMessage) {
if m.SessionID != "s1" || m.WorkflowID != "healthcheck" {
t.Fatalf("session.announced fields: %+v", m)
}
},
},
{
name: "chat.turn",
json: `{"type":"chat.turn","sessionId":"s1","turnId":"t9","role":"ROUTER","content":"hi there","sequence":7,"sessionSequence":2}`,
wantType: TypeChatTurn,
eventBear: true,
check: func(t *testing.T, m ServerMessage) {
if m.Role != "ROUTER" || m.Content != "hi there" || m.TurnID != "t9" {
t.Fatalf("chat.turn fields: %+v", m)
}
},
},
{
name: "stage.started",
json: `{"type":"stage.started","sessionId":"s1","stageId":"write_script","occurredAt":123,"sequence":8,"sessionSequence":3}`,
wantType: TypeStageStarted,
eventBear: true,
check: func(t *testing.T, m ServerMessage) {
if m.StageID != "write_script" {
t.Fatalf("stage.started fields: %+v", m)
}
},
},
{
name: "tool.completed",
json: `{"type":"tool.completed","sessionId":"s1","toolName":"shell","outputSummary":"ok","occurredAt":1,"diff":null,"sequence":9,"sessionSequence":4}`,
wantType: TypeToolCompleted,
eventBear: true,
check: func(t *testing.T, m ServerMessage) {
if m.ToolName != "shell" || m.Summary != "ok" {
t.Fatalf("tool.completed fields: %+v", m)
}
},
},
{
name: "snapshot_complete (non-event control frame)",
json: `{"type":"snapshot_complete"}`,
wantType: TypeSnapshotComplete,
eventBear: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m, err := Decode([]byte(c.json))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if m.Type != c.wantType {
t.Fatalf("type = %q, want %q", m.Type, c.wantType)
}
if m.IsEventBearing() != c.eventBear {
t.Fatalf("IsEventBearing = %v, want %v", m.IsEventBearing(), c.eventBear)
}
if c.check != nil {
c.check(t, m)
}
})
}
}
+6 -3
View File
@@ -1,7 +1,7 @@
// Package protocol implements the correx WebSocket wire contract used to talk // Package protocol implements the correx WebSocket wire contract used to talk
// to apps/server over /stream. The server (Kotlin/kotlinx.serialization) uses a // to apps/server over /stream. The server (Kotlin/kotlinx.serialization) uses a
// "type" class discriminator. Server->client variants carry @SerialName values // "type" class discriminator. Server->client variants carry @SerialName values
// (e.g. "session.started"); client->server variants have no @SerialName, so the // (e.g. "session.announced"); client->server variants have no @SerialName, so the
// discriminator is the fully-qualified Kotlin class name. // discriminator is the fully-qualified Kotlin class name.
package protocol package protocol
@@ -13,7 +13,8 @@ const clientPrefix = "com.correx.apps.server.protocol.ClientMessage."
// Server message discriminator values (@SerialName on ServerMessage variants). // Server message discriminator values (@SerialName on ServerMessage variants).
const ( const (
TypeSessionStarted = "session.started" TypeSessionAnnounced = "session.announced"
TypeChatTurn = "chat.turn"
TypeSessionPaused = "session.paused" TypeSessionPaused = "session.paused"
TypeSessionResumed = "session.resumed" TypeSessionResumed = "session.resumed"
TypeSessionCompleted = "session.completed" TypeSessionCompleted = "session.completed"
@@ -54,6 +55,8 @@ type ServerMessage struct {
WorkflowID string `json:"workflowId"` WorkflowID string `json:"workflowId"`
StageID string `json:"stageId"` StageID string `json:"stageId"`
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
Role string `json:"role"`
TurnID string `json:"turnId"`
Reason string `json:"reason"` Reason string `json:"reason"`
Summary string `json:"outputSummary"` Summary string `json:"outputSummary"`
Response string `json:"responseText"` Response string `json:"responseText"`
@@ -168,7 +171,7 @@ func Decode(raw []byte) (ServerMessage, error) {
// and are applied immediately. // and are applied immediately.
func (m ServerMessage) IsEventBearing() bool { func (m ServerMessage) IsEventBearing() bool {
switch m.Type { switch m.Type {
case TypeSessionStarted, TypeSessionPaused, TypeSessionResumed, case TypeSessionAnnounced, TypeChatTurn, TypeSessionPaused, TypeSessionResumed,
TypeSessionCompleted, TypeSessionFailed, TypeSessionCompleted, TypeSessionFailed,
TypeStageStarted, TypeStageCompleted, TypeStageFailed, TypeStageStarted, TypeStageCompleted, TypeStageFailed,
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout, TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
-73
View File
@@ -1,73 +0,0 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'application'
}
application {
mainClass = 'com.correx.apps.tui.TuiAppKt'
}
repositories {
maven {
url 'https://central.sonatype.com/repository/maven-snapshots/'
mavenContent { snapshotsOnly() }
}
}
configurations.all {
resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
}
dependencies {
implementation project(':apps:server')
implementation project(':core:events')
implementation project(':core:approvals')
implementation project(':core:router')
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-websockets:$ktor_version"
implementation "io.ktor:ktor-client-logging:$ktor_version"
implementation platform('dev.tamboui:tamboui-bom:0.3.0')
implementation 'dev.tamboui:tamboui-tui'
implementation 'dev.tamboui:tamboui-widgets'
implementation 'dev.tamboui:tamboui-core'
runtimeOnly 'dev.tamboui:tamboui-jline3-backend'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version"
testImplementation "org.jetbrains.kotlin:kotlin-test"
testImplementation "org.jetbrains.kotlin:kotlin-test-junit5"
testImplementation "org.junit.jupiter:junit-jupiter:5.10.2"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.2"
}
tasks.named('test') { useJUnitPlatform() }
tasks.register('tuiStartScripts', CreateStartScripts) {
mainClass = 'com.correx.apps.tui.TuiAppKt'
applicationName = 'tui'
outputDir = file("$buildDir/scripts-tui")
classpath = tasks.named('startScripts').get().classpath
}
distributions.named('main').configure {
contents {
from(tasks.named('tuiStartScripts')) { into 'bin' }
}
}
tasks.withType(Tar).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
tasks.withType(Zip).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
tasks.named("installDist") { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
configurations.configureEach {
resolutionStrategy.force(
"com.github.ajalt.mordant:mordant:2.6.0",
"com.github.ajalt.mordant:mordant-jvm:2.6.0"
)
}
tasks.named("koverVerify").configure { enabled = false }
@@ -1,25 +0,0 @@
package com.correx.apps.tui
sealed class KeyEvent {
object Quit : KeyEvent()
object NewSession : KeyEvent()
object Cancel : KeyEvent()
object Approve : KeyEvent()
object Reject : KeyEvent()
object NavUp : KeyEvent()
object NavDown : KeyEvent()
object Filter : KeyEvent()
object Tab : KeyEvent()
object Enter : KeyEvent()
object Backspace : KeyEvent()
object Escape : KeyEvent()
object ToggleEventStrip : KeyEvent()
object ReturnToSessionList : KeyEvent()
object ShowPendingApproval : KeyEvent()
data class CharInput(val ch: Char) : KeyEvent()
object CursorLeft : KeyEvent()
object CursorRight : KeyEvent()
object ToggleWorkflows : KeyEvent()
object ToggleSteeringMode : KeyEvent()
object ToggleDiffExpand : KeyEvent()
}
@@ -1,248 +0,0 @@
package com.correx.apps.tui
import com.correx.apps.tui.components.approvalSurfaceWidget
import com.correx.apps.tui.components.eventStreamPanelWidget
import com.correx.apps.tui.components.filteredSessions
import com.correx.apps.tui.components.footerBarWidget
import com.correx.apps.tui.components.inputBarWidget
import com.correx.apps.tui.components.routerPanelWidget
import com.correx.apps.tui.components.sessionListWidget
import com.correx.apps.tui.components.statusBarWidget
import com.correx.apps.tui.components.welcomePanelWidget
import com.correx.apps.tui.components.workflowListWidget
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.input.KeyResolver
import com.correx.apps.tui.input.mapKey
import com.correx.apps.tui.reducer.Effect
import com.correx.apps.tui.reducer.EffectDispatcher
import com.correx.apps.tui.reducer.RootReducer
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import com.correx.apps.tui.state.selectedPendingApproval
import com.correx.apps.tui.ws.ConnectionEvent
import com.correx.apps.tui.ws.TuiWsClient
import dev.tamboui.layout.Constraint
import dev.tamboui.layout.Layout
import dev.tamboui.terminal.Frame
import dev.tamboui.tui.EventHandler
import dev.tamboui.tui.Renderer
import dev.tamboui.tui.TuiRunner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private const val DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 1
private const val INPUT_HEIGHT = 4
private const val FOOTER_HEIGHT = 1
private const val MAX_VISIBLE_SESSIONS = 7
private const val SESSION_BORDER_PADDING = 2
private const val WORKFLOW_HEIGHT = 6
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
fun main(args: Array<String>) {
val host = args.getOrElse(0) { "localhost" }
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
runBlocking {
var state = TuiState()
val ws = TuiWsClient(host = host, port = port)
val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
TuiRunner.create().use { runner ->
val dispatcher = EffectDispatcher(ws) { runner.quit() }
fun dispatch(action: Action) {
val (next, effects) = RootReducer.reduce(state, action)
log.debug("got connection state after reducers: {}", next.connection)
log.debug("got input state after reducers: {}", next.input)
log.debug(
"got session state after reducers: {}",
next.sessions.sessions.firstOrNull { it.status != "COMPLETED" },
)
state = next
if (effects.isNotEmpty()) {
effectScope.launch {
dispatchAll(dispatcher, effects)
}
}
}
effectScope.launch {
ws.messages.collect { msg ->
runner.runOnRenderThread { dispatch(Action.ServerEventReceived(msg)) }
}
}
effectScope.launch {
ws.connection.collect { ev ->
val action = when (ev) {
is ConnectionEvent.Connected -> Action.Connected
is ConnectionEvent.Disconnected -> Action.Disconnected
is ConnectionEvent.RetryScheduled -> Action.RetryScheduled(ev.attempt, ev.nextRetryAtMs)
}
runner.runOnRenderThread { dispatch(action) }
}
}
effectScope.launch { ws.connect() }
val handler = EventHandler { event, _ ->
if (event is TambouiKeyEvent) {
mapKey(event)
?.let { key ->
val hasPendingApproval = state.selectedPendingApproval() != null
KeyResolver.resolve(key, state.displayState, state.inputMode, hasPendingApproval, state.inputBuffer)
}
?.let(::dispatch)
}
true
}
val renderer = Renderer { frame -> render(frame, state) }
runner.run(handler, renderer)
}
}
}
internal suspend fun dispatchAll(dispatcher: EffectDispatcher, effects: List<Effect>) {
for (effect in effects) {
dispatcher.dispatch(effect)
}
}
private fun render(frame: Frame, state: TuiState) {
when (state.displayState) {
DisplayState.IDLE -> renderIdleLayout(frame, state)
DisplayState.IN_SESSION -> renderInSessionLayout(frame, state)
DisplayState.APPROVAL -> renderApprovalLayout(frame, state)
}
}
/**
* 3-row grid: status bar | main area (horizontal split 1.7:1) | footer bar
* Left panel: session list [+ workflow list] + input bar footer
* Right panel: welcome panel
*/
private fun renderIdleLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val vertRects = Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.fill(), // main area
Constraint.length(FOOTER_HEIGHT),
)
.split(area)
val mainRects = Layout.horizontal()
.constraints(Constraint.fill(17), Constraint.fill(10)) // 1.7:1
.split(vertRects[1])
frame.renderWidget(statusBarWidget(state), vertRects[0])
renderLeftPanelWithInputBar(frame, state, mainRects[0]) { leftRect ->
renderIdleLeftContent(frame, state, leftRect)
}
frame.renderWidget(welcomePanelWidget(state), mainRects[1])
frame.renderWidget(footerBarWidget(state), vertRects[2])
}
/**
* 3-row grid: status bar | main area (horizontal split 1.7:1) | footer bar
* Left panel: router conversation + input bar footer
* Right panel: event stream
*/
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val vertRects = Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.fill(), // main area
Constraint.length(FOOTER_HEIGHT),
)
.split(area)
val mainRects = Layout.horizontal()
.constraints(Constraint.fill(17), Constraint.fill(10)) // 1.7:1
.split(vertRects[1])
frame.renderWidget(statusBarWidget(state), vertRects[0])
renderLeftPanelWithInputBar(frame, state, mainRects[0]) { leftRect ->
frame.renderWidget(routerPanelWidget(state), leftRect)
}
frame.renderWidget(eventStreamPanelWidget(state), mainRects[1])
frame.renderWidget(footerBarWidget(state), vertRects[2])
}
/**
* 3-row grid: status bar | main area (full width, no split) | footer bar
* Main area shows the approval surface.
*/
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val vertRects = Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.fill(), // main area
Constraint.length(FOOTER_HEIGHT),
)
.split(area)
frame.renderWidget(statusBarWidget(state), vertRects[0])
frame.renderWidget(approvalSurfaceWidget(state), vertRects[1])
frame.renderWidget(footerBarWidget(state), vertRects[2])
}
/**
* Renders the left panel content in the upper portion of [leftRect], with the
* InputBar pinned to the bottom. The [contentRenderer] draws the primary content
* (session list for IDLE, router panel for IN_SESSION) into the top sub-rect.
*/
private fun renderLeftPanelWithInputBar(
frame: Frame,
state: TuiState,
leftRect: dev.tamboui.layout.Rect,
contentRenderer: (dev.tamboui.layout.Rect) -> Unit,
) {
val panelRects = Layout.vertical()
.constraints(
Constraint.fill(), // content area
Constraint.length(INPUT_HEIGHT), // input bar footer
)
.split(leftRect)
contentRenderer(panelRects[0])
frame.renderWidget(inputBarWidget(state, state.displayState), panelRects[1])
}
/**
* Renders the session list and (optionally) the workflow picker in the IDLE left panel.
*/
private fun renderIdleLeftContent(frame: Frame, state: TuiState, rect: dev.tamboui.layout.Rect) {
val sessionCount = filteredSessions(state.sessions).size
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
val hasWorkflows = state.sessions.workflows.isNotEmpty()
val showWorkflows = hasWorkflows && (state.sessions.workflowsVisible || state.sessions.sessions.isEmpty())
val constraints = mutableListOf(
Constraint.length(sessionListHeight),
)
if (showWorkflows) {
constraints.add(Constraint.length(WORKFLOW_HEIGHT))
}
constraints.add(Constraint.fill())
val contentRects = Layout.vertical()
.constraints(constraints)
.split(rect)
var rectIdx = 0
frame.renderWidget(sessionListWidget(state), contentRects[rectIdx++])
if (showWorkflows) {
frame.renderWidget(workflowListWidget(state), contentRects[rectIdx++])
}
// Remaining space is unused (padding at the bottom of the left panel)
}
@@ -1,115 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.selectedPendingApproval
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.block.Title
import dev.tamboui.widgets.paragraph.Paragraph
private const val PREVIEW_MAX_LINES = 20
private const val DIFF_LINE_MAX_LENGTH = 100
fun approvalSurfaceWidget(state: TuiState): Paragraph {
val approval = state.selectedPendingApproval()
val pendingDecision = state.pendingDecision
val diffExpanded = state.diffExpanded
val scrollOffset = state.diffScrollOffset
val lines = buildList<Line> {
if (approval == null) {
add(Line.from(Span.styled("no pending approval", Theme.dimStyle)))
} else {
// Tool name, tier badge, risk summary
val toolName = approval.toolName ?: "(unknown tool)"
val toolSpan = Span.styled(toolName, Style.create())
val tierBadge = Span.styled("T${approval.tier}", Theme.warnStyle)
val riskSpan = Span.styled(approval.riskSummary, Theme.dimStyle)
add(Line.from(toolSpan, Span.raw(" "), tierBadge, Span.raw(" "), riskSpan))
// Preview: unified diff for file_write/file_edit, raw text otherwise
val preview = approval.preview
val isDiff = preview?.startsWith("--- a/") == true
if (preview != null) {
val previewLines = preview.lines()
if (previewLines.isNotEmpty()) {
val totalLines = previewLines.size
val visibleLines = if (diffExpanded) {
val from = scrollOffset.coerceIn(0, maxOf(0, totalLines - 1))
val to = minOf(from + PREVIEW_MAX_LINES, totalLines)
previewLines.subList(from, to)
} else {
previewLines.take(PREVIEW_MAX_LINES)
}
for (line in visibleLines) {
if (isDiff) {
val truncated = if (line.length > DIFF_LINE_MAX_LENGTH && !diffExpanded) {
line.take(DIFF_LINE_MAX_LENGTH - 3) + "..."
} else {
line
}
val span = when {
truncated.startsWith("@@") -> Span.styled(" $truncated", Theme.warnStyle)
truncated.startsWith("+++") || truncated.startsWith("---") -> Span.styled(" $truncated", Theme.dimStyle)
truncated.startsWith("+") -> Span.styled(" $truncated", Theme.okStyle)
truncated.startsWith("-") -> Span.styled(" $truncated", Theme.badStyle)
else -> Span.raw(" $truncated")
}
add(Line.from(span))
} else {
add(Line.from(Span.raw(" $line")))
}
}
if (!diffExpanded && totalLines > PREVIEW_MAX_LINES) {
val remaining = totalLines - PREVIEW_MAX_LINES
add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand, \u2191\u2193 to scroll)", Theme.dimStyle)))
}
if (diffExpanded) {
val showingTo = minOf(scrollOffset + PREVIEW_MAX_LINES, totalLines)
add(Line.from(Span.styled(" lines ${scrollOffset + 1}\u2013$showingTo of $totalLines\u2191\u2193 scroll, ctrl+x collapse", Theme.dimStyle)))
}
} else {
add(Line.from(Span.styled(" no preview available", Theme.dimStyle)))
}
} else {
add(Line.from(Span.styled(" no preview available", Theme.dimStyle)))
}
// Steering note input indicator
add(Line.from(Span.styled(" steering note: <type in input bar>", Theme.dimStyle)))
// Pending decision indicator (color coded)
val decisionSpan = when (pendingDecision) {
null -> Span.styled(" decision: \u2014", Theme.dimStyle)
ApprovalDecision.APPROVE -> Span.styled(" decision: APPROVE", Theme.okStyle)
ApprovalDecision.REJECT -> Span.styled(" decision: REJECT", Theme.badStyle)
}
add(Line.from(decisionSpan))
// Approve/reject key hints
add(Line.from(Span.styled(
" ctrl+a approve \u00B7 ctrl+r reject \u00B7 ctrl+x toggle diff \u00B7 enter submit \u00B7 esc dismiss",
Theme.dimStyle
)))
}
}
val block = Block.builder()
.title(Title.from(Span.styled("approval", Theme.warnStyle)).centered())
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.borderColor(Theme.warn)
.build()
return Paragraph.builder()
.text(Text.from(lines))
.block(block)
.build()
}
@@ -1,68 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.paragraph.Paragraph
private const val DETAIL_MAX = 60
private const val TYPE_WIDTH = 10
fun eventHistoryStripWidget(state: TuiState): Paragraph {
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val lines = buildList<Line> {
if (selectedSession == null) {
add(Line.from(Span.styled("no session selected", Theme.dimStyle)))
} else {
// Stage ID and status line (compact, dim metadata)
val stageInfo = buildString {
val stageId = selectedSession.currentStageId
val stage = selectedSession.currentStage
when {
stageId != null -> append(stageId)
stage != null -> append(stage)
else -> append("(no stage)")
}
append(" ")
append(selectedSession.status)
}
add(Line.from(Span.styled(stageInfo, Theme.dimStyle)))
// Recent events (plain text, compact format) — capped to fit allocated height.
// Line 0 = stage info, lines 1-5 = events (max 5).
val events = selectedSession.recentEvents.takeLast(5)
if (events.isEmpty()) {
add(Line.from(Span.styled("no events", Theme.dimStyle)))
} else {
for (ev in events) {
val typePadded = ev.type.padEnd(TYPE_WIDTH, ' ')
val typeStyle = when (ev.type) {
"StageCompleted", "InferenceCompleted", "ToolCompleted", "SessionCompleted" -> Theme.okStyle
"StageFailed", "SessionFailed", "ToolFailed", "ToolRejected" -> Theme.badStyle
"SessionPaused", "InferenceTimeout" -> Theme.warnStyle
"StageStarted", "InferenceStarted", "ToolStarted" -> Theme.accentStyle
"ArtifactCreated" -> Style.create().fg(Theme.categoryColor("context"))
else -> Style.create()
}
val detail = if (ev.detail.length > DETAIL_MAX) {
ev.detail.take(DETAIL_MAX - 3) + "..."
} else {
ev.detail
}
add(Line.from(
Span.styled("[${ev.timestamp}] ", Theme.dimStyle),
Span.styled("[$typePadded]", typeStyle),
Span.raw(" $detail"),
))
}
}
}
}
return Paragraph.builder()
.text(Text.from(lines))
.build()
}
@@ -1,120 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.TuiState
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
private const val TIMESTAMP_WIDTH = 8
private const val CATEGORY_WIDTH = 12
private const val TYPE_WIDTH = 18
private const val MAX_VISIBLE_EVENTS = 20
private const val DETAIL_MAX = 40
/**
* Structured event stream panel — right column in IN_SESSION state.
*
* Event row format:
* ```
* HH:MM:SS [Category] Type Detail
* 14:11:02 [Lifecycle] stage.enter implementation (role=coder)
* 14:11:19 [Tool] fs.write SegmentStore.kt (+212)
* ```
*/
fun eventStreamPanelWidget(state: TuiState): Paragraph {
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val lines = buildList<Line> {
// Header: "event stream" + live/paused indicator
val hasPendingApproval = selectedSession?.pendingApproval != null
val liveIndicator = if (hasPendingApproval) {
Span.styled("\u280B paused", Theme.warnStyle) // ⠋ paused
} else {
Span.styled("\u25CC live", Theme.okStyle) // ◌ live
}
add(Line.from(
Span.styled("event stream", Theme.accentStyle),
Span.raw(" "),
Span.styled("\u2502", Theme.dimStyle),
Span.raw(" "),
liveIndicator,
))
// Separator
add(Line.from(Span.styled("\u2500".repeat(40), Theme.dimStyle)))
if (selectedSession == null) {
add(Line.from(Span.styled(" no session selected", Theme.dimStyle)))
} else {
val events = selectedSession.recentEvents.takeLast(MAX_VISIBLE_EVENTS)
if (events.isEmpty()) {
add(Line.from(Span.styled(" no events", Theme.dimStyle)))
} else {
for (ev in events) {
val category = inferCategory(ev.type)
val catColor = Theme.categoryColor(category)
val catStyle = Style.create().fg(catColor)
val timestamp = ev.timestamp.take(TIMESTAMP_WIDTH).padStart(TIMESTAMP_WIDTH)
val categoryPadded = "[$category]".padEnd(CATEGORY_WIDTH)
val typePadded = ev.type.padEnd(TYPE_WIDTH).take(TYPE_WIDTH)
val detail = if (ev.detail.length > DETAIL_MAX) {
ev.detail.take(DETAIL_MAX - 3) + "..."
} else {
ev.detail
}
add(Line.from(
Span.styled(timestamp, Theme.dimStyle),
Span.raw(" "),
Span.styled(categoryPadded, catStyle),
Span.styled(typePadded, Theme.fgStrongStyle),
Span.raw(" "),
Span.styled(detail, Style.create().fg(Theme.fg)),
))
}
}
}
}
val block = Block.builder()
.title("events")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder()
.text(Text.from(lines))
.block(block)
.build()
}
/**
* Infers the event category from the event type name.
*
* | Type contains | Category |
* |------------------|------------|
* | Stage, Lifecycle | Lifecycle |
* | Infer | Inference |
* | Tool | Tool |
* | Session, Artifact| Domain |
* | Approval | Approval |
* | Context | Context |
*/
private fun inferCategory(type: String): String {
val t = type.lowercase()
return when {
t.contains("stage") || t.contains("lifecycle") -> "Lifecycle"
t.contains("infer") -> "Inference"
t.contains("tool") -> "Tool"
t.contains("session") || t.contains("artifact") -> "Domain"
t.contains("approval") -> "Approval"
t.contains("context") -> "Context"
else -> "Lifecycle"
}
}
@@ -1,63 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.paragraph.Paragraph
/**
* Bottom bar showing contextual keybind hints and the current mode indicator.
*
* Keybind chip format: `key label` with key in accent and label in dim.
* Mode indicator right-aligned: `Soft · blue`.
*/
fun footerBarWidget(state: TuiState): Paragraph {
val hints = buildList<Span> {
when (state.displayState) {
DisplayState.IDLE -> {
chip("i", "message")
chip("p", "commands")
chip("a", "approval")
chip("e", "events")
chip("F2", "skin")
}
DisplayState.IN_SESSION -> {
chip("i", "message")
chip("p", "commands")
chip("a", "approval")
chip("e", "events")
chip("Tab", "panels")
chip("\u2191\u2193", "history")
chip("l", "back")
chip("F2", "skin")
}
DisplayState.APPROVAL -> {
chip("a", "approve")
chip("A", "auto")
chip("s", "steer")
chip("r", "reject")
chip("esc", "later")
}
}
}
val modeIndicator = Span.styled("Soft \u00B7 blue", Theme.dimStyle)
return Paragraph.builder()
.text(Text.from(Line.from(hints + Span.raw(" ") + modeIndicator)))
.build()
}
/**
* Appends a keybind chip to the builder: ` key label `.
* Key in accent, label in dim, single-space separators.
*/
private fun MutableList<Span>.chip(key: String, label: String) {
add(Span.raw(" "))
add(Span.styled(key, Theme.accentStyle))
add(Span.styled(" $label", Theme.dimStyle))
add(Span.raw(" "))
}
@@ -1,135 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.TuiState
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
private fun promptStyle(inputMode: InputMode): Style = when (inputMode) {
InputMode.ROUTER -> Theme.accentStyle
InputMode.FILTER -> Theme.warnStyle
}
private fun chatModeLabel(state: TuiState): Span = when (state.chatMode) {
com.correx.core.router.ChatMode.CHAT -> Span.styled("chat", Theme.accentStyle)
com.correx.core.router.ChatMode.STEERING -> Span.styled("steer", Theme.accent2Style)
}
private fun cursorLine(buffer: String, cursor: Int, inputMode: InputMode): Line {
val prompt = Span.styled("", promptStyle(inputMode))
if (buffer.isEmpty()) return Line.from(prompt, Span.raw(""))
val before = buffer.take(cursor)
val after = buffer.drop(cursor)
return if (cursor >= buffer.length) {
Line.from(prompt, Span.raw(" $before"), Span.styled(" ", Style.create().reversed()))
} else {
Line.from(prompt, Span.raw(" $before"), Span.styled(buffer[cursor].toString(), Style.create().reversed()), Span.raw(after.drop(1)))
}
}
fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph {
val sessionName = state.sessions.sessions
.firstOrNull { it.id == state.sessions.selectedId }
?.name?.ifEmpty { null }
?: state.sessions.selectedId?.take(6)
?: "(no session)"
val (row1, row2) = when (displayState) {
DisplayState.IDLE -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForIdleRouter(state, sessionName)
InputMode.FILTER -> createRowsForFilter(state)
}
DisplayState.IN_SESSION -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForInSessionRouter(state, sessionName)
InputMode.FILTER -> createRowsForFilter(state)
}
DisplayState.APPROVAL -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForApprovalRouter(state)
InputMode.FILTER -> createRowsForFilter(state)
}
}
val block = Block.builder()
.title("")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build()
}
private fun createRowsForIdleRouter(
state: TuiState,
sessionName: String,
): Pair<Line?, Line?> {
val row1 = if (state.inputBuffer.isNotEmpty()) {
cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else {
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Session name…", Theme.dimStyle))
}
return row1 to Line.from(
Span.styled(sessionName, Theme.accentStyle),
Span.styled(" · ", Theme.dimStyle),
chatModeLabel(state),
)
}
private fun createRowsForInSessionRouter(
state: TuiState,
sessionName: String,
): Pair<Line?, Line?> {
val row1 = if (state.inputBuffer.isNotEmpty()) {
cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else {
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Ask anything…", Theme.dimStyle))
}
return row1 to Line.from(
Span.styled(sessionName, Theme.accentStyle),
Span.styled(" · ", Theme.dimStyle),
chatModeLabel(state),
)
}
private fun createRowsForApprovalRouter(
state: TuiState,
): Pair<Line?, Line?> {
val row1 = if (state.inputBuffer.isNotEmpty()) {
cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else {
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Steering note (optional)…", Theme.dimStyle))
}
val decisionSpans = when (state.pendingDecision) {
null -> listOf<Span>()
ApprovalDecision.APPROVE -> listOf(Span.styled(" [decision: ", Theme.dimStyle), Span.styled("APPROVE", Theme.okStyle), Span.styled("]", Theme.dimStyle))
ApprovalDecision.REJECT -> listOf(Span.styled(" [decision: ", Theme.dimStyle), Span.styled("REJECT", Theme.badStyle), Span.styled("]", Theme.dimStyle))
}
return row1 to Line.from(
Span.styled("approval", Theme.warnStyle),
*decisionSpans.toTypedArray(),
)
}
private fun createRowsForFilter(
state: TuiState,
): Pair<Line?, Line?> {
val row1 = if (state.inputBuffer.isNotEmpty()) {
cursorLine(state.inputBuffer, state.inputCursor, InputMode.FILTER)
} else {
Line.from(Span.styled("", promptStyle(InputMode.FILTER)), Span.styled(" /filter…", Theme.dimStyle))
}
return row1 to Line.from(
Span.styled("filtering", Theme.warnStyle),
)
}
@@ -1,48 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.RouterEntry
import com.correx.apps.tui.state.TuiState
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(state: TuiState): Paragraph {
val lines = buildList<Line> {
if (!state.routerConnected) {
add(Line.from(Span.styled("awaiting router response...", Theme.dimStyle)))
} else {
val msgs = state.routerMessages[state.sessions.selectedId].orEmpty()
for (entry in msgs) {
val style = when (entry.role) {
"user" -> Theme.accentStyle
"router" -> Theme.fgStrongStyle
"tool" -> Theme.dimStyle
else -> Theme.fgStrongStyle
}
val prefix = when (entry.role) {
"user" -> "\u25b8 "
"router" -> " "
"tool" -> " "
else -> ""
}
val contentLines = entry.content.split("\n")
contentLines.forEachIndexed { i, lineContent ->
val text = if (i == 0) "$prefix$lineContent" else " $lineContent"
add(Line.from(Span.styled(text, style)))
}
}
}
}
val block = Block.builder()
.title("output")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
@@ -1,67 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.TuiState
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
private const val MAX_VISIBLE = 7
private const val NAME_MAX = 15
private const val ID_LEN = 6
private const val STATUS_WIDTH = 8
fun sessionListWidget(state: TuiState): Paragraph {
val sessions = filteredSessions(state.sessions)
val visible = sessions.take(MAX_VISIBLE)
val overflow = sessions.size - MAX_VISIBLE
val lines = buildList<Line> {
for (s in visible) {
val isSelected = s.id == state.sessions.selectedId
val prefix = if (isSelected) Span.styled("", Theme.accentStyle) else Span.raw(" ")
val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", Theme.dimStyle)
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
val nameAndStage = buildString {
append(truncated)
if (s.currentStageId != null) append(" [${s.currentStageId}]")
}
add(Line.from(prefix, idSpan, Span.raw(" "), statusSpan, Span.raw(" $nameAndStage")))
}
if (overflow > 0) {
add(Line.from(Span.styled("$overflow more —", Theme.dimStyle)))
}
}
val block = Block.builder()
.title("sessions")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
private fun statusSpan(s: SessionSummary): Span {
val label = s.status.uppercase().padEnd(STATUS_WIDTH)
return when (s.status.lowercase()) {
"active", "running" -> Span.styled(label, Theme.okStyle)
"paused" -> Span.styled(label, Theme.warnStyle)
"failed", "error" -> Span.styled(label, Theme.badStyle)
"completed", "done" -> Span.styled(label, Theme.dimStyle)
else -> Span.raw(label)
}
}
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) }
}
@@ -1,123 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.ProviderType
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.paragraph.Paragraph
private const val METER_BARS = 10
fun statusBarWidget(state: TuiState): Paragraph {
val sep = Span.styled(" \u00B7 ", Theme.dimStyle)
// 1. Brand mark: "corre" + accented "x"
val brand = listOf(
Span.styled("corre", Theme.dimStyle),
Span.styled("x", Theme.accentStyle),
)
// 2. Connection indicator
val connIndicator = if (state.connection.connected) {
Span.styled("\u25CF connected", Theme.okStyle)
} else {
Span.styled("\u25CB disconnected", Theme.dimBadStyle)
}
// 3. Session name + branch (only when IN_SESSION or APPROVAL)
val sessionInfo = run {
if (state.displayState == DisplayState.IDLE) return@run emptyList<Span>()
val selected = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
?: return@run emptyList()
val name = selected.name.ifEmpty { selected.workflowId }
val stage = selected.currentStageId ?: selected.currentStage
buildList {
add(Span.styled(name, Theme.fgStrongStyle))
if (stage != null) {
add(Span.styled(" \u00B7 $stage", Theme.dimStyle))
}
}
}
// 4. Model + provider
val modelLabel = (state.currentModel ?: "(no model)") +
if (state.providerType == ProviderType.LOCAL) " (local)" else " (remote)"
val modelSpan = Span.styled(modelLabel, Theme.accentStyle)
// 5. Context meter
val contextMeter = run {
val used = state.contextUsed
val budget = state.contextBudget
if (used == null || budget == null || budget <= 0) return@run null
val ratio = (used.toFloat() / budget).coerceIn(0f, 1f)
val filled = (ratio * METER_BARS).toInt().coerceIn(0, METER_BARS)
val empty = METER_BARS - filled
val bar = buildString {
repeat(filled) { append('\u2588') } // █ full block
repeat(empty) { append('\u2591') } // ░ light shade
}
val pct = (ratio * 100).toInt()
val barSpan = Span.styled(bar, Theme.accentStyle)
val pctSpan = Span.styled(" $pct%", Theme.dimStyle)
Span.styled("ctx", Theme.dimStyle) to listOf(barSpan, pctSpan)
}
// 6. State badge
val stateBadge = run {
when (state.displayState) {
DisplayState.APPROVAL -> Span.styled("\u23F8 awaiting", Theme.warnStyle)
DisplayState.IN_SESSION -> Span.styled("\u25B8 active", Theme.okStyle)
DisplayState.IDLE -> null
}
}
// 7. Background update badge
val backgroundBadge = if (state.displayState != DisplayState.IDLE && state.sessions.backgroundUpdateCount > 0) {
Span.styled("+${state.sessions.backgroundUpdateCount} updated", Theme.dimWarnStyle)
} else {
null
}
// 8. Pending approval indicator
val approvalBadge = run {
val has = state.sessions.sessions.any { it.pendingApproval != null }
if (has) Span.styled("[\u26A0 approval]", Theme.warnStyle) else null
}
val spans = buildList<Span> {
addAll(brand)
add(sep)
add(connIndicator)
if (sessionInfo.isNotEmpty()) {
add(sep)
addAll(sessionInfo)
}
add(sep)
add(modelSpan)
if (contextMeter != null) {
add(sep)
add(contextMeter.first)
add(Span.raw(" "))
addAll(contextMeter.second)
}
if (stateBadge != null) {
add(Span.raw(" "))
add(stateBadge)
}
if (backgroundBadge != null) {
add(sep)
add(backgroundBadge)
}
if (approvalBadge != null) {
add(sep)
add(approvalBadge)
}
}
return Paragraph.builder()
.text(Text.from(Line.from(spans)))
.build()
}
@@ -1,105 +0,0 @@
package com.correx.apps.tui.components
import dev.tamboui.style.Color
import dev.tamboui.style.Style
/**
* Central theme for the Correx TUI — soft rounded dark palette with blue accent (#4a9eff).
*
* Every widget references Theme.* constants instead of hardcoded `.blue()` / `.cyan()` / `.gray()`.
*
* ## Palette
*
* | Token | Hex | ANSI | Use |
* |-------------|-----------|---------|-------------------------------|
* | `bg` | `#14161a` | idx 233 | Screen background |
* | `bgDeep` | `#0f1115` | idx 232 | Top / footer bar |
* | `panel` | `#181b21` | idx 234 | Panel surface |
* | `panel2` | `#1f232b` | idx 235 | Secondary surface |
* | `fg` | `#ced3da` | idx 252 | Body text |
* | `fgStrong` | `#eef1f5` | idx 255 | Headings, emphasis |
* | `dim` | `#838b96` | idx 243 | Muted text |
* | `faint` | `#434a55` | idx 238 | Borders, placeholders |
* | `border` | `#262b33` | idx 236 | Panel borders |
* | `sel` | `#21262e` | idx 235 | Selection highlight |
* | `accent` | `#4a9eff` | rgb | Blue accent |
* | `accent2` | `#7aa2f7` | rgb | Purple-blue secondary |
* | `ok` | `#7fd88f` | rgb | Success |
* | `warn` | `#e8c06a` | rgb | Warning |
* | `bad` | `#e87f7f` | rgb | Error |
*/
object Theme {
// ── Surfaces ──────────────────────────────────────────────
val bg: Color = Color.indexed(233)
val bgDeep: Color = Color.indexed(232)
val panel: Color = Color.indexed(234)
val panel2: Color = Color.indexed(235)
// ── Text ──────────────────────────────────────────────────
val fg: Color = Color.indexed(252)
val fgStrong: Color = Color.indexed(255)
val dim: Color = Color.indexed(243)
val faint: Color = Color.indexed(238)
// ── Accent ────────────────────────────────────────────────
val accent: Color = Color.rgb(74, 158, 255)
val accent2: Color = Color.rgb(122, 162, 247)
// ── Semantic ──────────────────────────────────────────────
val ok: Color = Color.rgb(127, 216, 143)
val warn: Color = Color.rgb(232, 192, 106)
val bad: Color = Color.rgb(232, 127, 127)
// ── Border / Selection ────────────────────────────────────
val border: Color = Color.indexed(236)
val sel: Color = Color.indexed(235)
// ── Pre-built Style objects ───────────────────────────────
val dimStyle: Style = Style.create().fg(dim)
val accentStyle: Style = Style.create().fg(accent)
val okStyle: Style = Style.create().fg(ok)
val warnStyle: Style = Style.create().fg(warn)
val badStyle: Style = Style.create().fg(bad)
val fgStrongStyle: Style = Style.create().fg(fgStrong)
val accent2Style: Style = Style.create().fg(accent2)
// Convenience: dim + semantic combined styles
val dimOkStyle: Style = Style.create().fg(ok).dim()
val dimWarnStyle: Style = Style.create().fg(warn).dim()
val dimBadStyle: Style = Style.create().fg(bad).dim()
val dimAccentStyle: Style = Style.create().fg(accent).dim()
// ── Typography ────────────────────────────────────────────
/**
* Nominal font size (points). Terminal-emulator controlled — this is a
* documentation / scaling reference for layout constants. Adjust if the
* emulator is configured to a size outside the 14-15pt range.
*/
const val fontSize: Int = 14
// ── Event category → color mapping ────────────────────────
/**
* Maps an event category string to its designated color.
*
* | Category | Color | Hex |
* |------------|---------|-----------|
* | Lifecycle | accent2 | `#7aa2f7` |
* | Context | purple | `#c98fd9` |
* | Inference | accent | `#4a9eff` |
* | Tool | ok | `#7fd88f` |
* | Domain | amber | `#e8b765` |
* | Approval | warn | `#e8c06a` |
*/
fun categoryColor(category: String): Color {
val c = category.lowercase()
return when {
c == "lifecycle" -> accent2
c == "context" -> Color.rgb(201, 143, 217) // purple
c.contains("infer") -> accent // inference / inference
c == "tool" -> ok
c == "domain" -> Color.rgb(232, 183, 101) // amber
c.contains("approval") || c == "approval" -> warn
else -> dim
}
}
}
@@ -1,104 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.TuiState
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
private const val RECENT_MAX = 5
private const val NAME_MAX = 20
/**
* Right-column panel in IDLE state showing welcome / quick-start info and recent sessions.
*/
fun welcomePanelWidget(state: TuiState): Paragraph {
val lines = buildList<Line> {
// Brand header with accented x
add(Line.from(
Span.styled("Corre", Theme.fgStrongStyle),
Span.styled("x", Theme.accentStyle),
Span.styled(" Agent Harness", Theme.fgStrongStyle),
))
// Connection status
val connText = if (state.connection.connected) {
"Connected \u00B7 ${state.sessions.sessions.size} sessions"
} else {
"Disconnected"
}
add(Line.from(Span.styled(connText, if (state.connection.connected) Theme.dimStyle else Theme.dimBadStyle)))
add(Line.from(Span.styled("", Theme.dimStyle)))
// Prompt text
add(Line.from(Span.styled("Select a session from the left", Theme.dimStyle)))
add(Line.from(Span.styled("or type a name to start a new one.", Theme.dimStyle)))
add(Line.from(Span.styled("", Theme.dimStyle)))
// Recent sessions
val sorted = state.sessions.sessions
.sortedByDescending { it.lastEventAt }
.take(RECENT_MAX)
if (sorted.isNotEmpty()) {
add(Line.from(Span.styled("recent:", Theme.dimStyle)))
for (s in sorted) {
val glyph = statusGlyph(s.status)
val name = s.name.ifEmpty { s.workflowId }
val truncated = if (name.length > NAME_MAX) name.take(NAME_MAX - 1) + "\u2026" else name
val time = formatTimestamp(s.lastEventAt)
add(Line.from(
Span.raw(" "),
Span.styled(truncated, Style.create().fg(Theme.fg)),
Span.raw(" "),
Span.styled(time, Theme.dimStyle),
Span.raw(" "),
Span.styled(glyph, glyphStyle(s.status)),
))
}
}
}
val block = Block.builder()
.title("welcome")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder()
.text(Text.from(lines))
.block(block)
.build()
}
/** Returns a one-character status glyph for the given session status. */
private fun statusGlyph(status: String): String = when (status.lowercase()) {
"active", "running" -> "\u25B8" // ▸
"completed", "done" -> "\u2714" // ✔
"failed", "error" -> "\u2718" // ✘
"paused" -> "\u23F8" // ⏸
else -> "\u00B7" // ·
}
/** Returns a style for the status glyph. */
private fun glyphStyle(status: String) = when (status.lowercase()) {
"active", "running" -> Theme.accentStyle
"completed", "done" -> Theme.okStyle
"failed", "error" -> Theme.badStyle
"paused" -> Theme.warnStyle
else -> Theme.dimStyle
}
/** Formats a Unix-millis timestamp to a short time string like "14:02". */
private fun formatTimestamp(epochMs: Long): String {
if (epochMs <= 0) return "--:--"
val totalMinutes = (epochMs / 60000) % (24 * 60)
val hh = totalMinutes / 60
val mm = totalMinutes % 60
return "${hh.toInt().toString().padStart(2, '0')}:${mm.toInt().toString().padStart(2, '0')}"
}
@@ -1,47 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.TuiState
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
private const val MAX_VISIBLE = 8
fun workflowListWidget(state: TuiState): Paragraph {
val workflows = state.sessions.workflows
val visible = workflows.take(MAX_VISIBLE)
val overflow = workflows.size - MAX_VISIBLE
val lines = buildList<Line> {
if (workflows.isEmpty()) {
add(Line.from(Span.styled("(no workflows configured)", Theme.dimStyle)))
} else {
for ((i, wf) in visible.withIndex()) {
val isSelected = i == state.sessions.selectedWorkflowIndex
val prefix = if (isSelected) Span.styled("", Theme.accentStyle) else Span.raw(" ")
val name = Span.styled(wf.workflowId, Theme.accentStyle)
val desc = if (wf.description.isNotBlank() && wf.description != wf.workflowId) {
Span.styled(" · ${wf.description}", Theme.dimStyle)
} else {
Span.styled("", Theme.dimStyle)
}
add(Line.from(prefix, name, desc))
}
if (overflow > 0) {
add(Line.from(Span.styled("$overflow more —", Theme.dimStyle)))
}
}
}
val block = Block.builder()
.title("workflows")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
@@ -1,33 +0,0 @@
package com.correx.apps.tui.input
import com.correx.apps.server.protocol.ServerMessage
sealed interface Action {
data object Quit : Action
data object OpenNewSessionPrompt : Action
data object CancelSelectedSession : Action
data object ApproveActive : Action
data object RejectActive : Action
data object NavigateUp : Action
data object NavigateDown : Action
data object OpenFilter : Action
data class AppendChar(val ch: Char) : Action
data object Backspace : Action
data object SubmitInput : Action
data object CancelInput : Action
data object ToggleEventStrip : Action
data object SubmitApprovalDecision : Action
data object ReturnToSessionList : Action
data object ShowPendingApproval : Action
data class ServerEventReceived(val message: ServerMessage) : Action
data object Connected : Action
data object Disconnected : Action
data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : Action
data object CycleMode : Action
data object CursorLeft : Action
data object CursorRight : Action
data object ToggleWorkflows : Action
data object CycleChatMode : Action
data object ToggleDiffExpand : Action
data object NoOp : Action
}
@@ -1,87 +0,0 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode
object KeyResolver {
fun resolve(
key: KeyEvent,
displayState: DisplayState,
inputMode: InputMode,
hasPendingApproval: Boolean,
inputText: String,
): Action? {
// Global actions (I-NAV-2): work regardless of display state
val global = when (key) {
KeyEvent.Quit -> Action.Quit
KeyEvent.Approve -> Action.ApproveActive
KeyEvent.Reject -> Action.RejectActive
KeyEvent.ToggleSteeringMode -> Action.CycleChatMode
else -> null
}
if (global != null) return global
// Cursor keys work everywhere text input is active
if (key is KeyEvent.CursorLeft) return Action.CursorLeft
if (key is KeyEvent.CursorRight) return Action.CursorRight
// FILTER mode override: ↑↓ always navigates sessions list
if (inputMode == InputMode.FILTER) {
return when (key) {
KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
KeyEvent.Escape -> Action.CancelInput
KeyEvent.Tab -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null
}
}
// Display-state-aware routing (ROUTER mode)
return when (displayState) {
DisplayState.IDLE -> when (key) {
KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Enter -> Action.SubmitInput
KeyEvent.Tab -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
KeyEvent.ToggleWorkflows -> Action.ToggleWorkflows
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null
}
DisplayState.IN_SESSION -> when (key) {
KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
KeyEvent.Tab -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
KeyEvent.Cancel -> Action.CancelSelectedSession
KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList
KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip
KeyEvent.ShowPendingApproval -> if (hasPendingApproval) Action.ShowPendingApproval else null
else -> null
}
DisplayState.APPROVAL -> when (key) {
KeyEvent.Enter -> Action.SubmitApprovalDecision
KeyEvent.Escape -> Action.CancelInput
KeyEvent.Cancel -> Action.CancelInput
KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList
KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip
KeyEvent.ToggleDiffExpand -> Action.ToggleDiffExpand
KeyEvent.Tab -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null
}
}
}
}
@@ -1,48 +0,0 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import dev.tamboui.tui.event.KeyCode
import org.slf4j.LoggerFactory
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private val log = LoggerFactory.getLogger("com.correx.apps.tui.input")
@Suppress("CyclomaticComplexMethod", "ReturnCount")
fun mapKey(event: TambouiKeyEvent): KeyEvent? {
log.debug("got event: {}", event)
if (event.hasAlt()) {
return null
}
if (event.hasCtrl()) {
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('l') -> KeyEvent.ReturnToSessionList
event.isChar('e') -> KeyEvent.ToggleEventStrip
event.isChar('h') -> KeyEvent.ShowPendingApproval
event.isChar('w') -> KeyEvent.ToggleWorkflows
event.isChar('s') -> KeyEvent.ToggleSteeringMode
event.isChar('x') -> KeyEvent.ToggleDiffExpand
else -> null
}
}
return when (event.code()) {
KeyCode.UP -> KeyEvent.NavUp
KeyCode.DOWN -> KeyEvent.NavDown
KeyCode.LEFT -> KeyEvent.CursorLeft
KeyCode.RIGHT -> KeyEvent.CursorRight
KeyCode.ENTER -> KeyEvent.Enter
KeyCode.ESCAPE -> KeyEvent.Escape
KeyCode.BACKSPACE -> KeyEvent.Backspace
KeyCode.TAB -> KeyEvent.Tab
KeyCode.CHAR -> {
val ch = event.character()
if (ch.isISOControl()) null else KeyEvent.CharInput(ch)
}
else -> null
}
}
@@ -1,21 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ConnectionState
object ConnectionReducer {
fun reduce(
connection: ConnectionState,
action: Action,
): Pair<ConnectionState, List<Effect>> = when (action) {
is Action.Connected ->
ConnectionState(connected = true, reconnecting = false, attempt = 0, nextRetryAtMs = null) to emptyList()
is Action.Disconnected -> connection.copy(connected = false, reconnecting = true) to emptyList()
is Action.RetryScheduled -> connection.copy(
reconnecting = true,
attempt = action.attempt,
nextRetryAtMs = action.nextRetryAtMs,
) to emptyList()
else -> connection to emptyList()
}
}
@@ -1,8 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ClientMessage
sealed interface Effect {
data class SendWs(val message: ClientMessage) : Effect
data object Quit : Effect
}
@@ -1,15 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.tui.ws.TuiWsClient
class EffectDispatcher(
private val wsClient: TuiWsClient,
private val onQuit: () -> Unit,
) {
suspend fun dispatch(effect: Effect) {
when (effect) {
is Effect.SendWs -> wsClient.send(effect.message)
Effect.Quit -> onQuit()
}
}
}
@@ -1,123 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.selectedPendingApproval
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.router.ChatMode
object InputReducer {
@Suppress("CyclomaticComplexMethod")
fun reduce(
state: TuiState,
action: Action,
): Pair<TuiState, List<Effect>> = when (action) {
is Action.CycleMode -> state.copy(
inputMode = when (state.inputMode) {
InputMode.ROUTER -> InputMode.FILTER
InputMode.FILTER -> InputMode.ROUTER
},
) to emptyList()
is Action.CycleChatMode -> state.copy(
chatMode = when (state.chatMode) {
ChatMode.CHAT -> ChatMode.STEERING
ChatMode.STEERING -> ChatMode.CHAT
},
) to emptyList()
is Action.CursorLeft -> state.copy(
inputCursor = (state.inputCursor - 1).coerceAtLeast(0),
) to emptyList()
is Action.CursorRight -> state.copy(
inputCursor = (state.inputCursor + 1).coerceAtMost(state.inputBuffer.length),
) to emptyList()
is Action.OpenNewSessionPrompt -> state.copy(
inputMode = InputMode.ROUTER,
inputBuffer = "",
inputCursor = 0,
) to emptyList()
is Action.AppendChar -> {
val buf = state.inputBuffer
val cur = state.inputCursor
state.copy(
inputBuffer = buf.take(cur) + action.ch + buf.drop(cur),
inputCursor = cur + 1,
) to emptyList()
}
is Action.Backspace -> {
val cur = state.inputCursor
if (cur > 0) {
val buf = state.inputBuffer
state.copy(
inputBuffer = buf.take(cur - 1) + buf.drop(cur),
inputCursor = cur - 1,
) to emptyList()
} else {
state to emptyList()
}
}
is Action.CancelInput -> state.copy(
inputMode = InputMode.ROUTER,
inputBuffer = "",
inputCursor = 0,
) to emptyList()
is Action.SubmitInput -> state.copy(
inputBuffer = "",
inputCursor = 0,
) to emptyList()
is Action.SubmitApprovalDecision -> {
val decision = state.pendingDecision ?: ApprovalDecision.REJECT
val active = state.selectedPendingApproval()
val steeringNote = state.inputBuffer.ifBlank { null }
if (active != null) {
val selectedId = state.sessions.selectedId
val clearedSessions = state.sessions.copy(
sessions = state.sessions.sessions.map { s ->
if (s.id == selectedId) s.copy(pendingApproval = null) else s
},
)
state.copy(
pendingDecision = null,
approvalDismissed = false,
inputBuffer = "",
sessions = clearedSessions,
) to listOf(
Effect.SendWs(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(active.requestId),
decision = decision,
steeringNote = steeringNote,
),
),
)
} else {
state.copy(
pendingDecision = null,
approvalDismissed = false,
inputBuffer = "",
) to emptyList()
}
}
is Action.ShowPendingApproval -> state.copy(
approvalDismissed = false,
) to emptyList()
is Action.ReturnToSessionList -> state.copy(
sessions = state.sessions.copy(selectedId = null),
) to emptyList()
else -> state to emptyList()
}
}
@@ -1,21 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ProviderState
object ProviderReducer {
fun reduce(
provider: ProviderState,
action: Action,
): Pair<ProviderState, List<Effect>> = when (action) {
is Action.ServerEventReceived -> when (val msg = action.message) {
is ServerMessage.ProviderStatusChanged -> ProviderState(
id = msg.status.providerId,
status = msg.status.status,
) to emptyList()
else -> provider to emptyList()
}
else -> provider to emptyList()
}
}
@@ -1,294 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.ProviderType
import com.correx.apps.tui.state.RouterEntry
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(RootReducer::class.java.name)
object RootReducer {
fun reduce(
state: TuiState,
action: Action,
clock: () -> Long = System::currentTimeMillis,
): Pair<TuiState, List<Effect>> {
val phase = SnapshotPhaseReducer.process(state, action)
var current = phase.state
val allEffects = mutableListOf<Effect>()
val replayingBufferedEvents = action is Action.ServerEventReceived &&
action.message is ServerMessage.SnapshotComplete
for (a in phase.actionsToDispatch) {
if (replayingBufferedEvents) {
val inner = SnapshotPhaseReducer.process(current, a)
current = inner.state
for (ia in inner.actionsToDispatch) {
val (next, effects) = reduceThroughSubReducers(current, ia, clock)
current = next
allEffects += effects
}
} else {
val (next, effects) = reduceThroughSubReducers(current, a, clock)
current = next
allEffects += effects
}
}
return current to allEffects
}
@Suppress("CyclomaticComplexMethod")
private fun reduceThroughSubReducers(
state: TuiState,
action: Action,
clock: () -> Long,
): Pair<TuiState, List<Effect>> {
log.debug("action={}", action::class.simpleName)
// Effect.Quit appended LAST so all cleanup effects from sub-reducers dispatch first.
// See effects-01 (sequential dispatch) for why list position is a runtime guarantee.
// I-Q3: sub-reducers must not synthesize Effect.Quit — it is a terminal signal owned by RootReducer.
// Capture pre-reducer state for cross-reducer weaving.
val prevInputMode = state.inputMode
val prevInputBuffer = state.inputBuffer
val prevSelectedId = state.sessions.selectedId
log.debug("input state before reducers={}", state.input)
val (afterInput, ie) = InputReducer.reduce(state, action)
log.debug("sessions state before reducers={}", state.sessions)
val (sessions, se) = SessionsReducer.reduce(
SessionsReducerContext(
sessions = afterInput.sessions,
displayState = afterInput.displayState,
inputMode = prevInputMode,
inputText = prevInputBuffer,
action = action,
clock = clock,
chatMode = afterInput.chatMode,
),
)
log.debug("connection state before reducers={}", state.connection)
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
log.debug("provider state before reducers={}", state.provider)
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
// ── Cross-reducer state weaving ──
// After all sub-reducers have run, weave fields that span multiple reducer boundaries.
// InputReducer runs first (clears inputBuffer, mode), then SessionsReducer (applies filter,
// sends effects), then ConnectionReducer and ProviderReducer.
// 1. Base merge: overlay sub-reducer outputs onto state after InputReducer.
val withSubReducers = afterInput.copy(
sessions = sessions,
connection = connection,
provider = provider,
)
// 2. backgroundUpdateCount reset: on any selectedId change (ARCH-BADGE-2).
val withBgReset = if (sessions.selectedId != prevSelectedId) {
withSubReducers.copy(sessions = sessions.copy(backgroundUpdateCount = 0))
} else {
withSubReducers
}
// 3. Action-specific cross-field handles.
val final = when (action) {
is Action.ApproveActive -> withBgReset.copy(
pendingDecision = ApprovalDecision.APPROVE,
)
is Action.RejectActive -> withBgReset.copy(
pendingDecision = ApprovalDecision.REJECT,
)
is Action.CancelInput -> when {
prevInputMode == InputMode.FILTER -> withBgReset
else -> withBgReset.copy(approvalDismissed = true, sessionEntered = false)
}
is Action.ReturnToSessionList -> withBgReset.copy(sessionEntered = false)
is Action.ToggleEventStrip -> {
if (withBgReset.displayState == DisplayState.IDLE) withBgReset
else withBgReset.copy(eventStripVisible = !withBgReset.eventStripVisible)
}
is Action.ToggleDiffExpand -> withBgReset.copy(
diffExpanded = !withBgReset.diffExpanded,
diffScrollOffset = 0,
)
// Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1),
// or enter a selected session from the list in IDLE.
is Action.SubmitInput -> {
val text = prevInputBuffer.trim()
when {
text.isBlank() && sessions.selectedId != null && prevInputMode != InputMode.FILTER ->
withBgReset.copy(sessionEntered = true)
text.isNotBlank() && sessions.selectedId != null &&
prevInputMode == InputMode.ROUTER &&
withBgReset.displayState == com.correx.apps.tui.state.DisplayState.IN_SESSION -> {
val selectedId = sessions.selectedId!!
val currentHistory = withBgReset.inputHistory[selectedId] ?: emptyList()
val updatedHistory = (currentHistory + text).takeLast(50)
withBgReset.copy(
inputHistory = withBgReset.inputHistory + (selectedId to updatedHistory),
routerMessages = withBgReset.routerMessages.run {
this + (selectedId to (this[selectedId].orEmpty() + RouterEntry("user", text)))
},
)
}
// Optimistic session created by SessionsReducer for IDLE chat submit.
sessions.selectedId != null && prevInputMode == InputMode.ROUTER &&
withBgReset.displayState == DisplayState.IDLE -> {
val selectedId = sessions.selectedId!!
withBgReset.copy(
sessionEntered = true,
routerMessages = withBgReset.routerMessages.run {
this + (selectedId to (this[selectedId].orEmpty() + RouterEntry("user", text)))
},
)
}
else -> withBgReset
}
}
// Auto-enter a newly started session when SessionStarted arrives.
// When a ProviderStatusChanged arrives, update model/provider labels.
is Action.ServerEventReceived -> {
val msg = action.message
when {
msg is ServerMessage.SessionStarted -> {
withBgReset.copy(
sessionEntered = true,
sessions = withBgReset.sessions.copy(selectedId = msg.sessionId.value),
)
}
msg is ServerMessage.ProviderStatusChanged -> {
withBgReset.copy(
currentModel = msg.providerId,
providerType = if (msg.providerId.contains("llama", ignoreCase = true) ||
msg.providerId.contains("local", ignoreCase = true)
) ProviderType.LOCAL else ProviderType.REMOTE,
)
}
msg is ServerMessage.RouterResponseMessage -> {
withBgReset.copy(
routerConnected = true,
routerMessages = withBgReset.routerMessages.run {
val key = msg.sessionId.value
this + (key to (this[key].orEmpty() + RouterEntry("router", msg.content)))
},
)
}
msg is ServerMessage.ToolCompleted && msg.diff != null -> {
withBgReset.copy(
routerMessages = withBgReset.routerMessages.run {
val key = msg.sessionId.value
this + (key to (this[key].orEmpty() + RouterEntry("tool", msg.diff!!)))
},
)
}
else -> withBgReset
}
}
// Diff scrolling in APPROVAL mode when expanded.
is Action.NavigateUp -> {
if (withBgReset.displayState == DisplayState.APPROVAL && withBgReset.diffExpanded) {
// TODO(diff-scroll): cap scrollOffset at totalLines - PREVIEW_MAX_LINES to prevent
// scrolling past the end of the diff. Needs total line count in state or a clamp
// communicated from ApprovalSurface.
withBgReset.copy(diffScrollOffset = maxOf(0, withBgReset.diffScrollOffset - 1))
} else if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) {
val selectedId = withBgReset.sessions.selectedId
if (selectedId != null) {
val history = withBgReset.inputHistory[selectedId] ?: emptyList()
if (history.isNotEmpty()) {
when (withBgReset.inputHistoryIndex) {
-1 -> {
val text = history[history.size - 1]
withBgReset.copy(
savedInputBuffer = withBgReset.inputBuffer,
inputHistoryIndex = history.size - 1,
inputBuffer = text,
inputCursor = text.length,
)
}
0 -> withBgReset // at oldest entry, stay
else -> {
val newIndex = withBgReset.inputHistoryIndex - 1
val text = history[newIndex]
withBgReset.copy(
inputHistoryIndex = newIndex,
inputBuffer = text,
inputCursor = text.length,
)
}
}
} else {
withBgReset
}
} else {
withBgReset
}
} else {
withBgReset
}
}
// Diff scrolling in APPROVAL mode when expanded.
is Action.NavigateDown -> {
if (withBgReset.displayState == DisplayState.APPROVAL && withBgReset.diffExpanded) {
// TODO(diff-scroll): cap scrollOffset at totalLines - PREVIEW_MAX_LINES; same issue
// as NavigateUp above — the reducer doesn't know the diff length.
withBgReset.copy(diffScrollOffset = withBgReset.diffScrollOffset + 1)
} else if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) {
val selectedId = withBgReset.sessions.selectedId
if (selectedId != null) {
val history = withBgReset.inputHistory[selectedId] ?: emptyList()
when (withBgReset.inputHistoryIndex) {
-1 -> withBgReset // already at current buffer
history.size - 1 -> {
val saved = withBgReset.savedInputBuffer
withBgReset.copy(
inputHistoryIndex = -1,
inputBuffer = saved,
inputCursor = saved.length,
)
}
else -> {
val newIndex = withBgReset.inputHistoryIndex + 1
val text = history[newIndex]
withBgReset.copy(
inputHistoryIndex = newIndex,
inputBuffer = text,
inputCursor = text.length,
)
}
}
} else {
withBgReset
}
} else {
withBgReset
}
}
else -> withBgReset
}
val combined = ie + se + ce + pe
val withTerminal = if (action is Action.Quit) combined + Effect.Quit else combined
return final to withTerminal
}
}
@@ -1,10 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
/**
* Thin adapter that wraps a ServerMessage in ServerEventReceived and fans it through the root reducer.
* Retained so existing callers that were using StateReducer.kt logic have a home during the transition.
*/
internal fun serverMessageToAction(msg: ServerMessage): Action = Action.ServerEventReceived(msg)
@@ -1,661 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.ToolDisplayStatus
import com.correx.apps.tui.state.ToolManifestEntry
import com.correx.apps.tui.state.TuiEventEntry
import com.correx.apps.tui.state.TuiToolRecord
import com.correx.core.events.types.SessionId
import com.correx.core.router.ChatMode
import org.slf4j.LoggerFactory
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
private val log = LoggerFactory.getLogger(SessionsReducer::class.simpleName)
data class SessionsReducerContext(
val sessions: SessionsState,
val displayState: DisplayState,
val inputMode: InputMode,
val inputText: String,
val action: Action,
val clock: () -> Long = System::currentTimeMillis,
val chatMode: ChatMode = ChatMode.CHAT,
)
object SessionsReducer {
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneOffset.UTC)
fun reduce(ctx: SessionsReducerContext): Pair<SessionsState, List<Effect>> = when (ctx.action) {
is Action.NavigateUp -> when {
ctx.inputMode == InputMode.FILTER -> navigateUp(ctx.sessions) to emptyList()
ctx.displayState == DisplayState.IDLE -> navigateUp(ctx.sessions) to emptyList()
ctx.displayState == DisplayState.IN_SESSION -> ctx.sessions to emptyList()
else -> ctx.sessions to emptyList()
}
is Action.NavigateDown -> when {
ctx.inputMode == InputMode.FILTER -> navigateDown(ctx.sessions) to emptyList()
ctx.displayState == DisplayState.IDLE -> navigateDown(ctx.sessions) to emptyList()
ctx.displayState == DisplayState.IN_SESSION -> ctx.sessions to emptyList()
else -> ctx.sessions to emptyList()
}
is Action.SubmitInput -> when {
ctx.inputMode == InputMode.FILTER -> ctx.sessions.copy(filter = ctx.inputText) to emptyList()
ctx.displayState == DisplayState.IDLE -> {
val text = ctx.inputText.trim()
val wfIndex = ctx.sessions.selectedWorkflowIndex
when {
wfIndex in ctx.sessions.workflows.indices -> {
val wf = ctx.sessions.workflows[wfIndex]
ctx.sessions.copy(selectedWorkflowIndex = -1) to listOf(
Effect.SendWs(ClientMessage.StartSession(workflowId = wf.workflowId, config = null)),
)
}
text.isNotBlank() -> {
val sessionId = SessionId(java.util.UUID.randomUUID().toString())
val optimistic = SessionSummary(
id = sessionId.value,
status = "STARTING",
workflowId = "chat",
name = "chat",
lastEventAt = ctx.clock(),
)
ctx.sessions.copy(
sessions = ctx.sessions.sessions + optimistic,
selectedId = sessionId.value,
) to listOf(
Effect.SendWs(ClientMessage.StartChatSession(sessionId = sessionId, text = text)),
)
}
else -> ctx.sessions to emptyList()
}
}
ctx.displayState == DisplayState.IN_SESSION -> ctx.sessions.selectedId?.let { sid ->
ctx.sessions to listOf(
Effect.SendWs(
ClientMessage.ChatInput(
sessionId = SessionId(sid),
text = ctx.inputText,
mode = ctx.chatMode,
),
),
)
} ?: (ctx.sessions to emptyList())
else -> ctx.sessions to emptyList()
}
is Action.CancelInput -> if (ctx.inputMode == InputMode.FILTER) {
ctx.sessions.copy(filter = "") to emptyList()
} else {
ctx.sessions to emptyList()
}
is Action.CancelSelectedSession -> {
val id = ctx.sessions.selectedId
if (id != null) {
ctx.sessions to listOf(Effect.SendWs(ClientMessage.CancelSession(sessionId = SessionId(id))))
} else {
ctx.sessions to emptyList()
}
}
is Action.ToggleWorkflows -> ctx.sessions.copy(
workflowsVisible = !ctx.sessions.workflowsVisible,
) to emptyList()
is Action.ServerEventReceived -> applyServerMessage(ctx.sessions, ctx.action.message, ctx.clock)
else -> ctx.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 wfi = sessions.selectedWorkflowIndex
if (wfi >= 0) {
// Currently in the workflow picker
return if (wfi > 0) {
sessions.copy(selectedWorkflowIndex = wfi - 1)
} else {
// Move back to last session, or wrap to last workflow if no sessions
val list = filteredSessions(sessions)
if (list.isNotEmpty()) {
sessions.copy(selectedWorkflowIndex = -1, selectedId = list.last().id)
} else {
sessions.copy(selectedWorkflowIndex = sessions.workflows.lastIndex)
}
}
}
val list = filteredSessions(sessions)
if (list.isEmpty()) {
// No sessions — move to last workflow
return if (sessions.workflows.isNotEmpty()) {
sessions.copy(selectedWorkflowIndex = sessions.workflows.lastIndex)
} else {
sessions
}
}
val idx = list.indexOfFirst { it.id == sessions.selectedId }
val newIdx = if (idx <= 0) list.lastIndex else idx - 1
return sessions.copy(selectedId = list[newIdx].id, selectedWorkflowIndex = -1)
}
private fun navigateDown(sessions: SessionsState): SessionsState {
val list = filteredSessions(sessions)
if (list.isEmpty()) {
// No sessions — navigate workflows
val wfi = sessions.selectedWorkflowIndex
return if (sessions.workflows.isNotEmpty()) {
val next = if (wfi >= sessions.workflows.lastIndex) 0 else wfi + 1
sessions.copy(selectedWorkflowIndex = next)
} else {
sessions
}
}
val idx = list.indexOfFirst { it.id == sessions.selectedId }
if (idx >= list.lastIndex) {
// At bottom of sessions — move to first workflow
return if (sessions.workflows.isNotEmpty()) {
sessions.copy(selectedId = null, selectedWorkflowIndex = 0)
} else {
sessions.copy(selectedId = list[0].id)
}
}
val newIdx = idx + 1
return sessions.copy(selectedId = list[newIdx].id, selectedWorkflowIndex = -1)
}
private fun formatTime(epochMs: Long): String =
timeFormatter.format(Instant.ofEpochMilli(epochMs))
@Suppress("CyclomaticComplexMethod")
private fun applyServerMessage(
sessions: SessionsState,
msg: ServerMessage,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> {
val (result, effects) = when (msg) {
is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions)
is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock)
is ServerMessage.SessionResumed -> processSessionResumedMessage(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)
is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg)
is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg)
is ServerMessage.StageToolManifest -> processStageToolManifestMessage(sessions, msg)
is ServerMessage.ArtifactCreated -> processArtifactCreatedMessage(sessions, msg)
is ServerMessage.WorkflowList -> processWorkflowListMessage(sessions, msg)
else -> sessions to emptyList()
}
val withBg = incrementBackgroundCountIfNonSelected(result, msg)
log.debug("processed server message: {}", msg)
return withBg to effects
}
private fun sessionIdFromMessage(msg: ServerMessage): String? = when (msg) {
is ServerMessage.SessionStarted -> msg.sessionId.value
is ServerMessage.SessionPaused -> msg.sessionId.value
is ServerMessage.SessionResumed -> msg.sessionId.value
is ServerMessage.SessionCompleted -> msg.sessionId.value
is ServerMessage.SessionFailed -> msg.sessionId.value
is ServerMessage.StageStarted -> msg.sessionId.value
is ServerMessage.StageCompleted -> msg.sessionId.value
is ServerMessage.StageFailed -> msg.sessionId.value
is ServerMessage.ToolStarted -> msg.sessionId.value
is ServerMessage.InferenceCompleted -> msg.sessionId.value
is ServerMessage.ToolCompleted -> msg.sessionId.value
is ServerMessage.ToolFailed -> msg.sessionId.value
is ServerMessage.ToolRejected -> msg.sessionId.value
is ServerMessage.ApprovalRequired -> msg.sessionId.value
is ServerMessage.SessionSnapshot -> msg.sessionId.value
is ServerMessage.ArtifactCreated -> msg.sessionId.value
// Control/non-event messages do not count per ARCH-BADGE-1:
is ServerMessage.StageToolManifest -> null
is ServerMessage.SnapshotComplete -> null
is ServerMessage.ProtocolError -> null
is ServerMessage.ProviderStatusChanged -> null
else -> null
}
private fun incrementBackgroundCountIfNonSelected(
sessions: SessionsState,
msg: ServerMessage,
): SessionsState {
val sessionId = sessionIdFromMessage(msg) ?: return sessions
return if (sessionId != sessions.selectedId) {
sessions.copy(backgroundUpdateCount = sessions.backgroundUpdateCount + 1)
} else {
sessions
}
}
/** TODO(RF-3): If StageToolManifest arrives before SessionStarted, the session
* won't exist in the list and the manifest is silently dropped. Options:
* (a) Buffer the manifest and apply when SessionStarted arrives for this sessionId.
* (b) Create a placeholder SessionSummary from the manifest's sessionId/workflowId.
* Option (b) also requires dedup in processSessionStartedMessage to avoid duplicates.
* Deferred — the spec says manifest arrives on session start, but WebSocket ordering
* is not guaranteed. */
private fun processStageToolManifestMessage(
sessions: SessionsState,
msg: ServerMessage.StageToolManifest,
): Pair<SessionsState, List<Effect>> {
val toolsByStage = msg.stages.associate { stageDecl ->
stageDecl.stageId to stageDecl.tools.map { toolDecl ->
ToolManifestEntry(name = toolDecl.name, tier = toolDecl.tier)
}
}
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(toolsByStage = toolsByStage)
} else {
s
}
},
) to emptyList()
}
private fun processSessionSnapshotMessage(
clock: () -> Long,
sessionState: SessionsState,
msg: ServerMessage.SessionSnapshot,
): Pair<SessionsState, List<Effect>> {
val firstPending = msg.pendingApprovals.firstOrNull()
val approvalInfo = firstPending?.let {
ApprovalInfo(
requestId = it.requestId,
sessionId = msg.sessionId.value,
tier = it.tier,
riskSummary = "unknown",
toolName = it.toolName,
preview = it.preview,
)
}
val status = if (firstPending != null) "PAUSED awaiting approval" else msg.state.status
val eventEntries = msg.recentEvents.map { entry ->
TuiEventEntry(
timestamp = formatTime(entry.timestamp),
type = entry.type,
detail = entry.detail,
)
}
val tools = msg.tools.map { tool ->
val displayStatus = when (tool.status) {
"COMPLETED" -> ToolDisplayStatus.COMPLETED
"FAILED" -> ToolDisplayStatus.FAILED
"REJECTED" -> ToolDisplayStatus.REJECTED
else -> ToolDisplayStatus.STARTED
}
TuiToolRecord(
name = tool.name,
tier = tool.tier,
status = displayStatus,
argsPreview = null,
)
}
val summary = SessionSummary(
id = msg.sessionId.value,
status = status,
workflowId = msg.workflowId,
name = msg.workflowId,
lastEventAt = clock(),
currentStage = msg.state.currentStageId,
currentStageId = msg.state.currentStageId,
pendingApproval = approvalInfo,
recentEvents = eventEntries,
tools = tools,
)
val selected = sessionState.selectedId ?: msg.sessionId.value
return sessionState.copy(
sessions = sessionState.sessions + summary,
selectedId = selected,
) to emptyList()
}
private fun processApprovalRequiredMessage(
sessions: SessionsState,
msg: ServerMessage.ApprovalRequired,
): Pair<SessionsState, List<Effect>> {
val info = ApprovalInfo(
requestId = msg.requestId.value,
sessionId = msg.sessionId.value,
tier = msg.tier.name,
riskSummary = msg.riskSummary.level,
toolName = msg.toolName,
preview = msg.preview,
)
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(pendingApproval = info) else s
},
) to emptyList()
}
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>> {
val result = touchSession(sessions, msg.sessionId.value, "FAILED", clock)
val cleared = if (msg.sessionId.value == result.selectedId) result.copy(selectedId = null) else result
return cleared to emptyList()
}
private fun processSessionCompletedMessage(
sessions: SessionsState,
msg: ServerMessage.SessionCompleted,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> {
val result = touchSession(sessions, msg.sessionId.value, "COMPLETED", clock)
return result to emptyList()
}
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 processSessionResumedMessage(
msg: ServerMessage.SessionResumed,
sessions: SessionsState,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> = sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(
status = "ACTIVE",
pendingApproval = null,
lastEventAt = clock(),
) else s
},
) to emptyList()
private fun processSessionStartedMessage(
msg: ServerMessage.SessionStarted,
clock: () -> Long,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
// Remove any optimistic (STARTING) session created by IDLE submit, then insert the
// real session from the server. This prevents duplicate session entries when the
// server-generated session ID differs from the client-generated optimistic ID.
val hadOptimistic = sessions.sessions.any { it.status == "STARTING" }
val cleaned = sessions.sessions.filter { it.status != "STARTING" }
val summary = SessionSummary(
id = msg.sessionId.value,
status = "ACTIVE",
workflowId = msg.workflowId,
name = msg.workflowId,
lastEventAt = clock(),
currentStage = null,
lastOutput = null,
)
// When replacing an optimistic session, switch selectedId to the real session ID.
// Otherwise preserve the existing selection (normal SessionStarted for a new session).
val selected = if (hadOptimistic) msg.sessionId.value else (sessions.selectedId ?: msg.sessionId.value)
return sessions.copy(
sessions = cleaned + summary,
selectedId = selected,
) to emptyList()
}
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) {
val entry = TuiEventEntry(formatTime(now), "InferenceCompleted", msg.stageId.value)
s.copy(
lastOutput = msg.outputSummary,
lastResponseText = msg.responseText.takeIf { it.isNotEmpty() },
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun touchSession(
sessions: SessionsState,
sessionId: String,
status: String,
clock: () -> Long,
): SessionsState = sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == sessionId) s.copy(status = status, lastEventAt = clock()) else s
},
)
private fun processArtifactCreatedMessage(
sessions: SessionsState,
msg: ServerMessage.ArtifactCreated,
): Pair<SessionsState, List<Effect>> {
val now = msg.sequence
val entry = TuiEventEntry(
timestamp = formatTime(now),
type = "ArtifactCreated",
detail = msg.artifactId.value,
)
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processWorkflowListMessage(
sessions: SessionsState,
msg: ServerMessage.WorkflowList,
): Pair<SessionsState, List<Effect>> =
sessions.copy(workflows = msg.workflows) to emptyList()
}
@@ -1,89 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.TuiState
import org.slf4j.LoggerFactory
object SnapshotPhaseReducer {
private val log = LoggerFactory.getLogger(SnapshotPhaseReducer::class.java)
data class Result(val state: TuiState, val actionsToDispatch: List<Action>)
fun process(state: TuiState, action: Action): Result = when (action) {
is Action.Disconnected -> Result(
state.copy(snapshotPhase = true, pendingEvents = emptyList(), cursors = emptyMap()),
listOf(action),
)
is Action.ServerEventReceived -> handleServerEvent(state, action)
else -> Result(state, listOf(action))
}
private fun handleServerEvent(state: TuiState, action: Action.ServerEventReceived): Result {
val msg = action.message
return when {
msg is ServerMessage.SessionSnapshot -> {
val newCursors = state.cursors + (msg.sessionId.value to msg.lastSessionSequence)
Result(state.copy(cursors = newCursors), listOf(action))
}
msg is ServerMessage.SnapshotComplete -> {
val replayActions = state.pendingEvents.map { Action.ServerEventReceived(it) }
Result(
state.copy(snapshotPhase = false, pendingEvents = emptyList()),
replayActions,
)
}
state.snapshotPhase -> {
Result(state.copy(pendingEvents = state.pendingEvents + msg), emptyList())
}
else -> dedupeLive(state, action, msg)
}
}
private fun dedupeLive(
state: TuiState,
action: Action.ServerEventReceived,
msg: ServerMessage,
): Result {
val sid = sessionIdOf(msg) ?: return Result(state, listOf(action))
val seq = sessionSequenceOf(msg) ?: return Result(state, listOf(action))
val cursor = state.cursors[sid] ?: 0L
return when {
seq <= cursor -> {
log.debug("dropping duplicate sessionSequence={} for sid={} (cursor={})", seq, sid, cursor)
Result(state, emptyList())
}
seq > cursor + 1 -> {
log.warn("gap detected for sid={}: cursor={} -> incoming seq={} (applying anyway)", sid, cursor, seq)
Result(state.copy(cursors = state.cursors + (sid to seq)), listOf(action))
}
else -> Result(state.copy(cursors = state.cursors + (sid to seq)), listOf(action))
}
}
internal fun sessionIdOf(msg: ServerMessage): String? = when (msg) {
is ServerMessage.SessionStarted -> msg.sessionId.value
is ServerMessage.SessionPaused -> msg.sessionId.value
is ServerMessage.SessionResumed -> msg.sessionId.value
is ServerMessage.SessionCompleted -> msg.sessionId.value
is ServerMessage.SessionFailed -> msg.sessionId.value
is ServerMessage.StageStarted -> msg.sessionId.value
is ServerMessage.StageCompleted -> msg.sessionId.value
is ServerMessage.StageFailed -> msg.sessionId.value
is ServerMessage.InferenceStarted -> msg.sessionId.value
is ServerMessage.InferenceCompleted -> msg.sessionId.value
is ServerMessage.InferenceTimedOut -> msg.sessionId.value
is ServerMessage.ToolStarted -> msg.sessionId.value
is ServerMessage.ToolCompleted -> msg.sessionId.value
is ServerMessage.ToolFailed -> msg.sessionId.value
is ServerMessage.ToolRejected -> msg.sessionId.value
is ServerMessage.ApprovalRequired -> msg.sessionId.value
is ServerMessage.SessionSnapshot -> msg.sessionId.value
else -> null
}
internal fun sessionSequenceOf(msg: ServerMessage): Long? = when (msg) {
is ServerMessage.SessionMessage -> msg.sessionSequence
else -> null
}
}
@@ -1,8 +0,0 @@
package com.correx.apps.tui.state
data class ConnectionState(
val connected: Boolean = false,
val reconnecting: Boolean = false,
val attempt: Int = 0,
val nextRetryAtMs: Long? = null,
)
@@ -1,16 +0,0 @@
package com.correx.apps.tui.state
enum class DisplayState { IDLE, IN_SESSION, APPROVAL }
val TuiState.displayState: DisplayState
get() {
val selectedId = sessions.selectedId ?: return DisplayState.IDLE
val selectedSession = sessions.sessions.firstOrNull { it.id == selectedId }
val hasApproval = selectedSession?.pendingApproval != null
return when {
approvalDismissed -> DisplayState.IN_SESSION
hasApproval -> DisplayState.APPROVAL
sessionEntered -> DisplayState.IN_SESSION
else -> DisplayState.IDLE
}
}
@@ -1,6 +0,0 @@
package com.correx.apps.tui.state
data class InputState(
val mode: InputMode = InputMode.ROUTER,
val text: String = "",
)
@@ -1,6 +0,0 @@
package com.correx.apps.tui.state
data class ProviderState(
val id: String = "",
val status: String = "unknown",
)
@@ -1,15 +0,0 @@
package com.correx.apps.tui.state
import com.correx.apps.server.protocol.WorkflowDto
data class SessionsState(
val sessions: List<SessionSummary> = emptyList(),
val selectedId: String? = null,
val filter: String = "",
val backgroundUpdateCount: Int = 0,
val workflows: List<WorkflowDto> = emptyList(),
/** Index into [workflows] when focus is in the workflow picker; -1 = no workflow selected. */
val selectedWorkflowIndex: Int = -1,
/** When true, the workflow list is shown in IDLE layout even when sessions exist. */
val workflowsVisible: Boolean = false,
)
@@ -1,116 +0,0 @@
package com.correx.apps.tui.state
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.router.ChatMode
enum class InputMode { ROUTER, FILTER }
enum class ToolDisplayStatus { STARTED, COMPLETED, FAILED, REJECTED }
enum class ProviderType { LOCAL, REMOTE }
data class TuiToolRecord(
val name: String,
val tier: Int,
val status: ToolDisplayStatus,
val argsPreview: String?,
)
data class TuiEventEntry(
val timestamp: String,
val type: String,
val detail: String,
)
/**
* A single entry in the router conversation log.
* @property role one of "user", "router", or "tool"
* @property content the message text
*/
data class RouterEntry(
val role: String,
val content: String,
)
data class TuiState(
val connection: ConnectionState = ConnectionState(),
val sessions: SessionsState = SessionsState(),
val input: InputState = InputState(),
val provider: ProviderState = ProviderState(),
val inputMode: InputMode = InputMode.ROUTER,
val inputBuffer: String = "",
val inputCursor: Int = 0,
val eventStripVisible: Boolean = true,
val inputHistory: Map<String, List<String>> = emptyMap(),
val inputHistoryIndex: Int = -1,
val savedInputBuffer: String = "",
val pendingDecision: ApprovalDecision? = null,
val approvalDismissed: Boolean = false,
/**
* True when the user has explicitly "entered" a session (pressed Enter on it or
* just started a new one). False when simply navigating the session list with ↑↓.
* Drives [displayState]: IN_SESSION requires both [selectedId] and [sessionEntered].
*/
val sessionEntered: Boolean = false,
val currentModel: String? = null,
val providerType: ProviderType = ProviderType.LOCAL,
val routerConnected: Boolean = false,
val routerMessages: Map<String, List<RouterEntry>> = emptyMap(),
val diffExpanded: Boolean = false,
val diffScrollOffset: Int = 0,
val chatMode: ChatMode = ChatMode.CHAT,
/**
* True from connect until SnapshotComplete observed. Reset to true on Disconnected.
* When true, event-bearing messages are buffered in [pendingEvents]; snapshots are applied immediately.
*/
val snapshotPhase: Boolean = true,
/**
* FIFO buffer of event-bearing server messages received while snapshotPhase == true.
* Drained atomically when snapshot phase completes.
*/
val pendingEvents: List<ServerMessage> = emptyList(),
/**
* Per-session high-water mark keyed by SessionId.value. Updated only on applied live events.
* Used to deduplicate events during live streaming phase.
*/
val cursors: Map<String, Long> = emptyMap(),
/** Context usage for the active session (populated from server snapshot data). */
val contextUsed: Int? = null,
/** Context budget for the active session (populated from server snapshot data). */
val contextBudget: Int? = null,
)
data class ToolManifestEntry(
val name: String,
val tier: Int,
)
data class SessionSummary(
val id: String,
val status: String,
val workflowId: String,
val name: String = "",
val lastEventAt: Long,
val currentStage: String? = null,
val currentStageId: String? = null,
val nextStageId: String? = null,
val lastOutput: String? = null,
val lastResponseText: String? = null,
val tools: List<TuiToolRecord> = emptyList(),
val toolsByStage: Map<String, List<ToolManifestEntry>> = emptyMap(),
val recentEvents: List<TuiEventEntry> = emptyList(),
val pendingApproval: ApprovalInfo? = null,
)
data class ApprovalInfo(
val requestId: String,
val sessionId: String,
val tier: String,
val riskSummary: String,
val toolName: String?,
val preview: String?,
)
fun TuiState.selectedPendingApproval(): ApprovalInfo? =
sessions.sessions.find { it.id == sessions.selectedId }?.pendingApproval
@@ -1,7 +0,0 @@
package com.correx.apps.tui.ws
sealed interface ConnectionEvent {
data object Connected : ConnectionEvent
data object Disconnected : ConnectionEvent
data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : ConnectionEvent
}
@@ -1,90 +0,0 @@
package com.correx.apps.tui.ws
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ServerMessage
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.HttpMethod
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val INITIAL_RETRY_DELAY_MS = 1_000L
private const val MAX_RETRY_DELAY_MS = 30_000L
class TuiWsClient(
private val host: String,
private val port: Int,
) {
private val json = Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
}
private val client = HttpClient(CIO) {
install(WebSockets)
install(Logging) {
level = LogLevel.NONE
}
}
private var session: WebSocketSession? = null
private val _messages = Channel<ServerMessage>(Channel.UNLIMITED)
val messages: Flow<ServerMessage> = _messages.receiveAsFlow()
private val _connection = Channel<ConnectionEvent>(Channel.UNLIMITED)
val connection: Flow<ConnectionEvent> = _connection.receiveAsFlow()
suspend fun send(message: ClientMessage) {
runCatching {
session?.send(json.encodeToString(message))
}
}
suspend fun connect() {
var delayMs = INITIAL_RETRY_DELAY_MS
var attempt = 0
while (true) {
runCatching {
client.webSocket(method = HttpMethod.Get, host = host, port = port, path = "/stream") {
session = this
_connection.send(ConnectionEvent.Connected)
delayMs = INITIAL_RETRY_DELAY_MS
attempt = 0
for (frame in incoming) {
if (frame is Frame.Text) {
runCatching {
json.decodeFromString<ServerMessage>(frame.readText())
}.onSuccess { msg ->
_messages.send(msg)
}
}
}
}
}.onFailure { }
session = null
_connection.send(ConnectionEvent.Disconnected)
attempt++
val nextRetryAtMs = System.currentTimeMillis() + delayMs
_connection.send(ConnectionEvent.RetryScheduled(attempt = attempt, nextRetryAtMs = nextRetryAtMs))
delay(delayMs)
delayMs = minOf(delayMs * 2, MAX_RETRY_DELAY_MS)
}
}
fun close() {
client.close()
}
}
@@ -1,29 +0,0 @@
package com.correx.apps.tui
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.io.File
class SingleConsumerContractTest {
private val sourceRoot = File("src/main/kotlin")
private fun countOccurrences(literal: String): Int =
sourceRoot.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.sumOf { file ->
file.readText().split(literal).size - 1
}
@Test
fun `ws messages has exactly one collector`() {
assertEquals(1, countOccurrences("ws.messages.collect"),
"Expected exactly one ws.messages.collect in src/main/kotlin")
}
@Test
fun `ws connection has exactly one collector`() {
assertEquals(1, countOccurrences("ws.connection.collect"),
"Expected exactly one ws.connection.collect in src/main/kotlin")
}
}
@@ -1,147 +0,0 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class KeyResolverTest {
private fun resolve(
key: KeyEvent,
displayState: DisplayState = DisplayState.IDLE,
inputMode: InputMode = InputMode.ROUTER,
hasPendingApproval: Boolean = false,
inputText: String = "",
) = KeyResolver.resolve(key, displayState, inputMode, hasPendingApproval, inputText)
// ── Global actions (work regardless of display state / input mode) ──
@Test
fun `Quit in ROUTER mode resolves to Quit`() {
assertEquals(Action.Quit, resolve(KeyEvent.Quit))
}
@Test
fun `Quit in FILTER mode resolves to Quit`() {
assertEquals(Action.Quit, resolve(KeyEvent.Quit, inputMode = InputMode.FILTER))
}
@Test
fun `Approve resolves globally to ApproveActive`() {
assertEquals(Action.ApproveActive, resolve(KeyEvent.Approve))
}
@Test
fun `Reject resolves globally to RejectActive`() {
assertEquals(Action.RejectActive, resolve(KeyEvent.Reject))
}
// ── FILTER mode ──
@Test
fun `NavUp in FILTER mode resolves to NavigateUp`() {
assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, inputMode = InputMode.FILTER))
}
@Test
fun `NavDown in FILTER mode resolves to NavigateDown`() {
assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, inputMode = InputMode.FILTER))
}
@Test
fun `Enter in FILTER mode on blank input resolves to null`() {
assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER))
}
@Test
fun `Enter in FILTER mode on non-blank input resolves to SubmitInput`() {
assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = "test"))
}
@Test
fun `Enter in FILTER mode on whitespace-only input resolves to null`() {
assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = " "))
}
@Test
fun `Escape in FILTER mode resolves to CancelInput`() {
assertEquals(Action.CancelInput, resolve(KeyEvent.Escape, inputMode = InputMode.FILTER))
}
@Test
fun `Backspace in FILTER mode resolves to Backspace`() {
assertEquals(Action.Backspace, resolve(KeyEvent.Backspace, inputMode = InputMode.FILTER))
}
@Test
fun `CharInput in FILTER mode resolves to AppendChar`() {
assertEquals(Action.AppendChar('a'), resolve(KeyEvent.CharInput('a'), inputMode = InputMode.FILTER))
}
// ── IDLE display state ──
@Test
fun `NavUp in IDLE resolves to NavigateUp`() {
assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, DisplayState.IDLE))
}
@Test
fun `NavDown in IDLE resolves to NavigateDown`() {
assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, DisplayState.IDLE))
}
@Test
fun `Enter in IDLE on blank input resolves to SubmitInput`() {
assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, DisplayState.IDLE))
}
@Test
fun `Enter in IDLE on non-blank input resolves to SubmitInput`() {
assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, DisplayState.IDLE, inputText = "wf"))
}
// ── IN_SESSION display state ──
@Test
fun `NavUp in IN_SESSION resolves to NavigateUp`() {
assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, DisplayState.IN_SESSION))
}
@Test
fun `NavDown in IN_SESSION resolves to NavigateDown`() {
assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, DisplayState.IN_SESSION))
}
@Test
fun `CharInput in ROUTER mode resolves to AppendChar`() {
assertEquals(Action.AppendChar('x'), resolve(KeyEvent.CharInput('x')))
}
@Test
fun `ShowPendingApproval in IN_SESSION with pending approval emits action`() {
assertEquals(
Action.ShowPendingApproval,
resolve(KeyEvent.ShowPendingApproval, DisplayState.IN_SESSION, hasPendingApproval = true),
)
}
@Test
fun `ShowPendingApproval in IN_SESSION without pending approval resolves to null`() {
assertNull(resolve(KeyEvent.ShowPendingApproval, DisplayState.IN_SESSION, hasPendingApproval = false))
}
// ── APPROVAL display state ──
@Test
fun `Enter in APPROVAL resolves to SubmitApprovalDecision`() {
assertEquals(Action.SubmitApprovalDecision, resolve(KeyEvent.Enter, DisplayState.APPROVAL))
}
@Test
fun `Escape in APPROVAL resolves to CancelInput`() {
assertEquals(Action.CancelInput, resolve(KeyEvent.Escape, DisplayState.APPROVAL))
}
}
@@ -1,139 +0,0 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import dev.tamboui.tui.event.KeyCode
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 {
// --- Ctrl-C always → Cancel ---
@Test
fun `ctrl-c maps to Cancel`() {
assertEquals(KeyEvent.Cancel, 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)))
}
@Test
fun `ctrl-a maps to Approve`() {
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)))
}
@Test
fun `ctrl-n maps to NewSession`() {
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code)))
}
@Test
fun `ctrl-h maps to ShowPendingApproval`() {
assertEquals(KeyEvent.ShowPendingApproval, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code)))
}
@Test
fun `ctrl-e maps to ToggleEventStrip`() {
assertEquals(KeyEvent.ToggleEventStrip, 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)))
}
// --- Alt always → null ---
@Test
fun `alt modifier maps to null`() {
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')))
}
@Test
fun `bare q maps to CharInput in 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')))
}
@Test
fun `bare x maps to CharInput in 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')))
}
@Test
fun `bare q maps to CharInput`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
}
// --- KeyCode mappings ---
@Test
fun `UP maps to NavUp`() {
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP)))
}
@Test
fun `DOWN maps to NavDown`() {
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN)))
}
@Test
fun `ENTER maps to Enter`() {
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER)))
}
@Test
fun `ESCAPE maps to Escape`() {
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE)))
}
@Test
fun `BACKSPACE maps to Backspace`() {
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE)))
}
@Test
fun `TAB maps to Tab`() {
assertEquals(KeyEvent.Tab, 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)))
}
@Test
fun `NUL codepoint as CHAR event maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00)))
}
}
@@ -1,51 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ConnectionState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ConnectionReducerTest {
private fun reduce(connection: ConnectionState = ConnectionState(), action: Action) =
ConnectionReducer.reduce(connection, action)
@Test
fun `Connected sets connected true and clears reconnection state`() {
val initial = ConnectionState(connected = false, reconnecting = true, attempt = 3, nextRetryAtMs = 9999L)
val (state, effects) = reduce(connection = initial, action = Action.Connected)
assertTrue(state.connected)
assertFalse(state.reconnecting)
assertEquals(0, state.attempt)
assertNull(state.nextRetryAtMs)
assertTrue(effects.isEmpty())
}
@Test
fun `Disconnected sets connected false and reconnecting true`() {
val initial = ConnectionState(connected = true)
val (state, effects) = reduce(connection = initial, action = Action.Disconnected)
assertFalse(state.connected)
assertTrue(state.reconnecting)
assertTrue(effects.isEmpty())
}
@Test
fun `RetryScheduled updates attempt and nextRetryAtMs`() {
val (state, effects) = reduce(action = Action.RetryScheduled(attempt = 2, nextRetryAtMs = 12345L))
assertTrue(state.reconnecting)
assertEquals(2, state.attempt)
assertEquals(12345L, state.nextRetryAtMs)
assertTrue(effects.isEmpty())
}
@Test
fun `Unknown action is no-op`() {
val initial = ConnectionState(connected = true)
val (state, _) = reduce(connection = initial, action = Action.NavigateUp)
assertEquals(initial, state)
}
}
@@ -1,50 +0,0 @@
package com.correx.apps.tui.reducer
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* Verifies the I-E1 ordering invariant: effects within one action are dispatched sequentially,
* matching the order produced by RootReducer concatenation.
*
* The old fire-and-forget loop (`effects.forEach { launch { dispatcher.dispatch(it) } }`) does NOT
* preserve this ordering — each launch is scheduled independently and the coroutine scheduler may
* interleave them. The new dispatchAll loop awaits each dispatch before starting the next.
*/
class EffectDispatchOrderTest {
@Test
fun `sequential loop preserves order even when first dispatch delays`(): Unit = runBlocking {
val observed = mutableListOf<Int>()
// Simulate what dispatchAll does: for (effect in effects) dispatcher.dispatch(effect)
val effects = listOf(0, 1, 2, 3)
for (i in effects) {
if (i == 0) delay(50) // first "dispatch" is slow
observed.add(i)
}
assertEquals(listOf(0, 1, 2, 3), observed)
}
@Test
fun `fire-and-forget loop breaks ordering when first dispatch delays`(): Unit = runBlocking {
val observed = mutableListOf<Int>()
// Reproduce what the OLD code did: launch each effect independently.
// The delay on index 0 causes later indices to be observed first.
val jobs = (0..3).map { i ->
launch {
if (i == 0) delay(50)
observed.add(i)
}
}
jobs.forEach { it.join() }
// With the old approach, 0 arrives last due to the delay.
assertEquals(listOf(1, 2, 3, 0), observed)
}
}
@@ -1,105 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.TuiState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class InputReducerTest {
private fun reduce(
state: TuiState = TuiState(),
action: Action,
) = InputReducer.reduce(state, action)
@Test
fun `OpenNewSessionPrompt sets mode to ROUTER with empty buffer`() {
val (state, effects) = reduce(action = Action.OpenNewSessionPrompt)
assertEquals(InputMode.ROUTER, state.inputMode)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `AppendChar appends character at cursor position`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab", inputCursor = 2),
action = Action.AppendChar('c'),
)
assertEquals("abc", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `AppendChar inserts at cursor when not at end`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab", inputCursor = 1),
action = Action.AppendChar('x'),
)
assertEquals("axb", state.inputBuffer)
assertEquals(2, state.inputCursor)
assertTrue(effects.isEmpty())
}
@Test
fun `Backspace drops character before cursor`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "abc", inputCursor = 3),
action = Action.Backspace,
)
assertEquals("ab", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `Backspace on empty buffer stays empty`() {
val (state, _) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = ""),
action = Action.Backspace,
)
assertEquals("", state.inputBuffer)
}
@Test
fun `CancelInput resets to ROUTER mode with empty buffer`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.FILTER, inputBuffer = "hello"),
action = Action.CancelInput,
)
assertEquals(InputMode.ROUTER, state.inputMode)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `SubmitInput clears input buffer with no effects`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow"),
action = Action.SubmitInput,
)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `SubmitInput with blank text clears input buffer`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = " "),
action = Action.SubmitInput,
)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `SubmitInput in FILTER mode clears buffer with no effects`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.FILTER, inputBuffer = "myfilter"),
action = Action.SubmitInput,
)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
}
@@ -1,37 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.TuiState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class RootReducerQuitTest {
private val fixedClock: () -> Long = { 1000L }
@Test
fun `Effect Quit is the terminal effect when Action Quit is reduced`() {
val (_, effects) = RootReducer.reduce(TuiState(snapshotPhase = false), Action.Quit, fixedClock)
assertEquals(1, effects.count { it is Effect.Quit })
assertTrue(effects.last() is Effect.Quit)
}
@Test
fun `no Effect Quit when action is not Quit`() {
val (_, effects) = RootReducer.reduce(TuiState(snapshotPhase = false), Action.Connected, fixedClock)
assertFalse(effects.any { it is Effect.Quit })
}
@Test
fun `sub-reducers are invoked for Action Quit`() {
val initial = TuiState(snapshotPhase = false)
val (state, _) = RootReducer.reduce(initial, Action.Quit, fixedClock)
// ConnectionReducer handles Action.Quit by keeping state; verify state went through sub-reducers
// (state is the result of all sub-reducers, not an early-exit)
assertEquals(initial.inputMode, state.inputMode)
assertEquals(initial.sessions, state.sessions)
assertEquals(initial.connection, state.connection)
}
}
@@ -1,465 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ConnectionState
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.ProviderType
import com.correx.apps.tui.state.RouterEntry
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import com.correx.core.approvals.Tier
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class RootReducerTest {
private val fixedClock: () -> Long = { 1000L }
@Test
fun `TuiState defaults include snapshot phase, pending events, and cursors`() {
val state = TuiState()
assertEquals(true, state.snapshotPhase)
assertEquals(emptyList<Any>(), state.pendingEvents)
assertEquals(emptyMap<String, Long>(), state.cursors)
}
@Test
fun `SubmitInput in ROUTER mode emits StartChatSession effect and resets buffer`() {
val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow")
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
assertEquals(InputMode.ROUTER, state.inputMode)
assertEquals("", state.inputBuffer)
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
assertEquals(1, wsEffects.size)
val msg = wsEffects[0].message as ClientMessage.StartChatSession
assertEquals("my-workflow", msg.text)
assertTrue(msg.sessionId.value.isNotBlank())
}
@Test
fun `Connected action updates connection state without touching others`() {
val initial = TuiState()
val (state, effects) = RootReducer.reduce(initial, Action.Connected, fixedClock)
assertTrue(state.connection.connected)
assertTrue(effects.isEmpty())
assertEquals(initial.inputMode, state.inputMode)
assertEquals(initial.inputBuffer, state.inputBuffer)
assertEquals(initial.sessions, state.sessions)
assertEquals(initial.provider, state.provider)
}
@Test
fun `NavigateDown in IDLE with no selection selects first session`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1"), session("s2")),
selectedId = null,
),
)
val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock)
assertEquals("s1", result.sessions.selectedId)
}
@Test
fun `NavigateUp in IDLE selects last session when none selected`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("a"), session("b"), session("c")),
selectedId = null,
),
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals("c", result.sessions.selectedId)
}
@Test
fun `SnapshotComplete flips snapshotPhase and replays buffered events in order`() {
val startMsg1 = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s1"),
workflowId = "wf",
sequence = 1L,
sessionSequence = 1L,
)
val startMsg2 = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s2"),
workflowId = "wf",
sequence = 2L,
sessionSequence = 1L,
)
val initial = TuiState(
snapshotPhase = true,
pendingEvents = listOf(startMsg1, startMsg2),
)
val (result, _) = RootReducer.reduce(
initial,
Action.ServerEventReceived(com.correx.apps.server.protocol.ServerMessage.SnapshotComplete),
fixedClock,
)
assertEquals(false, result.snapshotPhase)
assertTrue(result.pendingEvents.isEmpty())
assertEquals(2, result.sessions.sessions.size)
assertEquals(1L, result.cursors["s1"])
assertEquals(1L, result.cursors["s2"])
}
@Test
fun `Disconnected resets snapshot state and sets connection to disconnected`() {
val state = TuiState(
snapshotPhase = false,
cursors = mapOf("s1" to 3L),
connection = ConnectionState(connected = true),
)
val (newState, _) = RootReducer.reduce(state, Action.Disconnected, fixedClock)
assertEquals(true, newState.snapshotPhase)
assertTrue(newState.cursors.isEmpty())
assertEquals(false, newState.connection.connected)
assertEquals(state.sessions.sessions, newState.sessions.sessions)
}
@Test
fun `SnapshotComplete with empty pendingEvents flips phase and produces no sessions`() {
val initial = TuiState(snapshotPhase = true, pendingEvents = emptyList())
val (result, effects) = RootReducer.reduce(
initial,
Action.ServerEventReceived(com.correx.apps.server.protocol.ServerMessage.SnapshotComplete),
fixedClock,
)
assertEquals(false, result.snapshotPhase)
assertTrue(result.pendingEvents.isEmpty())
assertTrue(result.sessions.sessions.isEmpty())
assertTrue(effects.isEmpty())
}
// ── Input history navigation (ARCH-HISTORY-2/3) ──
@Test
fun `NavigateUp in IN_SESSION at index -1 saves buffer and loads last history entry`() {
val state = TuiState(
snapshotPhase = false,
sessionEntered = true,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")),
inputHistoryIndex = -1,
inputBuffer = "current input",
savedInputBuffer = "",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals(2, result.inputHistoryIndex)
assertEquals("msg3", result.inputBuffer)
assertEquals("current input", result.savedInputBuffer)
}
@Test
fun `NavigateUp in IN_SESSION from index above 0 decrements index and loads entry`() {
val state = TuiState(
snapshotPhase = false,
sessionEntered = true,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")),
inputHistoryIndex = 2,
inputBuffer = "msg3",
savedInputBuffer = "original",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals(1, result.inputHistoryIndex)
assertEquals("msg2", result.inputBuffer)
assertEquals("original", result.savedInputBuffer)
}
@Test
fun `NavigateUp in IN_SESSION at index 0 stays at oldest entry`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2")),
inputHistoryIndex = 0,
inputBuffer = "msg1",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals(0, result.inputHistoryIndex)
assertEquals("msg1", result.inputBuffer)
}
@Test
fun `NavigateUp in IN_SESSION with empty history is no-op`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = emptyMap(),
inputHistoryIndex = -1,
inputBuffer = "still here",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals(-1, result.inputHistoryIndex)
assertEquals("still here", result.inputBuffer)
}
@Test
fun `NavigateDown in IN_SESSION at index -1 stays at current buffer`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2")),
inputHistoryIndex = -1,
inputBuffer = "typing...",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock)
assertEquals(-1, result.inputHistoryIndex)
assertEquals("typing...", result.inputBuffer)
}
@Test
fun `NavigateDown in IN_SESSION at last entry restores savedInputBuffer and resets index`() {
val state = TuiState(
snapshotPhase = false,
sessionEntered = true,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2")),
inputHistoryIndex = 1,
inputBuffer = "msg2",
savedInputBuffer = "my saved text",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock)
assertEquals(-1, result.inputHistoryIndex)
assertEquals("my saved text", result.inputBuffer)
}
@Test
fun `NavigateDown in IN_SESSION from below last entry increments index`() {
val state = TuiState(
snapshotPhase = false,
sessionEntered = true,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")),
inputHistoryIndex = 0,
inputBuffer = "msg1",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock)
assertEquals(1, result.inputHistoryIndex)
assertEquals("msg2", result.inputBuffer)
}
@Test
fun `NavigateUp in IN_SESSION with FILTER mode does not navigate history`() {
val state = TuiState(
snapshotPhase = false,
inputMode = InputMode.FILTER,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2")),
inputHistoryIndex = -1,
inputBuffer = "dont touch",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
// In FILTER mode, history nav should not activate
assertEquals(-1, result.inputHistoryIndex)
assertEquals("dont touch", result.inputBuffer)
}
@Test
fun `AppendChar followed by Backspace returns to original buffer`() {
val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab")
val (s1, _) = RootReducer.reduce(initial, Action.AppendChar('c'), fixedClock)
val (s2, _) = RootReducer.reduce(s1, Action.Backspace, fixedClock)
assertEquals("ab", s2.inputBuffer)
}
private fun approvalRequiredMsg(sessionId: String, requestId: String = "r1") =
ServerMessage.ApprovalRequired(
sessionId = SessionId(sessionId),
requestId = ApprovalRequestId(requestId),
tier = Tier.T2,
riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"),
toolName = "bash",
preview = null,
sequence = 1L,
sessionSequence = 1L,
)
private fun session(id: String) = SessionSummary(id = id, status = "ACTIVE", workflowId = "wf", lastEventAt = 0L)
@Test
fun `ApprovalRequired on selected session transitions display state to APPROVAL`() {
val initial = TuiState(
snapshotPhase = false,
sessions = SessionsState(sessions = listOf(session("s1")), selectedId = "s1"),
)
val (state, _) = RootReducer.reduce(
initial, Action.ServerEventReceived(approvalRequiredMsg("s1")), fixedClock,
)
assertEquals(DisplayState.APPROVAL, state.displayState)
assertEquals("r1", state.sessions.sessions[0].pendingApproval?.requestId)
}
@Test
fun `ApprovalRequired on non-selected session does not transition to APPROVAL`() {
val initial = TuiState(
snapshotPhase = false,
sessionEntered = true,
sessions = SessionsState(
sessions = listOf(session("s1"), session("s2")),
selectedId = "s1",
),
)
val (state, _) = RootReducer.reduce(
initial, Action.ServerEventReceived(approvalRequiredMsg("s2")), fixedClock,
)
assertEquals(DisplayState.IN_SESSION, state.displayState)
assertEquals(null, state.sessions.sessions.find { it.id == "s1" }?.pendingApproval)
assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId)
}
@Test
fun `ProviderStatusChanged updates currentModel and providerType`() {
val initial = TuiState(snapshotPhase = false, currentModel = null, providerType = ProviderType.LOCAL)
val msg = ServerMessage.ProviderStatusChanged(
providerId = "llama.cpp",
status = ProviderHealthDto("llama.cpp", "healthy", null),
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(msg), fixedClock)
assertEquals("llama.cpp", state.currentModel)
assertEquals(ProviderType.LOCAL, state.providerType)
}
@Test
fun `ProviderStatusChanged for remote provider sets providerType to REMOTE`() {
val initial = TuiState(snapshotPhase = false, currentModel = null, providerType = ProviderType.LOCAL)
val msg = ServerMessage.ProviderStatusChanged(
providerId = "openai",
status = ProviderHealthDto("openai", "healthy", null),
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(msg), fixedClock)
assertEquals("openai", state.currentModel)
assertEquals(ProviderType.REMOTE, state.providerType)
}
@Test
fun `SessionStarted switches selectedId to new session when another session is selected`() {
val initial = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1"), session("s2")),
selectedId = "s1",
),
)
val startedMsg = ServerMessage.SessionStarted(
sessionId = SessionId("s2"),
workflowId = "wf",
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(startedMsg), fixedClock)
assertEquals("s2", state.sessions.selectedId)
assertTrue(state.sessionEntered)
}
@Test
fun `ToolCompleted with diff appends diff as tool entry to per-session routerMessages`() {
val initial = TuiState(snapshotPhase = false)
val diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old content\n+new content"
val toolCompleted = ServerMessage.ToolCompleted(
sessionId = SessionId("s1"),
toolName = "file_write",
outputSummary = "wrote 3 lines",
occurredAt = fixedClock(),
diff = diff,
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
val msgs = state.routerMessages["s1"]
assertEquals(1, msgs?.size)
val entry = msgs?.first()
assertEquals("tool", entry?.role)
assertTrue(entry?.content?.contains("--- a/file.txt") == true)
assertTrue(entry?.content?.contains("+new content") == true)
}
@Test
fun `ToolCompleted without diff does not append to routerMessages`() {
val existing = listOf(RouterEntry("user", "existing msg"))
val initial = TuiState(snapshotPhase = false, routerMessages = mapOf("s1" to existing))
val toolCompleted = ServerMessage.ToolCompleted(
sessionId = SessionId("s1"),
toolName = "file_write",
outputSummary = "wrote 3 lines",
occurredAt = fixedClock(),
diff = null,
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
val msgs = state.routerMessages["s1"]
assertEquals(1, msgs?.size)
assertEquals(RouterEntry("user", "existing msg"), msgs?.first())
}
@Test
fun `routerMessages are isolated per session`() {
val initial = TuiState(snapshotPhase = false)
val msg1 = ServerMessage.RouterResponseMessage(
sessionId = SessionId("s1"), content = "hello from s1",
steeringEmitted = false, sequence = null, sessionSequence = null,
)
val msg2 = ServerMessage.RouterResponseMessage(
sessionId = SessionId("s2"), content = "hello from s2",
steeringEmitted = false, sequence = null, sessionSequence = null,
)
val after1 = RootReducer.reduce(initial, Action.ServerEventReceived(msg1), fixedClock).first
val after2 = RootReducer.reduce(after1, Action.ServerEventReceived(msg2), fixedClock).first
assertEquals(listOf(RouterEntry("router", "hello from s1")), after2.routerMessages["s1"])
assertEquals(listOf(RouterEntry("router", "hello from s2")), after2.routerMessages["s2"])
}
@Test
fun `SubmitInput in IDLE with text creates optimistic session and enters session`() {
val initial = TuiState(snapshotPhase = false, inputMode = InputMode.ROUTER, inputBuffer = "my chat text")
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
assertTrue(state.sessionEntered)
assertNotNull(state.sessions.selectedId)
assertEquals(1, state.sessions.sessions.size)
assertEquals("STARTING", state.sessions.sessions[0].status)
assertEquals(state.sessions.selectedId, state.sessions.sessions[0].id)
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
assertEquals(1, wsEffects.size)
assertTrue(wsEffects[0].message is ClientMessage.StartChatSession)
}
}
@@ -1,429 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ApprovalDto
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.EventEntryDto
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.SessionStateDto
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.core.approvals.Tier
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SessionsReducerTest {
private val fixedClock: () -> Long = { 1000L }
private fun session(id: String) = SessionSummary(
id = id,
status = "ACTIVE",
workflowId = "wf",
lastEventAt = 0L,
currentStage = null,
lastOutput = null,
)
private fun reduce(
sessions: SessionsState = SessionsState(),
displayState: DisplayState = DisplayState.IDLE,
inputMode: InputMode = InputMode.ROUTER,
inputText: String = "",
action: Action,
) = SessionsReducer.reduce(
SessionsReducerContext(
sessions = sessions,
displayState = displayState,
inputMode = inputMode,
inputText = inputText,
action = action,
clock = fixedClock,
),
)
@Test
fun `NavigateUp wraps from top to bottom`() {
val s = SessionsState(sessions = listOf(session("a"), session("b"), session("c")), selectedId = "a")
val (state, effects) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateUp)
assertEquals("c", state.selectedId)
assertTrue(effects.isEmpty())
}
@Test
fun `NavigateDown advances selection`() {
val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "a")
val (state, _) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateDown)
assertEquals("b", state.selectedId)
}
@Test
fun `NavigateDown wraps from bottom to top`() {
val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "b")
val (state, _) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateDown)
assertEquals("a", state.selectedId)
}
@Test
fun `SubmitInput in Filter mode copies inputText into filter`() {
val s = SessionsState()
val (state, effects) = reduce(
sessions = s, inputMode = InputMode.FILTER, inputText = "myfilter", action = Action.SubmitInput,
)
assertEquals("myfilter", state.filter)
assertTrue(effects.isEmpty())
}
@Test
fun `SubmitInput in Filter mode with empty text clears filter`() {
val s = SessionsState(filter = "old")
val (state, effects) = reduce(
sessions = s, inputMode = InputMode.FILTER, inputText = "", action = Action.SubmitInput,
)
assertEquals("", state.filter)
assertTrue(effects.isEmpty())
}
@Test
fun `CancelInput in Filter mode clears filter`() {
val s = SessionsState(filter = "something")
val (state, effects) = reduce(sessions = s, inputMode = InputMode.FILTER, action = Action.CancelInput)
assertEquals("", state.filter)
assertTrue(effects.isEmpty())
}
@Test
fun `CancelInput in non-Filter mode is no-op`() {
val s = SessionsState(filter = "something")
val (state, _) = reduce(sessions = s, inputMode = InputMode.ROUTER, action = Action.CancelInput)
assertEquals("something", state.filter)
}
@Test
fun `ServerEventReceived SessionStarted appends session and sets selection`() {
val msg = ServerMessage.SessionStarted(sessionId = SessionId("s1"), workflowId = "wf1", sequence = 1L, sessionSequence = 1L)
val (state, effects) = reduce(action = Action.ServerEventReceived(msg))
assertEquals(1, state.sessions.size)
assertEquals("s1", state.sessions[0].id)
assertEquals("s1", state.selectedId)
assertTrue(effects.isEmpty())
}
@Test
fun `ServerEventReceived SessionStarted preserves existing selection`() {
val existing = SessionsState(sessions = listOf(session("existing")), selectedId = "existing")
val msg = ServerMessage.SessionStarted(sessionId = SessionId("new"), workflowId = "wf", sequence = 1L, sessionSequence = 1L)
val (state, _) = reduce(sessions = existing, action = Action.ServerEventReceived(msg))
assertEquals("existing", state.selectedId)
assertEquals(2, state.sessions.size)
}
@Test
fun `ServerEventReceived SessionCompleted updates status to COMPLETED`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.SessionCompleted(sessionId = SessionId("s1"), sequence = 1L, sessionSequence = 1L)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("COMPLETED", state.sessions[0].status)
assertEquals(1000L, state.sessions[0].lastEventAt)
}
@Test
fun `ServerEventReceived SessionFailed updates status to FAILED`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.SessionFailed(sessionId = SessionId("s1"), reason = "oops", sequence = 1L, sessionSequence = 1L)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
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"),
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
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",
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage)
assertEquals(1000L, state.sessions[0].lastEventAt)
}
@Test
fun `CancelSelectedSession emits CancelSession effect`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
assertEquals(1, effects.size)
val effect = effects[0] as Effect.SendWs
val msg = effect.message as ClientMessage.CancelSession
assertEquals("s1", msg.sessionId.value)
}
@Test
fun `CancelSelectedSession with no selection emits no effects`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = null)
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
assertTrue(effects.isEmpty())
}
@Test
fun `ServerEventReceived InferenceCompleted updates lastOutput and lastResponseText`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.InferenceCompleted(
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "summary", responseText = "full response",
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
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 = "",
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
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"),
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
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",
occurredAt = fixedClock(),
sequence = 1L,
sessionSequence = 1L,
)
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, sequence = 1L, sessionSequence = 1L)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("PAUSED awaiting approval", state.sessions[0].status)
}
@Test
fun `ApprovalRequired sets pendingApproval on matching session`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.ApprovalRequired(
sessionId = SessionId("s1"),
requestId = ApprovalRequestId("r1"),
tier = Tier.T2,
riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"),
toolName = "bash",
preview = "rm -rf /",
sequence = 1L,
sessionSequence = 1L,
)
val (state, effects) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("r1", state.sessions[0].pendingApproval?.requestId)
assertEquals("s1", state.sessions[0].pendingApproval?.sessionId)
assertEquals("bash", state.sessions[0].pendingApproval?.toolName)
assertTrue(effects.isEmpty())
}
@Test
fun `ApprovalRequired for unknown session is no-op`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.ApprovalRequired(
sessionId = SessionId("s2"),
requestId = ApprovalRequestId("r1"),
tier = Tier.T2,
riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"),
toolName = "bash",
preview = null,
sequence = 1L,
sessionSequence = 1L,
)
val (state, effects) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(s.sessions, state.sessions)
assertTrue(effects.isEmpty())
}
@Test
fun `SessionSnapshot with pendingApprovals populates pendingApproval on summary`() {
val msg = ServerMessage.SessionSnapshot(
sessionId = SessionId("s1"),
workflowId = "wf-1",
state = SessionStateDto("PAUSED", null, null),
pendingApprovals = listOf(ApprovalDto(requestId = "r9", tier = "T2")),
lastSequence = 1L,
lastSessionSequence = 1L,
)
val (state, _) = reduce(action = Action.ServerEventReceived(msg))
assertEquals("r9", state.sessions[0].pendingApproval?.requestId)
assertEquals("PAUSED awaiting approval", state.sessions[0].status)
}
private fun pendingApproval(requestId: String = "req-1", sessionId: String = "s1") = ApprovalInfo(
requestId = requestId,
sessionId = sessionId,
tier = "T2",
riskSummary = "HIGH",
toolName = "bash",
preview = null,
)
@Test
fun `ApproveActive is no-op in SessionsReducer (handled by RootReducer pendingDecision)`() {
val s = SessionsState(
sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())),
selectedId = "s1",
)
val (state, effects) = reduce(sessions = s, action = Action.ApproveActive)
assertEquals(s, state)
assertTrue(effects.isEmpty())
}
@Test
fun `RejectActive is no-op in SessionsReducer (handled by RootReducer pendingDecision)`() {
val s = SessionsState(
sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())),
selectedId = "s1",
)
val (state, effects) = reduce(sessions = s, action = Action.RejectActive)
assertEquals(s, state)
assertTrue(effects.isEmpty())
}
@Test
fun `ApproveActive is no-op when selected session has no pendingApproval`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val (state, effects) = reduce(sessions = s, action = Action.ApproveActive)
assertEquals(s, state)
assertTrue(effects.isEmpty())
}
@Test
fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() {
val msg = ServerMessage.SessionSnapshot(
sessionId = SessionId("s1"),
workflowId = "wf-1",
state = SessionStateDto("ACTIVE", null, null),
pendingApprovals = emptyList(),
lastSequence = 1L,
lastSessionSequence = 1L,
)
val (state, _) = reduce(action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].pendingApproval)
}
@Test
fun `SessionSnapshot recentEvents are mapped to TuiEventEntry on summary`() {
val msg = ServerMessage.SessionSnapshot(
sessionId = SessionId("s1"),
workflowId = "wf-1",
state = SessionStateDto("COMPLETED", null, null),
pendingApprovals = emptyList(),
recentEvents = listOf(
EventEntryDto(timestamp = 1000L, type = "StageStarted", detail = "stage-1"),
EventEntryDto(timestamp = 2000L, type = "InferenceCompleted", detail = "stage-1"),
EventEntryDto(timestamp = 3000L, type = "SessionCompleted", detail = ""),
),
lastSequence = 1L,
lastSessionSequence = 1L,
)
val (state, _) = reduce(action = Action.ServerEventReceived(msg))
val events = state.sessions[0].recentEvents
assertEquals(3, events.size)
assertEquals("StageStarted", events[0].type)
assertEquals("stage-1", events[0].detail)
assertEquals("InferenceCompleted", events[1].type)
assertEquals("SessionCompleted", events[2].type)
assertEquals("", events[2].detail)
}
@Test
fun `SubmitInput in IDLE creates optimistic STARTING session`() {
val (state, effects) = reduce(action = Action.SubmitInput, inputText = "hello world")
assertEquals(1, state.sessions.size)
assertEquals("STARTING", state.sessions[0].status)
assertEquals(state.sessions[0].id, state.selectedId)
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
assertEquals(1, wsEffects.size)
assertTrue(wsEffects[0].message is ClientMessage.StartChatSession)
val chatSession = wsEffects[0].message as ClientMessage.StartChatSession
assertEquals("hello world", chatSession.text)
}
@Test
fun `SessionStarted reconciles by removing optimistic STARTING session`() {
val optimisticId = SessionId("opt-1")
val optimistic = SessionSummary(id = "opt-1", status = "STARTING", workflowId = "chat", name = "chat", lastEventAt = 0L)
val s = SessionsState(sessions = listOf(optimistic), selectedId = "opt-1")
val msg = ServerMessage.SessionStarted(
sessionId = SessionId("real-1"), workflowId = "wf", sequence = 1L, sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(1, state.sessions.size)
assertEquals("real-1", state.sessions[0].id)
assertEquals("ACTIVE", state.sessions[0].status)
assertEquals("real-1", state.selectedId)
}
}
@@ -1,125 +0,0 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.SessionStateDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.TuiState
import com.correx.core.events.types.SessionId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SnapshotPhaseReducerTest {
@Test
fun `during snapshotPhase, three live ServerEventReceived actions are buffered and sessions unchanged`() {
var state = TuiState(snapshotPhase = true)
val allDispatched = mutableListOf<Action>()
repeat(3) {
val msg = ServerMessage.SessionStarted(
sessionId = SessionId("s1"),
workflowId = "wf",
sequence = 1L,
sessionSequence = 1L,
)
val result = SnapshotPhaseReducer.process(state, Action.ServerEventReceived(msg))
state = result.state
allDispatched += result.actionsToDispatch
}
assertEquals(3, state.pendingEvents.size)
assertTrue(allDispatched.isEmpty())
assertTrue(state.sessions.sessions.isEmpty())
}
@Test
fun `live event with sessionSequence at or below cursor is dropped`() {
val state = TuiState(snapshotPhase = false, cursors = mapOf("s1" to 5L))
val action = Action.ServerEventReceived(
ServerMessage.SessionStarted(
sessionId = SessionId("s1"),
workflowId = "wf",
sequence = 5L,
sessionSequence = 5L,
)
)
val result = SnapshotPhaseReducer.process(state, action)
assertTrue(result.actionsToDispatch.isEmpty())
assertEquals(5L, result.state.cursors["s1"])
}
@Test
fun `live event with sessionSequence one above cursor is applied and cursor advances`() {
val state = TuiState(snapshotPhase = false, cursors = mapOf("s1" to 5L))
val action = Action.ServerEventReceived(
ServerMessage.SessionStarted(
sessionId = SessionId("s1"),
workflowId = "wf",
sequence = 6L,
sessionSequence = 6L,
)
)
val result = SnapshotPhaseReducer.process(state, action)
assertEquals(listOf(action), result.actionsToDispatch)
assertEquals(6L, result.state.cursors["s1"])
}
@Test
fun `gap event is applied and cursor advances to incoming sequence`() {
val state = TuiState(snapshotPhase = false, cursors = mapOf("s1" to 5L))
val action = Action.ServerEventReceived(
ServerMessage.SessionStarted(
sessionId = SessionId("s1"),
workflowId = "wf",
sequence = 8L,
sessionSequence = 8L,
)
)
val result = SnapshotPhaseReducer.process(state, action)
assertEquals(listOf(action), result.actionsToDispatch)
assertEquals(8L, result.state.cursors["s1"])
}
@Test
fun `Disconnected resets snapshotPhase, pendingEvents, and cursors`() {
val state = TuiState(
snapshotPhase = false,
cursors = mapOf("s1" to 5L),
pendingEvents = listOf(ServerMessage.ProtocolError("x")),
)
val result = SnapshotPhaseReducer.process(state, Action.Disconnected)
assertEquals(true, result.state.snapshotPhase)
assertTrue(result.state.pendingEvents.isEmpty())
assertTrue(result.state.cursors.isEmpty())
assertEquals(listOf(Action.Disconnected), result.actionsToDispatch)
}
@Test
fun `during snapshotPhase SessionSnapshot installs cursor and is forwarded to sub-reducers`() {
val state = TuiState(snapshotPhase = true)
val snapshot = ServerMessage.SessionSnapshot(
sessionId = SessionId("s1"),
workflowId = "wf-1",
state = SessionStateDto(status = "ACTIVE", currentStageId = null, pauseReason = null),
pendingApprovals = emptyList(),
lastSequence = 1L,
lastSessionSequence = 42L,
)
val action = Action.ServerEventReceived(snapshot)
val result = SnapshotPhaseReducer.process(state, action)
assertEquals(42L, result.state.cursors["s1"])
assertEquals(listOf(action), result.actionsToDispatch)
assertTrue(result.state.pendingEvents.isEmpty())
}
}
@@ -1,59 +0,0 @@
package com.correx.apps.tui.ws
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ProtocolSerializer
import com.correx.core.events.types.ApprovalRequestId
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Test
/**
* Per tui-02 invariant 5: all ClientMessage types (including ApprovalResponse) are sent over
* the single global socket. This test asserts that the JSON encoding used by TuiWsClient.send()
* round-trips through the server-side global socket decoder (ProtocolSerializer.decodeClientMessage)
* as an ApprovalResponse.
*/
class ApprovalResponseGlobalSocketRoundTripTest {
// Mirrors the Json config in TuiWsClient
private val clientJson = Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
}
@Test
fun `ApprovalResponse encoded by TUI client decodes on the global socket`() {
val original: ClientMessage = ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId("req-123"),
decision = ApprovalDecision.APPROVE,
steeringNote = null,
)
val wire = clientJson.encodeToString(original)
val decoded = ProtocolSerializer.decodeClientMessage(wire)
assertInstanceOf(ClientMessage.ApprovalResponse::class.java, decoded)
val approval = decoded as ClientMessage.ApprovalResponse
assertEquals("req-123", approval.requestId.value)
assertEquals(ApprovalDecision.APPROVE, approval.decision)
assertEquals(null, approval.steeringNote)
}
@Test
fun `ApprovalResponse with steering note round-trips`() {
val original: ClientMessage = ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId("req-steer"),
decision = ApprovalDecision.APPROVE,
steeringNote = "please retry with smaller batch",
)
val wire = clientJson.encodeToString(original)
val decoded = ProtocolSerializer.decodeClientMessage(wire) as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.APPROVE, decoded.decision)
assertEquals("please retry with smaller batch", decoded.steeringNote)
}
}
@@ -1,48 +0,0 @@
package com.correx.apps.tui.ws
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TuiWsClientChannelTest {
@Test
fun `Channel UNLIMITED does not drop messages under high load`(): Unit = runTest {
// Create a test channel to simulate TuiWsClient._messages behavior
val channel = Channel<ServerMessage>(Channel.UNLIMITED)
// Collect messages from the channel
val collected = mutableListOf<ServerMessage>()
val collectJob = launch {
for (msg in channel) {
collected.add(msg)
}
}
// Send 10k messages rapidly without delay
val sentCount = 10_000
repeat(sentCount) { i ->
val msg = ServerMessage.SessionStarted(
sessionId = SessionId("session-$i"),
workflowId = "wf-$i",
sequence = i.toLong(),
sessionSequence = i.toLong(),
)
channel.send(msg)
}
// Close channel to signal completion
channel.close()
// Wait for collection to complete
collectJob.join()
// Verify all messages were received (no silent drops)
assertEquals(sentCount, collected.size, "All $sentCount messages should be received without drops")
}
}
@@ -1,45 +0,0 @@
package com.correx.apps.tui.ws
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TuiWsClientConnectionChannelTest {
@Test
fun `Channel UNLIMITED does not drop connection events under high load`(): Unit = runTest {
// Create a test channel to simulate TuiWsClient._connection behavior
val channel = Channel<ConnectionEvent>(Channel.UNLIMITED)
// Collect events from the channel
val collected = mutableListOf<ConnectionEvent>()
val collectJob = launch {
for (event in channel) {
collected.add(event)
}
}
// Send 10k events rapidly without delay (mix of all event types)
val sentCount = 10_000
repeat(sentCount) { i ->
val event = when (i % 3) {
0 -> ConnectionEvent.Connected
1 -> ConnectionEvent.Disconnected
else -> ConnectionEvent.RetryScheduled(attempt = i, nextRetryAtMs = System.currentTimeMillis() + 1000)
}
channel.send(event)
}
// Close channel to signal completion
channel.close()
// Wait for collection to complete
collectJob.join()
// Verify all events were received (no silent drops)
assertEquals(sentCount, collected.size, "All $sentCount connection events should be received without drops")
}
}
-1
View File
@@ -9,7 +9,6 @@ rootProject.name = 'correx'
include ':apps:cli' include ':apps:cli'
include ':apps:server' include ':apps:server'
include ':apps:tui'
include ':apps:worker' include ':apps:worker'
include ':apps:desktop' include ':apps:desktop'