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:
@@ -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()
|
||||
}
|
||||
|
||||
+59
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user