fix(android): honor session control reset semantics

This commit is contained in:
Bailey Dixon
2026-07-20 08:49:20 -04:00
parent 512199448e
commit 16d1728306
5 changed files with 92 additions and 5 deletions
+6
View File
@@ -53,6 +53,12 @@ intentionally remain outside that code batch:
`docs/upstream-contributions.md`. Until then, document the narrow race where a
disconnect or Stop after `/model --once` succeeds but before prompt submission
can leave the override armed for a later prompt.
- Keep HRUI-052 (`/new` session-control reset parity) blocked until upstream
exposes a reset on the active gateway session or an authoritative reset event.
`slash.exec` runs the command in a separate worker today, and the mirrored
slash side effects do not reset the active TUI session's agent. Relay must not
clear local model, reasoning, or Fast pins from a successful command response
that did not mutate the agent those controls describe.
- Upstream the first-class commentary event and profile-scoped cron execution
attempts API before adding Relay client parsers for those surfaces.
- Keep Standard voice labeled host-global until upstream exposes a stable
@@ -314,7 +314,9 @@ data class GatewayModelOptions(
*
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
* provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string
* (`low`/`medium`/`high`/…). [fast] pins the priority service tier when true.
* (`low`/`medium`/`high`/…). [fast] follows the contract-v4 tri-state: `true`
* pins priority, `false` explicitly pins normal, and `null` omits the field so
* the profile's service tier is inherited.
* Note `yolo` is intentionally absent — upstream `session.create` does NOT
* accept it as a per-session override, so it is applied post-create instead.
*/
@@ -1092,10 +1092,11 @@ class ChatViewModel : ViewModel() {
val model = _selectedModelOverride.value?.takeIf { it.isNotBlank() }
val provider = _selectedProviderOverride.value?.takeIf { it.isNotBlank() }
val effort = _selectedReasoningEffort.value?.takeIf { it.isNotBlank() }
// Only pin fast when explicitly ON (upstream treats fast=false the
// same as omitting → profile default tier), so a null/false leaves
// the profile's own service tier intact.
val fast = _fastEnabled.value?.takeIf { it }
// Contract v4 distinguishes all three states: null omits the field
// and inherits the profile tier, true pins priority, and false pins
// normal. Do not filter false here or a user's explicit Fast-off
// pick would silently inherit a priority-by-default profile.
val fast = _fastEnabled.value
if (model != null || effort != null || fast != null) {
GatewaySessionModel(
model = model,
@@ -234,6 +234,10 @@ class GatewayClientHarness(
else -> JsonObject(emptyMap())
}
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
"model" -> buildJsonObject {
put("key", "model")
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "")
}
"reasoning" -> {
reasoningEffort = (params["value"] as? JsonPrimitive)?.contentOrNull ?: reasoningEffort
buildJsonObject {
@@ -241,6 +245,10 @@ class GatewayClientHarness(
put("value", reasoningEffort)
}
}
"fast" -> buildJsonObject {
put("key", "fast")
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "normal")
}
else -> JsonObject(emptyMap())
}
else -> JsonObject(emptyMap())
@@ -1194,6 +1202,20 @@ class GatewayChatClientTest {
assertEquals(true, (create["fast"] as? JsonPrimitive)?.booleanOrNull)
}
@Test
fun `session create binds explicit normal fast tier`() {
val r = Recorder()
client.sessionModelProvider = {
GatewaySessionModel(model = null, provider = null, reasoningEffort = null, fast = false)
}
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val create = harness.awaitRpc("session.create")
assertEquals(false, (create["fast"] as? JsonPrimitive)?.booleanOrNull)
}
@Test
fun `session create omits reasoning_effort and fast when unset`() {
val r = Recorder()
@@ -1503,6 +1525,43 @@ class GatewayChatClientTest {
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `model update targets live session without global scope`() {
val r = Recorder()
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.setModel("grok-4.3 --provider xai") }
assertTrue(result.isSuccess)
val rpc = harness.awaitRpc("config.set")
assertEquals("model", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
assertEquals("grok-4.3 --provider xai", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
assertFalse(rpc.containsKey("scope"))
}
@Test
fun `fast update targets live session without global scope`() {
val r = Recorder()
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.setFast(false) }
assertTrue(result.isSuccess)
assertFalse(result.getOrThrow())
val rpc = harness.awaitRpc("config.set")
assertEquals("fast", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
assertEquals("normal", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
assertFalse(rpc.containsKey("scope"))
}
// --- Edit & regenerate ---
@Test
@@ -139,6 +139,25 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("victor", gatewayClient.sessionProfileProvider())
}
@Test
fun gatewaySessionCreateProviderPreservesExplicitFastFalse() {
serverWs.send(
gatewayHarness.eventFrame(
"session.info",
buildJsonObject { put("fast", false) },
"live-resumed",
),
)
val deadline = System.currentTimeMillis() + 5_000
while (viewModel.fastEnabled.value != false) {
shadowOf(Looper.getMainLooper()).idle()
if (System.currentTimeMillis() >= deadline) error("fast=false did not reconcile")
Thread.sleep(20)
}
assertEquals(false, gatewayClient.sessionModelProvider()?.fast)
}
@Test
fun dashboardOnlyConnectionCanSendWithoutApiClient() {
viewModel.updateGatewayClient(null)