fix(tui-02): route ApprovalResponse through global socket

Complete the tui-02 spec acceptance criteria: delete the orphan
`connectSession`, `disconnectSession`, `sessionStreamJob`, and
`sessionWsSession` members from TuiWsClient, and remove the `send()`
fallback that previously routed ApprovalResponse over a per-session
socket. All ClientMessage types now travel the single global socket.

Server side: GlobalStreamHandler.handleClientMessage now actually
handles ApprovalResponse (was returning a protocol error), converting
to a domain ApprovalDecision and dispatching to the orchestrator —
mirrors SessionStreamHandler's prior logic.

Adds ApprovalResponseGlobalSocketRoundTripTest covering APPROVE and
STEER encode/decode through ProtocolSerializer.
This commit is contained in:
2026-05-25 17:18:01 +04:00
parent 8f20f6aa24
commit c8a599c64f
3 changed files with 107 additions and 48 deletions
@@ -2,7 +2,6 @@ package com.correx.apps.tui.ws
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.events.types.SessionId
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.logging.LogLevel
@@ -14,13 +13,10 @@ import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -43,8 +39,6 @@ class TuiWsClient(
}
}
private var sessionStreamJob: Job? = null
private var sessionWsSession: WebSocketSession? = null
private var session: WebSocketSession? = null
private val _messages = Channel<ServerMessage>(Channel.UNLIMITED)
@@ -55,16 +49,10 @@ class TuiWsClient(
suspend fun send(message: ClientMessage) {
runCatching {
// approval responses go to session stream, everything else to global
val target = when (message) {
is ClientMessage.ApprovalResponse -> sessionWsSession ?: session
else -> session
}
target?.send(json.encodeToString(message))
session?.send(json.encodeToString(message))
}
}
suspend fun connect() {
var delayMs = INITIAL_RETRY_DELAY_MS
var attempt = 0
@@ -96,39 +84,6 @@ class TuiWsClient(
}
}
fun connectSession(sessionId: SessionId, scope: CoroutineScope) {
sessionStreamJob?.cancel()
sessionStreamJob = scope.launch {
runCatching {
client.webSocket(
method = HttpMethod.Get,
host = host,
port = port,
path = "/sessions/${sessionId.value}/stream",
) {
sessionWsSession = this
for (frame in incoming) {
if (frame is Frame.Text) {
runCatching {
json.decodeFromString<ServerMessage>(frame.readText())
}.onSuccess { msg ->
_messages.send(msg)
}
}
}
}
}.also {
sessionWsSession = null
}
}
}
fun disconnectSession() {
sessionStreamJob?.cancel()
sessionStreamJob = null
sessionWsSession = null
}
fun close() {
client.close()
}
@@ -0,0 +1,59 @@
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.STEER,
steeringNote = "please retry with smaller batch",
)
val wire = clientJson.encodeToString(original)
val decoded = ProtocolSerializer.decodeClientMessage(wire) as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.STEER, decoded.decision)
assertEquals("please retry with smaller batch", decoded.steeringNote)
}
}