fix: harden event store, serialization, and grant engine; wire router chat
Event log integrity (sole source of truth): - SqliteEventStore: serialize append/appendAll under a Mutex, enable WAL + busy_timeout + synchronous=NORMAL, roll back on any Throwable. Replaces the app-computed `SELECT MAX(seq)+1` read-modify-write that dropped events under concurrent appends (race was invisible to CI: only the in-memory store and single-threaded :memory: tests were ever exercised). - Serialization: encodeDefaults + ignoreUnknownKeys, explicit @SerialName on all 46 EventPayload subclasses and event-referenced enum constants, @Serializable on RiskLevel/RiskAction. Guards the append-only log against silent corruption from future class renames, field removals, or default-value changes. Approval/grant security (Invariant: approvals cannot widen authority): - Tier authorization is now a ceiling (request.tier.level <= max granted) instead of set membership; SESSION grants bind to a specific toolName instead of matching everything; handleCreateGrant rejects empty/over-tier/scopeless grants. Router chat wiring (Phase 0-2): ChatInput round-trip, StartChatSession from IDLE, router-panel layout, workflows behind Ctrl+W. Tests: SqliteEventStore concurrency, serialization round-trip + discriminator stability + unknown-key tolerance, adversarial grant scope/tier; updated existing approval and reducer tests for the new grant semantics and StartChatSession effect.
This commit is contained in:
@@ -47,11 +47,19 @@ sealed class ClientMessage {
|
||||
val permittedTiers: List<Tier>,
|
||||
val reason: String,
|
||||
val expiresAt: Instant? = null,
|
||||
// Required for SESSION scope: binds this grant to a specific tool name.
|
||||
val toolName: String? = null,
|
||||
) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class Ping(val timestamp: Long) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class StartChatSession(
|
||||
val sessionId: SessionId,
|
||||
val text: String,
|
||||
) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class ChatInput(val sessionId: SessionId, val text: String, val mode: ChatMode) : ClientMessage()
|
||||
}
|
||||
|
||||
@@ -12,14 +12,17 @@ import com.correx.apps.server.protocol.WorkflowDto
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.utils.TypeId
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
@@ -140,11 +143,29 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
when (msg) {
|
||||
is ClientMessage.Ping -> Unit
|
||||
is ClientMessage.StartSession -> handleStartSession(session, msg, sendFrame)
|
||||
is ClientMessage.StartChatSession -> handleStartChatSession(session, msg, sendFrame)
|
||||
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
||||
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
||||
is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task
|
||||
is ClientMessage.ChatInput -> {
|
||||
runCatching {
|
||||
module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode)
|
||||
}.onSuccess { response ->
|
||||
sendFrame(ServerMessage.RouterResponseMessage(
|
||||
sessionId = msg.sessionId,
|
||||
content = response.content,
|
||||
steeringEmitted = response.steeringEmitted,
|
||||
))
|
||||
}.onFailure {
|
||||
log.error("routerFacade.onUserInput failed: {}", it.message)
|
||||
sendFrame(ServerMessage.ProtocolError(
|
||||
message = "Router error: ${it.message}",
|
||||
sequence = null,
|
||||
sessionSequence = null,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +209,31 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
msg: ClientMessage.CreateGrant,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
|
||||
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
|
||||
if (msg.permittedTiers.isEmpty()) {
|
||||
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
|
||||
return
|
||||
}
|
||||
val maxGrantableTier = Tier.T2
|
||||
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
|
||||
if (overBroad.isNotEmpty()) {
|
||||
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
|
||||
return
|
||||
}
|
||||
|
||||
val scope = when (msg.scope) {
|
||||
GrantScopeDto.SESSION -> {
|
||||
if (msg.stageId != null) {
|
||||
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
|
||||
return
|
||||
}
|
||||
GrantScope.SESSION
|
||||
// Require toolName — blanket session grants (no operation scope) are rejected.
|
||||
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
|
||||
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
|
||||
return
|
||||
}
|
||||
GrantScope.SESSION(toolName)
|
||||
}
|
||||
GrantScopeDto.STAGE -> {
|
||||
val sid = msg.stageId ?: run {
|
||||
@@ -222,11 +261,66 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
sessionId = msg.sessionId,
|
||||
stageId = (scope as? GrantScope.STAGE)?.stageId,
|
||||
projectId = null,
|
||||
toolName = (scope as? GrantScope.SESSION)?.toolName,
|
||||
),
|
||||
)
|
||||
module.eventStore.append(event)
|
||||
}
|
||||
|
||||
private suspend fun handleStartChatSession(
|
||||
session: DefaultWebSocketServerSession,
|
||||
msg: ClientMessage.StartChatSession,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
val sessionId = msg.sessionId
|
||||
log.info("starting chat session={}", sessionId.value)
|
||||
|
||||
// Emit WorkflowStartedEvent for the new session (no graph, no orchestrator).
|
||||
val event = NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = WorkflowStartedEvent(
|
||||
sessionId = sessionId,
|
||||
workflowId = "chat",
|
||||
startStageId = StageId("none"),
|
||||
),
|
||||
)
|
||||
module.eventStore.append(event)
|
||||
|
||||
// Send SessionStarted so the TUI creates the session entry immediately.
|
||||
val started = ServerMessage.SessionStarted(
|
||||
sessionId = sessionId,
|
||||
workflowId = "chat",
|
||||
sequence = 0L,
|
||||
sessionSequence = 0L,
|
||||
)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
|
||||
|
||||
// Call router and send response.
|
||||
runCatching {
|
||||
module.routerFacade.onUserInput(sessionId, msg.text)
|
||||
}.onSuccess { response ->
|
||||
sendFrame(ServerMessage.RouterResponseMessage(
|
||||
sessionId = sessionId,
|
||||
content = response.content,
|
||||
steeringEmitted = response.steeringEmitted,
|
||||
))
|
||||
}.onFailure {
|
||||
log.error("routerFacade.onUserInput failed: {}", it.message)
|
||||
sendFrame(ServerMessage.ProtocolError(
|
||||
message = "Router error: ${it.message}",
|
||||
sequence = null,
|
||||
sessionSequence = null,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleStartSession(
|
||||
session: DefaultWebSocketServerSession,
|
||||
msg: ClientMessage.StartSession,
|
||||
|
||||
@@ -23,6 +23,7 @@ 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"
|
||||
|
||||
@@ -19,4 +19,5 @@ sealed class KeyEvent {
|
||||
data class CharInput(val ch: Char) : KeyEvent()
|
||||
object CursorLeft : KeyEvent()
|
||||
object CursorRight : KeyEvent()
|
||||
object ToggleWorkflows : KeyEvent()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.correx.apps.tui
|
||||
|
||||
import com.correx.apps.tui.components.approvalSurfaceWidget
|
||||
import com.correx.apps.tui.components.diffViewerWidget
|
||||
import com.correx.apps.tui.components.eventHistoryStripWidget
|
||||
import com.correx.apps.tui.components.filteredSessions
|
||||
import com.correx.apps.tui.components.inputBarWidget
|
||||
import com.correx.apps.tui.components.routerPanelWidget
|
||||
import com.correx.apps.tui.components.sessionListWidget
|
||||
@@ -16,7 +16,6 @@ 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.ToolDisplayStatus
|
||||
import com.correx.apps.tui.state.displayState
|
||||
import com.correx.apps.tui.state.selectedPendingApproval
|
||||
import com.correx.apps.tui.ws.ConnectionEvent
|
||||
@@ -38,9 +37,10 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
private const val DEFAULT_PORT = 8080
|
||||
private const val STATUS_HEIGHT = 1
|
||||
private const val EVENT_STRIP_HEIGHT = 6
|
||||
private const val DIFF_HEIGHT = 8
|
||||
private const val WORKFLOW_HEIGHT = 6
|
||||
private const val INPUT_HEIGHT = 4
|
||||
private const val MAX_VISIBLE_SESSIONS = 7
|
||||
private const val SESSION_BORDER_PADDING = 2
|
||||
|
||||
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
|
||||
|
||||
@@ -130,51 +130,16 @@ private fun render(frame: Frame, state: TuiState) {
|
||||
|
||||
private fun renderIdleLayout(frame: Frame, state: TuiState) {
|
||||
val area = frame.area()
|
||||
val sessionCount = filteredSessions(state.sessions).size
|
||||
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
|
||||
val hasWorkflows = state.sessions.workflows.isNotEmpty()
|
||||
val vertRects = if (hasWorkflows) {
|
||||
Layout.vertical()
|
||||
.constraints(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
Constraint.fill(),
|
||||
Constraint.length(WORKFLOW_HEIGHT),
|
||||
Constraint.length(INPUT_HEIGHT),
|
||||
)
|
||||
.split(area)
|
||||
} else {
|
||||
Layout.vertical()
|
||||
.constraints(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
Constraint.fill(),
|
||||
Constraint.length(INPUT_HEIGHT),
|
||||
)
|
||||
.split(area)
|
||||
}
|
||||
|
||||
frame.renderWidget(statusBarWidget(state), vertRects[0])
|
||||
frame.renderWidget(sessionListWidget(state), vertRects[1])
|
||||
if (hasWorkflows) {
|
||||
frame.renderWidget(workflowListWidget(state), vertRects[2])
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[3])
|
||||
} else {
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[2])
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
||||
val area = frame.area()
|
||||
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
||||
val hasDiff = selectedSession?.tools
|
||||
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
|
||||
?.diff != null
|
||||
|
||||
val showWorkflows = hasWorkflows && (state.sessions.workflowsVisible || state.sessions.sessions.isEmpty())
|
||||
val constraints = mutableListOf(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
Constraint.length(sessionListHeight),
|
||||
)
|
||||
if (state.eventStripVisible) {
|
||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
||||
}
|
||||
if (hasDiff) {
|
||||
constraints.add(Constraint.length(DIFF_HEIGHT))
|
||||
if (showWorkflows) {
|
||||
constraints.add(Constraint.length(WORKFLOW_HEIGHT))
|
||||
}
|
||||
constraints.add(Constraint.fill())
|
||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||
@@ -185,32 +150,51 @@ private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
||||
|
||||
var rectIdx = 0
|
||||
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(sessionListWidget(state), vertRects[rectIdx++])
|
||||
if (showWorkflows) {
|
||||
frame.renderWidget(workflowListWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[rectIdx])
|
||||
}
|
||||
|
||||
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
||||
val area = frame.area()
|
||||
val sessionCount = filteredSessions(state.sessions).size
|
||||
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
|
||||
|
||||
val constraints = mutableListOf(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
Constraint.length(sessionListHeight),
|
||||
)
|
||||
if (state.eventStripVisible) {
|
||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
||||
}
|
||||
constraints.add(Constraint.fill())
|
||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||
|
||||
val vertRects = Layout.vertical()
|
||||
.constraints(constraints)
|
||||
.split(area)
|
||||
|
||||
var rectIdx = 0
|
||||
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(sessionListWidget(state), vertRects[rectIdx++])
|
||||
if (state.eventStripVisible) {
|
||||
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
if (hasDiff) {
|
||||
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx])
|
||||
}
|
||||
|
||||
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
||||
val area = frame.area()
|
||||
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
||||
val hasDiff = selectedSession?.tools
|
||||
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
|
||||
?.diff != null
|
||||
|
||||
val constraints = mutableListOf(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
)
|
||||
if (state.eventStripVisible) {
|
||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
||||
}
|
||||
if (hasDiff) {
|
||||
constraints.add(Constraint.length(DIFF_HEIGHT))
|
||||
}
|
||||
constraints.add(Constraint.fill())
|
||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||
|
||||
@@ -223,9 +207,6 @@ private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
||||
if (state.eventStripVisible) {
|
||||
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
if (hasDiff) {
|
||||
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), vertRects[rectIdx])
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import com.correx.apps.tui.state.ToolDisplayStatus
|
||||
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.block.Title
|
||||
import dev.tamboui.widgets.paragraph.Paragraph
|
||||
|
||||
private const val DIFF_MAX_LINES = 8
|
||||
private const val LINE_MAX_LENGTH = 100
|
||||
|
||||
private val dimStyle = Style.create().dim().gray()
|
||||
private val greenStyle = Style.create().green()
|
||||
private val redStyle = Style.create().red()
|
||||
private val yellowStyle = Style.create().yellow()
|
||||
|
||||
fun diffViewerWidget(state: TuiState): Paragraph {
|
||||
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
||||
|
||||
// Only show the diff for the most recently completed tool. If the last tool has no diff
|
||||
// (e.g. a shell command), the viewer is hidden — stale diffs from earlier tools are noise.
|
||||
val toolWithDiff = selectedSession?.tools
|
||||
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
|
||||
?.takeIf { it.diff != null }
|
||||
|
||||
val diffText = toolWithDiff?.diff ?: return Paragraph.builder().build()
|
||||
|
||||
val header = " ${toolWithDiff.name}"
|
||||
val headerLine = Line.from(Span.styled(header, Style.create().cyan()))
|
||||
|
||||
val diffLines = diffText.lines()
|
||||
val visible = diffLines.take(DIFF_MAX_LINES)
|
||||
val overflow = diffLines.size - DIFF_MAX_LINES
|
||||
|
||||
val contentLines = buildList<Line> {
|
||||
for (line in visible) {
|
||||
val truncated = if (line.length > LINE_MAX_LENGTH) {
|
||||
line.take(LINE_MAX_LENGTH - 3) + "..."
|
||||
} else {
|
||||
line
|
||||
}
|
||||
when {
|
||||
truncated.startsWith("@@") -> add(
|
||||
Line.from(Span.styled(truncated, yellowStyle)),
|
||||
)
|
||||
|
||||
truncated.startsWith("+++") || truncated.startsWith("---") -> add(
|
||||
Line.from(Span.styled(truncated, dimStyle)),
|
||||
)
|
||||
|
||||
truncated.startsWith("+") -> add(
|
||||
Line.from(Span.styled(truncated, greenStyle)),
|
||||
)
|
||||
|
||||
truncated.startsWith("-") -> add(
|
||||
Line.from(Span.styled(truncated, redStyle)),
|
||||
)
|
||||
|
||||
else -> add(Line.from(Span.raw(truncated)))
|
||||
}
|
||||
}
|
||||
if (overflow > 0) {
|
||||
add(Line.from(Span.styled(" ($overflow more lines...)", dimStyle)))
|
||||
}
|
||||
}
|
||||
|
||||
val lines = listOf(headerLine) + contentLines
|
||||
|
||||
val block = Block.builder()
|
||||
.title(Title.from(Span.styled("diff", Style.create().green())).centered())
|
||||
.borders(Borders.ALL)
|
||||
.borderType(BorderType.ROUNDED)
|
||||
.build()
|
||||
|
||||
return Paragraph.builder().text(Text.from(lines)).block(block).build()
|
||||
}
|
||||
@@ -26,5 +26,6 @@ sealed interface Action {
|
||||
data object CycleMode : Action
|
||||
data object CursorLeft : Action
|
||||
data object CursorRight : Action
|
||||
data object ToggleWorkflows : Action
|
||||
data object NoOp : Action
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ object KeyResolver {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
|
||||
event.isChar('l') -> KeyEvent.ReturnToSessionList
|
||||
event.isChar('e') -> KeyEvent.ToggleEventStrip
|
||||
event.isChar('h') -> KeyEvent.ShowPendingApproval
|
||||
event.isChar('w') -> KeyEvent.ToggleWorkflows
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,17 @@ object RootReducer {
|
||||
) ProviderType.LOCAL else ProviderType.REMOTE,
|
||||
)
|
||||
}
|
||||
msg is ServerMessage.RouterResponseMessage -> {
|
||||
withBgReset.copy(
|
||||
routerMessages = withBgReset.routerMessages + msg.content,
|
||||
)
|
||||
}
|
||||
msg is ServerMessage.ToolCompleted && msg.diff != null -> {
|
||||
withBgReset.copy(
|
||||
routerMessages = withBgReset.routerMessages +
|
||||
"--- diff from ${msg.toolName} ---",
|
||||
)
|
||||
}
|
||||
else -> withBgReset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ 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.ApprovalRequestId
|
||||
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
|
||||
@@ -59,19 +59,30 @@ object SessionsReducer {
|
||||
Effect.SendWs(ClientMessage.StartSession(workflowId = wf.workflowId, config = null)),
|
||||
)
|
||||
}
|
||||
|
||||
text.isNotBlank() -> {
|
||||
val sessionId = SessionId(java.util.UUID.randomUUID().toString())
|
||||
sessions to listOf(
|
||||
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)),
|
||||
Effect.SendWs(ClientMessage.StartChatSession(sessionId = sessionId, text = text)),
|
||||
)
|
||||
}
|
||||
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
displayState == DisplayState.IN_SESSION -> {
|
||||
// Chat send is blocked until Epic 14 (router integration).
|
||||
// History append is handled by RootReducer cross-field weave.
|
||||
sessions to emptyList()
|
||||
sessions to listOf(
|
||||
Effect.SendWs(
|
||||
ClientMessage.ChatInput(
|
||||
sessionId = SessionId(sessions.selectedId!!),
|
||||
text = inputText,
|
||||
mode = ChatMode.CHAT,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
|
||||
@@ -90,6 +101,10 @@ object SessionsReducer {
|
||||
}
|
||||
}
|
||||
|
||||
is Action.ToggleWorkflows -> sessions.copy(
|
||||
workflowsVisible = !sessions.workflowsVisible,
|
||||
) to emptyList()
|
||||
|
||||
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
|
||||
@@ -10,4 +10,6 @@ data class SessionsState(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -33,15 +33,16 @@ class RootReducerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in ROUTER mode emits StartSession effect and resets buffer`() {
|
||||
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.StartSession
|
||||
assertEquals("my-workflow", msg.workflowId)
|
||||
val msg = wsEffects[0].message as ClientMessage.StartChatSession
|
||||
assertEquals("my-workflow", msg.text)
|
||||
assertTrue(msg.sessionId.value.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user