fix(#1379): send only contract params in subagent.tail (#1383)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 17:04:10 +03:00
1 parent aac42032fb
commit 1dd8d2138d
4 files changed
+46 -15

No files matched your search

@@ -55,17 +55,14 @@ object SubagentRepository {
suspend fun tailSubagent( suspend fun tailSubagent(
sessionId: String, sessionId: String,
subagentId: String, subagentId: String,
maxBytes: Int = 16384,
): SubagentTailResponse? { ): SubagentTailResponse? {
if (subagentId.isBlank()) return null // Contract (SubagentIdParams): session_id and subagent_id are required, extra keys forbidden (#1379).
if (subagentId.isBlank() || sessionId.isBlank()) return null
val params = val params =
mutableMapOf<String, Any>( mapOf<String, Any>(
"session_id" to sessionId,
"subagent_id" to subagentId, "subagent_id" to subagentId,
"max_bytes" to maxBytes,
) )
if (sessionId.isNotBlank()) {
params["session_id"] = sessionId
}
val result = val result =
HermesWsClient HermesWsClient
.request( .request(
@@ -27,7 +27,6 @@ class ChatSubagentsDelegate(
) { ) {
companion object { companion object {
private const val TAG = "ChatSubagentsDelegate" private const val TAG = "ChatSubagentsDelegate"
const val SUBAGENT_TAIL_MAX_BYTES = 16384
} }
private var pollingJob: Job? = null private var pollingJob: Job? = null
@@ -130,7 +129,6 @@ class ChatSubagentsDelegate(
subagentRepository.tailSubagent( subagentRepository.tailSubagent(
sessionId = sessionId, sessionId = sessionId,
subagentId = subagentId, subagentId = subagentId,
maxBytes = SUBAGENT_TAIL_MAX_BYTES,
) )
if (tailResponse != null) { if (tailResponse != null) {
@@ -3,6 +3,14 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.SubagentListItem import com.m57.hermescontrol.data.model.SubagentListItem
import com.m57.hermescontrol.data.model.SubagentListResponse import com.m57.hermescontrol.data.model.SubagentListResponse
import com.m57.hermescontrol.data.model.SubagentTailResponse import com.m57.hermescontrol.data.model.SubagentTailResponse
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
@@ -11,6 +19,34 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class SubagentRepositoryTest { class SubagentRepositoryTest {
@After
fun tearDown() {
unmockkAll()
}
@Test
fun tailSubagentSendsExactlySessionIdAndSubagentId() {
// #1379: the contract forbids extra keys (max_bytes) and requires session_id.
val captured = slot<Map<String, Any>>()
mockkObject(HermesWsClient)
every { HermesWsClient.request(WsMethods.SUBAGENT_TAIL, capture(captured), any(), any()) } returns
CompletableDeferred<Any?>(mapOf("subagent_id" to "sub-1"))
runBlocking { SubagentRepository.tailSubagent("s1", "sub-1") }
assertEquals(mapOf<String, Any>("session_id" to "s1", "subagent_id" to "sub-1"), captured.captured)
}
@Test
fun tailSubagentSkipsTheCallWhenAnIdIsBlank() {
mockkObject(HermesWsClient)
runBlocking {
assertNull(SubagentRepository.tailSubagent("", "sub-1"))
assertNull(SubagentRepository.tailSubagent("s1", " "))
}
verify(exactly = 0) { HermesWsClient.request(any(), any(), any(), any()) }
}
@Test @Test
fun testWsMethodsConstants() { fun testWsMethodsConstants() {
assertEquals("subagent.list", WsMethods.SUBAGENT_LIST) assertEquals("subagent.list", WsMethods.SUBAGENT_LIST)
@@ -448,7 +448,7 @@ class ChatSubagentsDelegateTest {
), ),
) )
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returnsMany coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returnsMany
listOf( listOf(
SubagentTailResponse(subagentId = "sub-1", text = "Line 1\n", truncated = false), SubagentTailResponse(subagentId = "sub-1", text = "Line 1\n", truncated = false),
SubagentTailResponse(subagentId = "sub-1", text = "Line 1\nLine 2\n", truncated = false), SubagentTailResponse(subagentId = "sub-1", text = "Line 1\nLine 2\n", truncated = false),
@@ -485,7 +485,7 @@ class ChatSubagentsDelegateTest {
), ),
) )
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
SubagentTailResponse(subagentId = "sub-1", text = "Finished output", truncated = false) SubagentTailResponse(subagentId = "sub-1", text = "Finished output", truncated = false)
delegate.inspectSubagentTranscript("sub-1") delegate.inspectSubagentTranscript("sub-1")
@@ -507,7 +507,7 @@ class ChatSubagentsDelegateTest {
advanceTimeBy(3000) advanceTimeBy(3000)
// Verify tailSubagent was called at most once or twice, not indefinitely // Verify tailSubagent was called at most once or twice, not indefinitely
coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1", 16384) } coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1") }
delegate.closeSubagentTranscript() delegate.closeSubagentTranscript()
} }
@@ -526,7 +526,7 @@ class ChatSubagentsDelegateTest {
), ),
) )
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
SubagentTailResponse(subagentId = "sub-1", text = "Output") SubagentTailResponse(subagentId = "sub-1", text = "Output")
delegate.inspectSubagentTranscript("sub-1") delegate.inspectSubagentTranscript("sub-1")
@@ -553,7 +553,7 @@ class ChatSubagentsDelegateTest {
), ),
) )
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } throws coEvery { mockRepository.tailSubagent("session-123", "sub-1") } throws
RuntimeException("Gateway connection error") RuntimeException("Gateway connection error")
delegate.inspectSubagentTranscript("sub-1") delegate.inspectSubagentTranscript("sub-1")
@@ -565,7 +565,7 @@ class ChatSubagentsDelegateTest {
assertFalse(transcript?.isLoading == true) assertFalse(transcript?.isLoading == true)
// Now recover on retry // Now recover on retry
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
SubagentTailResponse(subagentId = "sub-1", text = "Recovered logs") SubagentTailResponse(subagentId = "sub-1", text = "Recovered logs")
delegate.retryTranscript() delegate.retryTranscript()