Merge pull request #1206 from Hy4ri/feat/reasoning-composer-correctness
feat(chat): make reasoning control acknowledged and route-honest
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user