Merge pull request #1206 from Hy4ri/feat/reasoning-composer-correctness

feat(chat): make reasoning control acknowledged and route-honest
This commit is contained in:
m57 authored and GitHub committed 2026-09-21 12:40:54 +03:00
1 parent 0a323deae5
commit 29fdb52978
20 files changed
+773 -54

No files matched your search

@@ -715,11 +715,17 @@ class EventParserTest {
result = null,
error = null,
method = "event",
params = mapOf("type" to "session.info", "payload" to mapOf("session_id" to "sess-1")),
params =
mapOf(
"type" to "session.info",
"session_id" to "sess-1",
"payload" to mapOf("session_id" to "sess-1"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.SessionInfo)
assertEquals(mapOf("session_id" to "sess-1"), (event as WsEvent.SessionInfo).data)
assertEquals("sess-1", event.sessionId)
}
@Test
@@ -88,6 +88,21 @@ class WsProfileParamsTest {
assertEquals("meow", result["profile"])
}
@Test
fun `config get and set are profile scoped`() {
AuthManager.setActiveProfileId("work")
val getResult = WsProfileParams.decorate(WsMethods.CONFIG_GET, mapOf("key" to "reasoning"))
val setResult =
WsProfileParams.decorate(
WsMethods.CONFIG_SET,
mapOf("key" to "reasoning", "value" to "high"),
)
assertEquals("work", getResult["profile"])
assertEquals("work", setResult["profile"])
}
@Test
fun `profile_scoped decorator method gets profile injected`() {
AuthManager.setActiveProfileId("meow")
@@ -430,6 +430,163 @@ class ChatModelSwitchDelegateTest {
assertEquals("anthropic/claude-3-5-sonnet", uiState.value.currentSessionModel)
}
@Test
fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() =
testScope.runTest {
val gate = CompletableDeferred<Any?>()
val reqMethods = mutableListOf<String>()
val reqParams = mutableListOf<Map<String, Any>>()
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { method, params ->
reqMethods.add(method)
reqParams.add(params)
gate.await()
},
)
uiState.value = uiState.value.copy(reasoningLevel = "ultra", reasoningWireLevel = "max")
customDelegate.setReasoningLevel("high")
runCurrent()
// Confirmed level remains unchanged until acknowledged. The stale
// wire is cleared immediately so "High→Max" can never be painted.
assertEquals("ultra", uiState.value.reasoningLevel)
assertEquals("high", uiState.value.pendingReasoningLevel)
assertNull(uiState.value.reasoningWireLevel)
assertEquals(listOf(WsMethods.CONFIG_SET), reqMethods)
assertEquals("session", reqParams.first()["scope"])
assertEquals("high", reqParams.first()["value"])
// Second pick while in-flight is rejected (serialized)
customDelegate.setReasoningLevel("low")
runCurrent()
assertEquals(1, reqMethods.size)
// Simulate session.info winning the race and publishing the fresh
// authoritative wire before the RPC ACK resumes the delegate.
uiState.value = uiState.value.copy(reasoningWireLevel = "high")
// Production request() returns a raw JsonObject, not a Map.
gate.complete(
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
},
)
advanceUntilIdle()
assertEquals("high", uiState.value.reasoningLevel)
assertNull(uiState.value.pendingReasoningLevel)
// ACK must not erase a fresh wire that arrived first.
assertEquals("high", uiState.value.reasoningWireLevel)
}
@Test
fun setReasoningLevel_onFailure_clearsPendingAndSurfacesError() =
testScope.runTest {
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { _, _ -> throw RuntimeException("4002 unknown reasoning value") },
)
customDelegate.setReasoningLevel("ultra")
advanceUntilIdle()
assertNull(uiState.value.reasoningLevel)
assertNull(uiState.value.pendingReasoningLevel)
assertTrue(uiState.value.errorMessage?.contains("4002") == true)
}
@Test
fun setReasoningLevel_whenRuntimeSessionNullOrBlank_doesNotSend() =
testScope.runTest {
val reqMethods = mutableListOf<String>()
runtimeId = null
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { method, _ ->
reqMethods.add(method)
null
},
)
customDelegate.setReasoningLevel("high")
advanceUntilIdle()
assertTrue(reqMethods.isEmpty())
assertNull(uiState.value.pendingReasoningLevel)
assertNull(uiState.value.reasoningLevel)
}
@Test
fun setReasoningLevel_staleScopeOrReset_dropsLateAck() =
testScope.runTest {
val gate = CompletableDeferred<Any?>()
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { _, _ -> gate.await() },
)
customDelegate.setReasoningLevel("high")
runCurrent()
assertEquals("high", uiState.value.pendingReasoningLevel)
// Reset called while in-flight
customDelegate.reset()
assertNull(uiState.value.pendingReasoningLevel)
// Late RPC response arrives
gate.complete(mapOf("key" to "reasoning", "value" to "high"))
advanceUntilIdle()
assertNull(uiState.value.reasoningLevel)
assertNull(uiState.value.pendingReasoningLevel)
}
@Test
fun rapidPicks_preservesOriginalConfirmedModelForRollback() =
testScope.runTest {
@@ -2651,9 +2651,50 @@ class ChatViewModelTest {
assertNull(state.sudoPrompt)
assertNull(state.errorMessage)
assertNull(state.resumeError)
assertNull(state.pendingReasoningLevel)
assertNull(state.reasoningWireLevel)
assertTrue(state.isLoading)
}
@Test
fun testSessionInfo_reconcilesRequestedAndWireReasoningEffort() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(
WsEvent.SessionInfo(
data =
mapOf(
"model" to "gpt-5",
"provider" to "openai",
"reasoning_effort" to "ultra",
"reasoning_effort_wire" to "max",
),
sessionId = sessionId,
),
)
runCurrent()
val state = viewModel.uiState.value
assertEquals("ultra", state.reasoningLevel)
assertEquals("max", state.reasoningWireLevel)
// Other session's event is ignored
mockEventsFlow.emit(
WsEvent.SessionInfo(
data =
mapOf(
"model" to "gpt-5",
"provider" to "openai",
"reasoning_effort" to "low",
),
sessionId = "other-session-id",
),
)
runCurrent()
assertEquals("ultra", viewModel.uiState.value.reasoningLevel)
}
@Test
fun testSessionBranchResult_clearsSessionBoundUiState() =
runTest {
@@ -38,4 +38,16 @@ class ComposerToolbarTest {
assertEquals("openai/", composerModelLabel("openai/", showProvider = true))
assertEquals("/gpt-5", composerModelLabel("/gpt-5", showProvider = true))
}
@Test
fun buildReasoningLabel_formatsRequestedAndClampedWire() {
assertEquals("Default", buildReasoningLabel(null, defaultLabel = "Default"))
assertEquals("الافتراضي", buildReasoningLabel(null, defaultLabel = "الافتراضي"))
assertEquals("None", buildReasoningLabel("none"))
assertEquals("High", buildReasoningLabel("high"))
assertEquals("Ultra", buildReasoningLabel("ultra"))
assertEquals("Ultra→Max", buildReasoningLabel("ultra", wireLevel = "max"))
assertEquals("High", buildReasoningLabel("high", wireLevel = "high"))
assertEquals("None", buildReasoningLabel("none", wireLevel = "low"))
}
}
@@ -237,6 +237,121 @@ class SlashCommandDispatchRpcTest {
assertEquals("do the thing", userBubble?.content)
}
@Test
fun `reasoning effort with global flag uses acknowledged CONFIG_SET`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_SET) {
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
}
vm.sendMessage("/reasoning high --global")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET)
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals("high", params["value"])
assertEquals(sessionId, params["session_id"])
assertEquals("global", params["scope"])
assertEquals("high", vm.uiState.value.reasoningLevel)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
}
@Test
fun `reasoning display word uses CONFIG_SET instead of rejecting locally`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_SET) {
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("show"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
}
vm.sendMessage("/reasoning show")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET)
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals("show", params["value"])
assertEquals(sessionId, params["session_id"])
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
assertEquals(
"reasoning: show",
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
?.content,
)
}
@Test
fun `bare reasoning reads canonical CONFIG_GET status`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_GET) {
kotlinx.serialization.json.buildJsonObject {
put("value", kotlinx.serialization.json.JsonPrimitive("ultra"))
put("display", kotlinx.serialization.json.JsonPrimitive("hide"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
}
vm.sendMessage("/reasoning")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_GET)
assertTrue("expected CONFIG_GET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals(sessionId, params["session_id"])
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
assertEquals(
"reasoning: ultra · display hide",
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
?.content,
)
}
@Test
fun `slash queue with no arg shows usage and sends nothing`() =
runTest {
@@ -53,6 +53,14 @@ class SlashCommandDispatcherTest {
assertEquals(SlashResult.ModelSwitch, dispatcher.dispatch("/Model"))
}
@Test
fun `reasoning routes to ReasoningSwitch`() {
assertEquals(SlashResult.ReasoningSwitch(""), dispatcher.dispatch("/reasoning"))
assertEquals(SlashResult.ReasoningSwitch("high"), dispatcher.dispatch("/reasoning high"))
assertEquals(SlashResult.ReasoningSwitch("HIGH"), dispatcher.dispatch("/REASONING HIGH"))
assertEquals(SlashResult.ReasoningSwitch("ultra"), dispatcher.dispatch("/Reasoning ultra"))
}
@Test
fun `update routes to Update`() {
// /update's backend handler is interactive + session-exiting and can