feat: execute gateway session SSE
This commit is contained in:
+16
-3
@@ -15,9 +15,8 @@ object HermesStreamReducer {
|
||||
)
|
||||
|
||||
is HermesStreamEvent.AssistantDelta -> state.copy(
|
||||
messages = state.messages.map { message ->
|
||||
if (message.id == event.messageId) message.appendText(event.text).copy(status = MessageStatus.Streaming) else message
|
||||
},
|
||||
messages = state.messages.appendAssistantDelta(event),
|
||||
session = state.session.copy(status = SessionStatus.Running, subtitle = "Hermes is streaming..."),
|
||||
error = null
|
||||
)
|
||||
|
||||
@@ -51,6 +50,20 @@ object HermesStreamReducer {
|
||||
private fun List<HermesMessage>.upsert(message: HermesMessage): List<HermesMessage> =
|
||||
if (any { it.id == message.id }) map { if (it.id == message.id) message else it } else this + message
|
||||
|
||||
private fun List<HermesMessage>.appendAssistantDelta(event: HermesStreamEvent.AssistantDelta): List<HermesMessage> {
|
||||
val existing = firstOrNull { it.id == event.messageId }
|
||||
val streamingMessage = existing?.appendText(event.text)?.copy(status = MessageStatus.Streaming)
|
||||
?: HermesMessage(
|
||||
id = event.messageId,
|
||||
sessionId = event.sessionId,
|
||||
role = MessageRole.Assistant,
|
||||
parts = listOf(MessagePart.Text(event.text)),
|
||||
status = MessageStatus.Streaming,
|
||||
createdAt = event.timestamp
|
||||
)
|
||||
return upsert(streamingMessage)
|
||||
}
|
||||
|
||||
private fun HermesMessage.appendText(delta: String): HermesMessage {
|
||||
val lastTextIndex = parts.indexOfLast { it is MessagePart.Text }
|
||||
if (lastTextIndex < 0) return copy(parts = parts + MessagePart.Text(delta))
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package cloud.molberg.hermesmobile.streaming
|
||||
|
||||
import cloud.molberg.hermesmobile.domain.HermesConversationState
|
||||
import cloud.molberg.hermesmobile.domain.HermesStreamEvent
|
||||
import cloud.molberg.hermesmobile.domain.HermesStreamReducer
|
||||
|
||||
object BoundGatewayStreamReducer {
|
||||
fun reduce(
|
||||
lease: GatewayStreamLease,
|
||||
state: HermesConversationState,
|
||||
event: HermesStreamEvent
|
||||
): HermesConversationState {
|
||||
if (state.session.id != lease.sessionId || event.sessionId != lease.sessionId) return state
|
||||
return HermesStreamReducer.reduce(state, event)
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package cloud.molberg.hermesmobile.streaming
|
||||
|
||||
import cloud.molberg.hermesmobile.domain.HermesMessage
|
||||
import cloud.molberg.hermesmobile.domain.HermesStreamEvent
|
||||
import cloud.molberg.hermesmobile.domain.MessagePart
|
||||
import cloud.molberg.hermesmobile.domain.MessageRole
|
||||
import cloud.molberg.hermesmobile.domain.MessageStatus
|
||||
import org.json.JSONObject
|
||||
|
||||
sealed interface GatewayDecodedStreamEvent {
|
||||
data class Domain(val event: HermesStreamEvent) : GatewayDecodedStreamEvent
|
||||
data class Structured(val name: String, val payload: JSONObject) : GatewayDecodedStreamEvent
|
||||
}
|
||||
|
||||
object GatewaySessionSseDecoder {
|
||||
fun decode(lease: GatewayStreamLease, eventName: String, data: String): GatewayDecodedStreamEvent? {
|
||||
if (!lease.accepts(eventName)) return null
|
||||
val payload = JSONObject(data)
|
||||
val sessionId = payload.optString("session_id").ifBlank { lease.sessionId }
|
||||
val timestamp = payload.optDouble("ts").takeUnless { it.isNaN() }?.toString()
|
||||
val eventId = listOf(payload.optString("run_id"), payload.optString("seq"))
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(":")
|
||||
.ifBlank { eventName }
|
||||
return when (eventName) {
|
||||
"message.started" -> payload.optJSONObject("message")?.let { message ->
|
||||
val messageId = message.optString("id")
|
||||
if (messageId.isBlank()) null else GatewayDecodedStreamEvent.Domain(
|
||||
HermesStreamEvent.MessageStarted(
|
||||
eventId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
HermesMessage(
|
||||
id = messageId,
|
||||
sessionId = sessionId,
|
||||
role = MessageRole.Assistant,
|
||||
parts = emptyList(),
|
||||
status = MessageStatus.Streaming,
|
||||
createdAt = timestamp
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
"assistant.delta" -> payload.requiredString("message_id")?.let { messageId ->
|
||||
GatewayDecodedStreamEvent.Domain(
|
||||
HermesStreamEvent.AssistantDelta(
|
||||
eventId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
messageId,
|
||||
payload.optString("delta")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
"assistant.completed" -> payload.requiredString("message_id")?.let { messageId ->
|
||||
GatewayDecodedStreamEvent.Domain(
|
||||
HermesStreamEvent.MessageCompleted(
|
||||
eventId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
HermesMessage(
|
||||
id = messageId,
|
||||
sessionId = sessionId,
|
||||
role = MessageRole.Assistant,
|
||||
parts = listOf(MessagePart.Text(payload.optString("content"))),
|
||||
status = MessageStatus.Complete,
|
||||
createdAt = timestamp
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
"error" -> GatewayDecodedStreamEvent.Domain(
|
||||
HermesStreamEvent.Failed(
|
||||
eventId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
payload.optString("message").ifBlank { "Gateway stream failed." },
|
||||
recoverable = true
|
||||
)
|
||||
)
|
||||
|
||||
"done" -> GatewayDecodedStreamEvent.Domain(HermesStreamEvent.Done(eventId, sessionId, timestamp))
|
||||
in STRUCTURED_EVENTS -> GatewayDecodedStreamEvent.Structured(eventName, payload)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.requiredString(name: String): String? = optString(name).takeIf { it.isNotBlank() }
|
||||
|
||||
private val STRUCTURED_EVENTS = setOf("tool.progress", "tool.started", "tool.completed", "tool.failed")
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package cloud.molberg.hermesmobile.streaming
|
||||
|
||||
import cloud.molberg.hermesmobile.connection.GatewayRouteLease
|
||||
import cloud.molberg.hermesmobile.domain.HermesConversationState
|
||||
import cloud.molberg.hermesmobile.domain.HermesStreamEvent
|
||||
import java.io.IOException
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.BufferedSource
|
||||
import org.json.JSONException
|
||||
|
||||
class GatewaySessionStreamRepository(
|
||||
private val client: OkHttpClient,
|
||||
private val reportTransportFailure: (GatewayRouteLease) -> Unit
|
||||
) {
|
||||
fun execute(
|
||||
streamRequest: GatewayStreamRequest,
|
||||
initialState: HermesConversationState
|
||||
): HermesConversationState {
|
||||
val lease = streamRequest.lease
|
||||
require(initialState.session.id == lease.sessionId) {
|
||||
"Gateway stream state must match the leased session."
|
||||
}
|
||||
var state = initialState
|
||||
return try {
|
||||
client.newCall(streamRequest.request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Gateway stream returned HTTP ${response.code}.")
|
||||
}
|
||||
val body = response.body ?: throw IOException("Gateway stream response body is missing.")
|
||||
readEvents(body.source()) { eventName, data ->
|
||||
when (val decoded = GatewaySessionSseDecoder.decode(lease, eventName, data)) {
|
||||
is GatewayDecodedStreamEvent.Domain -> {
|
||||
state = BoundGatewayStreamReducer.reduce(lease, state, decoded.event)
|
||||
}
|
||||
|
||||
is GatewayDecodedStreamEvent.Structured, null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
state
|
||||
} catch (failure: IOException) {
|
||||
transportFailure(lease, state)
|
||||
} catch (failure: JSONException) {
|
||||
transportFailure(lease, state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun transportFailure(
|
||||
lease: GatewayStreamLease,
|
||||
state: HermesConversationState
|
||||
): HermesConversationState {
|
||||
reportTransportFailure(lease.routeLease)
|
||||
return BoundGatewayStreamReducer.reduce(
|
||||
lease,
|
||||
state,
|
||||
HermesStreamEvent.Failed(
|
||||
id = "transport:${lease.routeLease.generation}",
|
||||
sessionId = lease.sessionId,
|
||||
timestamp = null,
|
||||
message = "Gateway stream transport failed.",
|
||||
recoverable = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun readEvents(source: BufferedSource, consume: (String, String) -> Unit) {
|
||||
var eventName: String? = null
|
||||
val dataLines = mutableListOf<String>()
|
||||
|
||||
fun dispatch() {
|
||||
val name = eventName
|
||||
if (name != null && dataLines.isNotEmpty()) consume(name, dataLines.joinToString("\n"))
|
||||
eventName = null
|
||||
dataLines.clear()
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val line = source.readUtf8Line() ?: break
|
||||
if (line.isEmpty()) {
|
||||
dispatch()
|
||||
continue
|
||||
}
|
||||
if (line.startsWith(':')) continue
|
||||
val separator = line.indexOf(':')
|
||||
val field = if (separator < 0) line else line.substring(0, separator)
|
||||
val rawValue = if (separator < 0) "" else line.substring(separator + 1)
|
||||
val value = rawValue.removePrefix(" ")
|
||||
when (field) {
|
||||
"event" -> eventName = value
|
||||
"data" -> dataLines += value
|
||||
}
|
||||
}
|
||||
dispatch()
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package cloud.molberg.hermesmobile.streaming
|
||||
|
||||
import cloud.molberg.hermesmobile.connection.GatewayRouteLease
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
data class GatewayCapabilityEndpoint(
|
||||
val method: String,
|
||||
val path: String
|
||||
)
|
||||
|
||||
data class GatewayStreamingCapabilities(
|
||||
val sessionChatStreaming: Boolean = false,
|
||||
val toolProgressEvents: Boolean = false,
|
||||
val sessionChatStream: GatewayCapabilityEndpoint? = null
|
||||
) {
|
||||
companion object {
|
||||
fun fromCapabilities(json: JSONObject): GatewayStreamingCapabilities {
|
||||
val features = json.optJSONObject("features")
|
||||
val endpoint = json.optJSONObject("endpoints")
|
||||
?.optJSONObject("session_chat_stream")
|
||||
?.let { GatewayCapabilityEndpoint(it.optString("method"), it.optString("path")) }
|
||||
?.takeIf { it.method.isNotBlank() && it.path.isNotBlank() }
|
||||
return GatewayStreamingCapabilities(
|
||||
sessionChatStreaming = features?.optBoolean("session_chat_streaming") == true,
|
||||
toolProgressEvents = features?.optBoolean("tool_progress_events") == true,
|
||||
sessionChatStream = endpoint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class GatewayStreamLease(
|
||||
val routeLease: GatewayRouteLease,
|
||||
val sessionId: String,
|
||||
val endpointPath: String,
|
||||
val structuredEventsEnabled: Boolean
|
||||
) {
|
||||
fun accepts(eventName: String): Boolean =
|
||||
eventName in CORE_EVENTS || structuredEventsEnabled && eventName in STRUCTURED_EVENTS
|
||||
|
||||
private companion object {
|
||||
val CORE_EVENTS = setOf(
|
||||
"run.started",
|
||||
"message.started",
|
||||
"assistant.delta",
|
||||
"assistant.completed",
|
||||
"run.completed",
|
||||
"error",
|
||||
"done"
|
||||
)
|
||||
val STRUCTURED_EVENTS = setOf("tool.progress", "tool.started", "tool.completed", "tool.failed")
|
||||
}
|
||||
}
|
||||
|
||||
data class GatewayStreamRequest(
|
||||
val lease: GatewayStreamLease,
|
||||
val request: Request
|
||||
)
|
||||
|
||||
class GatewayStreamingContract(private val capabilities: GatewayStreamingCapabilities) {
|
||||
fun sessionChatRequest(
|
||||
routeLease: GatewayRouteLease,
|
||||
bearerToken: String,
|
||||
sessionId: String,
|
||||
input: String
|
||||
): GatewayStreamRequest {
|
||||
require(capabilities.sessionChatStreaming) { "Gateway does not advertise session_chat_streaming." }
|
||||
val endpoint = requireNotNull(capabilities.sessionChatStream) {
|
||||
"Gateway does not advertise the session_chat_stream endpoint."
|
||||
}
|
||||
require(endpoint.method == "POST") { "Gateway session_chat_stream method must be POST." }
|
||||
require(endpoint.path.startsWith('/')) { "Gateway session_chat_stream path must be absolute." }
|
||||
require(endpoint.path.contains("{session_id}")) {
|
||||
"Gateway session_chat_stream path must document a {session_id} placeholder."
|
||||
}
|
||||
val endpointPath = endpoint.path.replace("{session_id}", encode(sessionId))
|
||||
val lease = GatewayStreamLease(
|
||||
routeLease = routeLease,
|
||||
sessionId = sessionId,
|
||||
endpointPath = endpointPath,
|
||||
structuredEventsEnabled = capabilities.toolProgressEvents
|
||||
)
|
||||
val body = JSONObject().put("input", input).toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
val builder = Request.Builder()
|
||||
.url(routeLease.baseUrl.trimEnd('/') + endpointPath)
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(body)
|
||||
if (bearerToken.isNotBlank()) builder.header("Authorization", "Bearer $bearerToken")
|
||||
return GatewayStreamRequest(lease, builder.build())
|
||||
}
|
||||
|
||||
private fun encode(value: String): String =
|
||||
URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
|
||||
|
||||
private companion object {
|
||||
val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
}
|
||||
}
|
||||
+36
@@ -32,6 +32,42 @@ class HermesStreamReducerTest {
|
||||
assertNull(result.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantDeltaCreatesStreamingMessageWhenStartEventWasMissed() {
|
||||
val result = HermesStreamReducer.reduce(
|
||||
baseState(),
|
||||
HermesStreamEvent.AssistantDelta("e1", "s1", null, "a1", "Recovered delta")
|
||||
)
|
||||
|
||||
assertEquals("Recovered delta", (result.messages.single().parts.single() as MessagePart.Text).text)
|
||||
assertEquals(MessageStatus.Streaming, result.messages.single().status)
|
||||
assertEquals(SessionStatus.Running, result.session.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finalResponseReplacesAccumulatedDeltaText() {
|
||||
val streamed = HermesStreamReducer.reduce(
|
||||
baseState(),
|
||||
HermesStreamEvent.AssistantDelta("e1", "s1", null, "a1", "Partial answer")
|
||||
)
|
||||
val finalMessage = HermesMessage(
|
||||
"a1",
|
||||
"s1",
|
||||
MessageRole.Assistant,
|
||||
listOf(MessagePart.Text("Authoritative final answer")),
|
||||
MessageStatus.Complete,
|
||||
null
|
||||
)
|
||||
|
||||
val result = HermesStreamReducer.reduce(
|
||||
streamed,
|
||||
HermesStreamEvent.MessageCompleted("e2", "s1", null, finalMessage)
|
||||
)
|
||||
|
||||
assertEquals("Authoritative final answer", (result.messages.single().parts.single() as MessagePart.Text).text)
|
||||
assertEquals(SessionStatus.Completed, result.session.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolAndArtifactEventsAreStoredById() {
|
||||
val tool = ToolResult("t1", "call-1", "workspace.inspect", ToolStatus.Running, "Running", null, "", null, null, null)
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package cloud.molberg.hermesmobile.streaming
|
||||
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionRoute
|
||||
import cloud.molberg.hermesmobile.connection.GatewayRouteLease
|
||||
import cloud.molberg.hermesmobile.domain.HermesConversationState
|
||||
import cloud.molberg.hermesmobile.domain.HermesSession
|
||||
import cloud.molberg.hermesmobile.domain.MessagePart
|
||||
import cloud.molberg.hermesmobile.domain.SessionStatus
|
||||
import java.io.IOException
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class GatewaySessionStreamRepositoryTest {
|
||||
@Test
|
||||
fun fakeHttpSseCallReducesFramesThroughTheBoundLease() {
|
||||
val streamRequest = request(routeLease = LOCAL_LEASE)
|
||||
var requestedUrl: String? = null
|
||||
val repository = GatewaySessionStreamRepository(
|
||||
client = client { chain ->
|
||||
requestedUrl = chain.request().url.toString()
|
||||
response(
|
||||
chain,
|
||||
"""
|
||||
: keepalive
|
||||
|
||||
event: message.started
|
||||
data: {"session_id":"session-1","run_id":"run-1","seq":1,"message":{"id":"message-1"}}
|
||||
|
||||
event: assistant.delta
|
||||
data: {"session_id":"session-1","run_id":"run-1","seq":2,"message_id":"message-1","delta":"Draft"}
|
||||
|
||||
event: assistant.completed
|
||||
data: {"session_id":"session-1","run_id":"run-1","seq":3,"message_id":"message-1","content":"Authoritative final"}
|
||||
|
||||
event: done
|
||||
data: {"session_id":"session-1","run_id":"run-1","seq":4}
|
||||
|
||||
""".trimIndent()
|
||||
)
|
||||
},
|
||||
reportTransportFailure = { error("Successful stream must not report route failure.") }
|
||||
)
|
||||
|
||||
val result = repository.execute(streamRequest, state("session-1"))
|
||||
|
||||
assertEquals("https://local.example.test/api/sessions/session-1/chat/stream", requestedUrl)
|
||||
assertEquals("Authoritative final", (result.messages.single().parts.single() as MessagePart.Text).text)
|
||||
assertEquals(SessionStatus.Completed, result.session.status)
|
||||
assertNull(result.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transportFailureIsReportedAndReducedOnlyAgainstTheOriginalLease() {
|
||||
val streamRequest = request(routeLease = LOCAL_LEASE)
|
||||
val reportedLeases = mutableListOf<GatewayRouteLease>()
|
||||
val repository = GatewaySessionStreamRepository(
|
||||
client = client { throw IOException("synthetic disconnect") },
|
||||
reportTransportFailure = reportedLeases::add
|
||||
)
|
||||
|
||||
val result = repository.execute(streamRequest, state("session-1"))
|
||||
|
||||
assertEquals(listOf(LOCAL_LEASE), reportedLeases)
|
||||
assertEquals("session-1", result.session.id)
|
||||
assertEquals(SessionStatus.Failed, result.session.status)
|
||||
assertEquals("Gateway stream transport failed.", result.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayErrorFrameDoesNotReportTransportFailure() {
|
||||
val reportedLeases = mutableListOf<GatewayRouteLease>()
|
||||
val repository = GatewaySessionStreamRepository(
|
||||
client = client { chain ->
|
||||
response(
|
||||
chain,
|
||||
"""
|
||||
event: error
|
||||
data: {"session_id":"session-1","message":"Hermes rejected the turn."}
|
||||
|
||||
""".trimIndent()
|
||||
)
|
||||
},
|
||||
reportTransportFailure = reportedLeases::add
|
||||
)
|
||||
|
||||
val result = repository.execute(request(LOCAL_LEASE), state("session-1"))
|
||||
|
||||
assertEquals(emptyList<GatewayRouteLease>(), reportedLeases)
|
||||
assertEquals(SessionStatus.Failed, result.session.status)
|
||||
assertEquals("Hermes rejected the turn.", result.error)
|
||||
}
|
||||
|
||||
private fun request(routeLease: GatewayRouteLease): GatewayStreamRequest =
|
||||
GatewayStreamingContract(
|
||||
GatewayStreamingCapabilities(
|
||||
sessionChatStreaming = true,
|
||||
sessionChatStream = GatewayCapabilityEndpoint(
|
||||
method = "POST",
|
||||
path = "/api/sessions/{session_id}/chat/stream"
|
||||
)
|
||||
)
|
||||
).sessionChatRequest(routeLease, "", "session-1", "hello")
|
||||
|
||||
private fun state(sessionId: String): HermesConversationState =
|
||||
HermesConversationState(
|
||||
HermesSession(
|
||||
id = sessionId,
|
||||
title = sessionId,
|
||||
subtitle = "Ready",
|
||||
status = SessionStatus.Idle,
|
||||
model = null,
|
||||
workspace = null,
|
||||
messageCount = 0,
|
||||
createdAt = null,
|
||||
updatedAt = null
|
||||
)
|
||||
)
|
||||
|
||||
private fun client(interceptor: (Interceptor.Chain) -> Response): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor { chain -> interceptor(chain) }
|
||||
.build()
|
||||
|
||||
private fun response(chain: Interceptor.Chain, body: String): Response =
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body(body.toResponseBody("text/event-stream".toMediaType()))
|
||||
.build()
|
||||
|
||||
private companion object {
|
||||
val LOCAL_LEASE = GatewayRouteLease(ConnectionRoute.Local, "https://local.example.test", 11L)
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package cloud.molberg.hermesmobile.streaming
|
||||
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionRoute
|
||||
import cloud.molberg.hermesmobile.connection.GatewayRouteLease
|
||||
import cloud.molberg.hermesmobile.domain.HermesConversationState
|
||||
import cloud.molberg.hermesmobile.domain.HermesSession
|
||||
import cloud.molberg.hermesmobile.domain.MessagePart
|
||||
import cloud.molberg.hermesmobile.domain.SessionStatus
|
||||
import okio.Buffer
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayStreamingContractTest {
|
||||
@Test
|
||||
fun capabilitiesProvideStructuredSessionStreamEndpointAndToolGate() {
|
||||
val capabilities = capabilities(toolProgressEvents = true)
|
||||
|
||||
assertTrue(capabilities.sessionChatStreaming)
|
||||
assertEquals("POST", capabilities.sessionChatStream?.method)
|
||||
assertEquals("/api/sessions/{session_id}/chat/stream", capabilities.sessionChatStream?.path)
|
||||
assertTrue(capabilities.toolProgressEvents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestUsesAdvertisedEndpointAndBindsImmutableRouteSessionLease() {
|
||||
val routeLease = GatewayRouteLease(ConnectionRoute.Local, "https://local.example.test", 7L)
|
||||
val stream = GatewayStreamingContract(capabilities()).sessionChatRequest(
|
||||
routeLease,
|
||||
"secret-token",
|
||||
"session / one",
|
||||
"Continue the build"
|
||||
)
|
||||
val body = Buffer().also { stream.request.body!!.writeTo(it) }.readUtf8()
|
||||
val laterRoute = GatewayRouteLease(ConnectionRoute.Remote, "https://remote.example.test", 8L)
|
||||
|
||||
assertEquals("https://local.example.test/api/sessions/session%20%2F%20one/chat/stream", stream.request.url.toString())
|
||||
assertEquals("Bearer secret-token", stream.request.header("Authorization"))
|
||||
assertEquals("Continue the build", JSONObject(body).getString("input"))
|
||||
assertEquals(routeLease, stream.lease.routeLease)
|
||||
assertFalse(stream.lease.routeLease == laterRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentOrInvalidStreamCapabilityStaysGated() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GatewayStreamingContract(GatewayStreamingCapabilities()).sessionChatRequest(
|
||||
GatewayRouteLease(ConnectionRoute.Remote, "https://remote.example.test", 1L),
|
||||
"",
|
||||
"session-1",
|
||||
"hello"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deterministicFakeStreamReducesDeltasThenAuthoritativeFinalResponse() {
|
||||
val lease = GatewayStreamingContract(capabilities()).sessionChatRequest(
|
||||
GatewayRouteLease(ConnectionRoute.Remote, "https://remote.example.test", 3L),
|
||||
"",
|
||||
"session-1",
|
||||
"hello"
|
||||
).lease
|
||||
val events = listOf(
|
||||
"message.started" to """{"session_id":"session-1","run_id":"run-1","seq":1,"message":{"id":"message-1","role":"assistant"}}""",
|
||||
"assistant.delta" to """{"session_id":"session-1","run_id":"run-1","seq":2,"message_id":"message-1","delta":"Hello"}""",
|
||||
"assistant.delta" to """{"session_id":"session-1","run_id":"run-1","seq":3,"message_id":"message-1","delta":" draft"}""",
|
||||
"assistant.completed" to """{"session_id":"session-1","run_id":"run-1","seq":4,"message_id":"message-1","content":"Hello final"}""",
|
||||
"done" to """{"session_id":"session-1","run_id":"run-1","seq":5}"""
|
||||
)
|
||||
val result = events.fold(baseState()) { state, (name, data) ->
|
||||
val decoded = GatewaySessionSseDecoder.decode(lease, name, data)
|
||||
assertNotNull(decoded)
|
||||
BoundGatewayStreamReducer.reduce(lease, state, (decoded as GatewayDecodedStreamEvent.Domain).event)
|
||||
}
|
||||
|
||||
assertEquals("Hello final", (result.messages.single().parts.single() as MessagePart.Text).text)
|
||||
assertEquals(SessionStatus.Completed, result.session.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foreignSessionEventsAndUnadvertisedStructuredEventsAreIgnored() {
|
||||
val lease = GatewayStreamingContract(capabilities(toolProgressEvents = false)).sessionChatRequest(
|
||||
GatewayRouteLease(ConnectionRoute.Remote, "https://remote.example.test", 3L),
|
||||
"",
|
||||
"session-1",
|
||||
"hello"
|
||||
).lease
|
||||
val foreign = GatewaySessionSseDecoder.decode(
|
||||
lease,
|
||||
"assistant.delta",
|
||||
"""{"session_id":"session-2","message_id":"message-2","delta":"wrong session"}"""
|
||||
) as GatewayDecodedStreamEvent.Domain
|
||||
|
||||
assertEquals(baseState(), BoundGatewayStreamReducer.reduce(lease, baseState(), foreign.event))
|
||||
assertNull(GatewaySessionSseDecoder.decode(lease, "tool.started", """{"tool_name":"terminal"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun structuredEventsDecodeOnlyWhenCapabilityIsAdvertised() {
|
||||
val lease = GatewayStreamingContract(capabilities(toolProgressEvents = true)).sessionChatRequest(
|
||||
GatewayRouteLease(ConnectionRoute.Remote, "https://remote.example.test", 3L),
|
||||
"",
|
||||
"session-1",
|
||||
"hello"
|
||||
).lease
|
||||
|
||||
val decoded = GatewaySessionSseDecoder.decode(lease, "tool.started", """{"tool_name":"terminal"}""")
|
||||
|
||||
assertTrue(decoded is GatewayDecodedStreamEvent.Structured)
|
||||
}
|
||||
|
||||
private fun capabilities(toolProgressEvents: Boolean = false): GatewayStreamingCapabilities =
|
||||
GatewayStreamingCapabilities.fromCapabilities(
|
||||
JSONObject(
|
||||
"""{
|
||||
"features": {
|
||||
"session_chat_streaming": true,
|
||||
"tool_progress_events": $toolProgressEvents
|
||||
},
|
||||
"endpoints": {
|
||||
"session_chat_stream": {
|
||||
"method": "POST",
|
||||
"path": "/api/sessions/{session_id}/chat/stream"
|
||||
}
|
||||
}
|
||||
}""".trimIndent()
|
||||
)
|
||||
)
|
||||
|
||||
private fun baseState(): HermesConversationState =
|
||||
HermesConversationState(
|
||||
session = HermesSession(
|
||||
id = "session-1",
|
||||
title = "Test",
|
||||
subtitle = "Ready",
|
||||
status = SessionStatus.Idle,
|
||||
model = null,
|
||||
workspace = null,
|
||||
messageCount = 0,
|
||||
createdAt = null,
|
||||
updatedAt = null
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user