From 6a45d1c0d3115520a0a6e828f075362d215d7e58 Mon Sep 17 00:00:00 2001 From: Malcolm Habeeb Date: Tue, 11 Aug 2026 12:20:45 -0400 Subject: [PATCH 01/19] XERK-246: put the local-model failover controls in the Android client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running out of Claude usage stops every session on a host at once. The web UI has had the failover since XERK-246; Android had neither control, which is a gap precisely when it matters most β€” usage runs out while you are away from a desk, and a phone was then the one surface that could do nothing about it. Two controls, matching the web: - A "run: " chip in the chat compose bar, beside model and mode, that POSTs .../sessions//model-source. The agent relaunches with --resume, so the conversation, worktree and branch carry over. - A "Run against" row in the spawn composer, so new work can START on the local model. Without it you could fail existing sessions over from the phone but not begin anything once usage was gone. Both follow the HOST's localModel.available exactly as the composer's clip button follows uploadMaxBytes: an agent reporting nothing cannot do it and the hub 409s the command, so the control is hidden rather than offered and refused. The chip is shown anyway when the session is already local, so one whose host later lost its configuration keeps a visible way back. The chip paints from a memo until the heartbeat agrees β€” the relaunch takes several beats, and without it the value springs back and reads as a control that did nothing. The memo retires on the heartbeat reporting the switch, and ages out at 60s so one that never lands can't pin the chip on a lie; a refusal drops it at once and says why. The Claude model picker is hidden on a local session (a static chip states the model instead), as on the web: every alias it could offer β€” "default" included, since that resolves to the shared login's default β€” is one the self-hosted endpoint refuses. Pure half in core/ModelSource.kt so the Compose screens stay thin renderers. android/PARITY.md narrows the gap to the remaining 🏠 mark on session cards. --- android/PARITY.md | 44 +++++++--- .../com/xerktech/turma/core/ModelSource.kt | 85 +++++++++++++++++++ .../java/com/xerktech/turma/model/Models.kt | 30 +++++++ .../java/com/xerktech/turma/net/HubApi.kt | 20 +++++ .../java/com/xerktech/turma/ui/ChatScreen.kt | 71 +++++++++++++++- .../com/xerktech/turma/ui/FleetDialogs.kt | 58 +++++++++++-- .../java/com/xerktech/turma/ui/FleetScreen.kt | 7 +- .../com/xerktech/turma/ui/SessionsScreen.kt | 7 +- .../com/xerktech/turma/vm/ChatViewModel.kt | 63 +++++++++++++- .../com/xerktech/turma/vm/FleetViewModel.kt | 7 ++ .../xerktech/turma/core/ModelSourceTest.kt | 85 +++++++++++++++++++ .../xerktech/turma/model/AgentDecodeTest.kt | 41 +++++++++ 12 files changed, 490 insertions(+), 28 deletions(-) create mode 100644 android/app/src/main/java/com/xerktech/turma/core/ModelSource.kt create mode 100644 android/app/src/test/java/com/xerktech/turma/core/ModelSourceTest.kt diff --git a/android/PARITY.md b/android/PARITY.md index 28ddbd9e..bd087ba1 100644 --- a/android/PARITY.md +++ b/android/PARITY.md @@ -315,6 +315,32 @@ are recorded under "Deliberate differences" below, not left to look like gaps. the drop case. Sharing INTO the app is not wired up (no `ACTION_SEND` intent filter yet) β€” a reasonable follow-up, tracked below. +## Done (XERK-246 β€” local-model failover controls) + +- **Switch a running session between the subscription and the host's self-hosted model.** A third + compose-bar chip beside model and mode ("run: subscription" / "run: ") POSTing + `.../sessions//model-source`; the agent relaunches with `--resume`, so the conversation, + worktree and branch carry over. The chip paints from a memo until the heartbeat agrees β€” the + relaunch takes several beats, and without it the value springs back and reads as a dead control β€” + and the memo ages out (`ModelSource.SWITCH_SETTLE_MS`) so a switch that never lands can't pin it + on a lie. A refused switch (the hub 409s a host with no local model) drops the memo at once and + says why. +- **Start NEW work on the local model**: a "Run against" row in the spawn composer, the web's + `sessions.html` field. Without it you could fail existing sessions over from the phone but not + begin anything once usage was gone β€” which is exactly when you need to. +- Both follow the **host's** `localModel.available`, exactly as the πŸ“Ž follows `uploadMaxBytes`; an + agent reporting nothing cannot do it, so the control is hidden rather than offered and refused. + The compose-bar chip is also shown when the session is already `local`, so one whose host later + lost its configuration keeps a visible way back. +- **The Claude model picker is hidden on a local session** (a static chip states the model instead), + matching the web: every alias it could offer β€” "default" included, since that resolves to the + shared login's default β€” is one the self-hosted endpoint refuses. The spawn composer drops the + alias for the same reason. +- Pure half in `core/ModelSource.kt` (`ModelSourceTest.kt`); the wire block is locked in + `AgentDecodeTest.kt`, including the all-nulls shape an unconfigured host reports. +- **Still open:** the 🏠 mark on live and ended session CARDS (see below) β€” inside a session the + compose-bar chip already names the model. + ## Open (subsequent installments), by screen and priority Many of these need Android's wire model (`model/Models.kt`) to decode fields the web already renders; @@ -332,19 +358,11 @@ those are marked `[MODEL]`. - P1 Composer base-branch dropdown + per-repo option persistence. ### Sessions + Chat (`sessions.html` + `chat.js` β†’ `SessionsScreen`/`ChatScreen`) -- **P1 Local-model failover control + chip (XERK-246).** The web compose bar carries a third - selector beside agent-mode and model β€” "Run against: Claude subscription / " β€” - which POSTs `/api/agents//sessions//model-source` and relaunches the session on the - local model, keeping its conversation. A session on the local model is marked (🏠 + warn colour) - so nobody has to wonder which model wrote a turn. Android shows neither the control nor the mark. - Gate it on the host's `localModel.available`, exactly as the πŸ“Ž gates on `uploadMaxBytes` β€” an - agent that reports nothing cannot do it. Field: `session.modelSource` - (`"subscription"`/`"local"`). This matters on a phone precisely when it matters most: usage runs - out while you are away from a desk. Three parts, all missing: the compose-bar selector, the mark - on live AND ended session cards, and a **"Run against" option in the spawn composer** (without the - last one you can fail existing sessions over from the phone but cannot start new work once usage - is gone). Note the web hides the model picker entirely for a local session β€” every alias it could - offer is one the gateway refuses. +- **P2 Local-model mark on session CARDS (XERK-246 remainder).** The two controls are done (see Done + above); what's left is the web's 🏠 + warn-colour mark on live and ended session cards, so a + glance at the list says which sessions are on the weaker model without opening each one. Read + `session.modelSource == "local"`, titled with `modelSourceAt`. Both fields already decode onto + `SessionInfo`. - ~~P0 Jump-to-latest pill + stick-bottom scroll.~~ Done (XERK-78, see Done above). - ~~P0 Ended sessions: stopped + `repo.resumable` channels + live-list exclusion.~~ Done (XERK-78, see Done above; the read-only review itself was XERK-70). diff --git a/android/app/src/main/java/com/xerktech/turma/core/ModelSource.kt b/android/app/src/main/java/com/xerktech/turma/core/ModelSource.kt new file mode 100644 index 00000000..095c941e --- /dev/null +++ b/android/app/src/main/java/com/xerktech/turma/core/ModelSource.kt @@ -0,0 +1,85 @@ +package com.xerktech.turma.core + +import com.xerktech.turma.model.LocalModelInfo +import com.xerktech.turma.model.SessionInfo + +/** + * Which model a session runs against, and how the chat compose bar paints it β€” + * the port of web `chat.js`'s `localModelOffered` / `currentModelSource` / + * `modelSourceLabel` / `modelSourceOpts` (XERK-246). + * + * Running out of Claude usage stops every session on a host at once; this is the + * control that moves one onto the host's self-hosted model instead, keeping its + * conversation. On a phone that matters most precisely when it matters at all β€” + * usage runs out while you are away from a desk. + */ +object ModelSource { + + const val SUBSCRIPTION = "subscription" + const val LOCAL = "local" + + /** + * How long an unconfirmed switch keeps painting its own value. The switch + * relaunches Claude with `--resume`, so the heartbeat can take several beats + * to agree; without a memo the chip springs back to the old value and reads + * as a control that did nothing. It AGES OUT so a switch that never lands + * can't pin the chip on a lie forever. + */ + const val SWITCH_SETTLE_MS = 60_000L + + /** An in-flight switch: what was asked for, when, and for which session. */ + data class Pending(val sessionId: String, val value: String, val at: Long) + + /** + * Should the "run against" control be shown at all? + * + * Gated on the HOST's capability flag exactly as the composer's πŸ“Ž is gated + * on `uploadMaxBytes` β€” an agent reporting nothing cannot do it, so offering + * the switch would queue a command it silently drops (the hub 409s too). + * + * The second half is not redundant: a session already running `local` keeps + * the control even if its host later lost the configuration, so there is + * always a visible way back to the subscription. + */ + fun offered(local: LocalModelInfo?, current: String): Boolean = + local?.available == true || current == LOCAL + + /** + * The source to paint: an unexpired [pending] for THIS session outranks the + * heartbeat, else what the agent reports. Blank (an agent predating the + * failover) reads as the subscription β€” the only thing it can run. + */ + fun current(session: SessionInfo?, pending: Pending?, now: Long): String { + if (pending != null && pending.sessionId == (session?.id ?: "") && + now - pending.at < SWITCH_SETTLE_MS + ) { + return pending.value + } + return session?.modelSource?.takeIf { it.isNotBlank() } ?: SUBSCRIPTION + } + + /** + * Human label for a source. A local session reads as the MODEL NAME, not the + * word "local": it is a weaker model than Claude, and nobody should have to + * wonder which one wrote a turn. + */ + fun label(source: String, local: LocalModelInfo?): String = + if (source == LOCAL) local?.model?.takeIf { it.isNotBlank() } ?: "local model" + else "subscription" + + /** Menu rows as (value, label) pairs, in the web menu's order. */ + fun options(local: LocalModelInfo?): List> = + listOf( + SUBSCRIPTION to "Claude subscription", + LOCAL to (local?.model?.takeIf { it.isNotBlank() } ?: "Self-hosted model"), + ) + + /** + * Is the Claude model picker meaningful for this session? It is not on the + * local model: every alias it could offer β€” "Default" included, since that + * resolves to the shared login's default β€” is one the self-hosted endpoint + * refuses, so the chip states the fixed model instead of offering a menu + * that can only break the session. Mirrors web `cc-model-fixed`. + */ + fun modelPickable(source: String): Boolean = source != LOCAL +} diff --git a/android/app/src/main/java/com/xerktech/turma/model/Models.kt b/android/app/src/main/java/com/xerktech/turma/model/Models.kt index afa23c80..05be1179 100644 --- a/android/app/src/main/java/com/xerktech/turma/model/Models.kt +++ b/android/app/src/main/java/com/xerktech/turma/model/Models.kt @@ -131,6 +131,27 @@ data class AgentInfo( // which is what hides the composer's πŸ“Ž rather than letting the operator // attach into a void. See the hub's uploadCapFor. val uploadMaxBytes: Long = 0, + /** + * Whether this host can run a session against its own self-hosted model + * (XERK-246), and which one. Doubles as the capability flag, exactly like + * [uploadMaxBytes]: an agent predating the failover β€” or one with no + * LOCAL_MODEL_* env β€” reports nothing, and an ABSENT block means "that host + * cannot do it", never "assume it can". Clients hide the control rather than + * queue a command the host would ack and drop. + */ + val localModel: LocalModelInfo? = null, +) + +/** + * A host's self-hosted-model configuration (hub-agent's `localModel` block). + * [model] and [contextTokens] are null exactly when [available] is false, so + * nothing may read them as a fallback for an unconfigured host. + */ +@Serializable +data class LocalModelInfo( + val available: Boolean = false, + val model: String? = null, + val contextTokens: Int? = null, ) /** A host's session ceiling and live counts (hub-agent `_capacity_payload`). */ @@ -495,6 +516,15 @@ data class SessionInfo( val ttydPort: Int = 0, val model: String = "", val permissionMode: String = "", + /** + * Which model this session runs against (XERK-246): "subscription" (the + * host's shared Claude login) or "local" (its self-hosted model). An agent + * predating the failover reports nothing, which reads as the subscription β€” + * the only thing such a host can run. + */ + val modelSource: String = "", + /** When it was last switched; "" for a session that never moved. */ + val modelSourceAt: String = "", val usage: UsageInfo? = null, val prs: List = emptyList(), /** diff --git a/android/app/src/main/java/com/xerktech/turma/net/HubApi.kt b/android/app/src/main/java/com/xerktech/turma/net/HubApi.kt index cad43a72..e95372ca 100644 --- a/android/app/src/main/java/com/xerktech/turma/net/HubApi.kt +++ b/android/app/src/main/java/com/xerktech/turma/net/HubApi.kt @@ -99,6 +99,19 @@ interface HubApi { @Body body: ModeRequest, ): OkResponse + /** + * Move a RUNNING session between the host's Claude subscription and its + * self-hosted model (XERK-246), keeping the conversation. The hub 409s when + * the host reports no local model, so the caller must gate on + * `localModel.available` rather than let the operator press a dead button. + */ + @POST("api/agents/{host}/sessions/{id}/model-source") + suspend fun setModelSource( + @Path("host") host: String, + @Path("id") id: String, + @Body body: ModelSourceRequest, + ): OkResponse + @POST("api/agents/{host}/sessions/{id}/summary") suspend fun setSummary( @Path("host") host: String, @@ -322,6 +335,10 @@ data class SpawnRequest( val baseRef: String? = null, val model: String? = null, val permissionMode: String? = null, + // Which model the new session runs against (XERK-246). Omitted unless the + // operator picked one, so a bare spawn is the exact body it always was; the + // hub 409s "local" at a host reporting no local model. + val modelSource: String? = null, ) @Serializable @@ -347,6 +364,9 @@ data class ModelRequest(val model: String) @Serializable data class ModeRequest(val permissionMode: String) +@Serializable +data class ModelSourceRequest(val modelSource: String) + /** Rename a session β€” a blank [summary] clears the name back to the fallback. */ @Serializable data class SummaryRequest(val summary: String) diff --git a/android/app/src/main/java/com/xerktech/turma/ui/ChatScreen.kt b/android/app/src/main/java/com/xerktech/turma/ui/ChatScreen.kt index 4c0b7353..2bd808f9 100644 --- a/android/app/src/main/java/com/xerktech/turma/ui/ChatScreen.kt +++ b/android/app/src/main/java/com/xerktech/turma/ui/ChatScreen.kt @@ -266,6 +266,10 @@ fun ChatScreen( onMicStop = vm::stopDictation, onModel = vm::setModel, onMode = vm::setMode, + localModel = state.localModel, + modelSource = state.modelSource(), + canSwitchModelSource = state.canSwitchModelSource(), + onModelSource = vm::setModelSource, onAttach = vm::attach, onRemoveAttachment = vm::removeAttachment, ) @@ -583,6 +587,10 @@ private fun ChatFooter( onMicStop: () -> Unit, onModel: (String) -> Unit, onMode: (String) -> Unit, + localModel: com.xerktech.turma.model.LocalModelInfo?, + modelSource: String, + canSwitchModelSource: Boolean, + onModelSource: (String) -> Unit, onAttach: (List) -> Unit, onRemoveAttachment: (String) -> Unit, ) { @@ -600,8 +608,31 @@ private fun ChatFooter( // shows (newest first β€” the freshest link leads), matching the web footer // chip (chat.js prFooterChip); FlowRow wraps them on a narrow phone. FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - MenuChip("model: ${session?.model?.ifBlank { "default" } ?: "default"}", listOf("default", "opus", "sonnet", "haiku"), onModel) + // On the local model the Claude alias picker is not just useless but + // harmful β€” every alias it offers is one that endpoint refuses β€” so + // the chip states the fixed model instead (web: cc-model-fixed). + if (com.xerktech.turma.core.ModelSource.modelPickable(modelSource)) { + MenuChip("model: ${session?.model?.ifBlank { "default" } ?: "default"}", listOf("default", "opus", "sonnet", "haiku"), onModel) + } else { + StaticChip("model: ${com.xerktech.turma.core.ModelSource.label(modelSource, localModel)}") + } MenuChip("mode: ${session?.permissionMode?.ifBlank { "auto" } ?: "auto"}", listOf("auto", "acceptEdits", "plan", "bypassPermissions", "default"), onMode) + // "Run against" β€” the local-model failover (XERK-246). Hidden on a + // host whose agent doesn't report one, exactly as the πŸ“Ž is hidden on + // one that can't take files: the hub 409s the command either way. + if (canSwitchModelSource) { + val opts = com.xerktech.turma.core.ModelSource.options(localModel) + MenuChip( + label = "run: ${com.xerktech.turma.core.ModelSource.label(modelSource, localModel)}", + options = opts.map { it.first }, + onSelect = onModelSource, + optionLabel = { v -> opts.firstOrNull { it.first == v }?.second ?: v }, + // A local session is marked, and in the warn colour: it is a + // weaker model, and nobody should have to wonder which one + // wrote a turn. + accent = modelSource == com.xerktech.turma.core.ModelSource.LOCAL, + ) + } session?.prs?.asReversed()?.forEach { PrBadge(it) } } // Files staged for the next message, above the box so adding one doesn't @@ -743,20 +774,52 @@ private fun MenuSectionHeader(label: String) { ) } +/** + * A compose-bar setting: the current value, tapped to open its menu. + * + * [optionLabel] separates what a row READS from the value it SENDS, for a + * setting whose wire value isn't a label ("subscription" β†’ "Claude + * subscription"). [accent] tints the chip for a value worth noticing. + */ @Composable -private fun MenuChip(label: String, options: List, onSelect: (String) -> Unit) { +private fun MenuChip( + label: String, + options: List, + onSelect: (String) -> Unit, + optionLabel: (String) -> String = { it }, + accent: Boolean = false, +) { var open by remember { mutableStateOf(false) } Box { Text( label, Modifier - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(6.dp)) + .background( + if (accent) com.xerktech.turma.ui.theme.TurmaColors.waiting.copy(alpha = 0.22f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(6.dp), + ) .clickable { open = true } .padding(horizontal = 8.dp, vertical = 4.dp), style = MaterialTheme.typography.bodySmall, ) DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - options.forEach { o -> DropdownMenuItem(text = { Text(o) }, onClick = { onSelect(o); open = false }) } + options.forEach { o -> + DropdownMenuItem(text = { Text(optionLabel(o)) }, onClick = { onSelect(o); open = false }) + } } } } + +/** A compose-bar chip stating a setting that isn't the operator's to change. */ +@Composable +private fun StaticChip(label: String) { + Text( + label, + Modifier + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/android/app/src/main/java/com/xerktech/turma/ui/FleetDialogs.kt b/android/app/src/main/java/com/xerktech/turma/ui/FleetDialogs.kt index 00834005..73eae373 100644 --- a/android/app/src/main/java/com/xerktech/turma/ui/FleetDialogs.kt +++ b/android/app/src/main/java/com/xerktech/turma/ui/FleetDialogs.kt @@ -35,24 +35,38 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import com.xerktech.turma.core.ModelSource +import com.xerktech.turma.model.LocalModelInfo import com.xerktech.turma.model.RepoInfo private val MODELS = listOf("default", "opus", "sonnet", "haiku") private val MODES = listOf("auto", "acceptEdits", "plan", "bypassPermissions", "default") +/** + * The "New session" composer (web `sessions.html`'s spawn options block). + * + * [localModel] is the target HOST's self-hosted-model block (XERK-246): when it + * reports one, the composer offers a "Run against" row, because spawning onto + * the local model is how new work starts once Claude usage is gone β€” without it + * you could fail existing sessions over from a phone but not begin anything. + * Absent, the row is hidden rather than shown-and-refused (the hub 409s it). + */ @Composable fun SpawnDialog( host: String, repo: String, isRoot: Boolean, + localModel: LocalModelInfo? = null, onDismiss: () -> Unit, - onSpawn: (prompt: String, label: String, baseRef: String, model: String, mode: String) -> Unit, + onSpawn: (prompt: String, label: String, baseRef: String, model: String, mode: String, modelSource: String) -> Unit, ) { var prompt by remember { mutableStateOf("") } var label by remember { mutableStateOf("") } var baseRef by remember { mutableStateOf("") } var model by remember { mutableStateOf("default") } var mode by remember { mutableStateOf("auto") } + var modelSource by remember { mutableStateOf(ModelSource.SUBSCRIPTION) } + val sourceOpts = remember(localModel) { ModelSource.options(localModel) } AlertDialog( onDismissRequest = onDismiss, @@ -80,22 +94,54 @@ fun SpawnDialog( singleLine = true, modifier = Modifier.fillMaxWidth(), ) } - DropdownField("Model", MODELS, model) { model = it } + // On the local model the Claude alias picker only breaks the + // session β€” every alias that endpoint refuses β€” so it goes, the + // same way the web hides it for a local session. + if (ModelSource.modelPickable(modelSource)) { + DropdownField("Model", MODELS, model) { model = it } + } DropdownField("Permission mode", MODES, mode) { mode = it } + if (localModel?.available == true) { + DropdownField( + label = "Run against", + options = sourceOpts.map { it.first }, + selected = modelSource, + optionLabel = { v -> sourceOpts.firstOrNull { it.first == v }?.second ?: v }, + ) { modelSource = it } + } } }, - confirmButton = { TextButton(onClick = { onSpawn(prompt, label, baseRef, model, mode) }) { Text("Spawn") } }, + confirmButton = { + TextButton(onClick = { + // A local session's model comes from the host's ANTHROPIC_MODEL and + // the agent drops --model for it; send blank so the request says + // what the composer showed rather than a stale hidden alias. + val picked = if (ModelSource.modelPickable(modelSource)) model else "" + onSpawn(prompt, label, baseRef, picked, mode, modelSource) + }) { Text("Spawn") } + }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, ) } +/** + * A read-only ``. Android instead renders the value itself as the control β€” a pill with a trailing β–Ύ (`SelectableValue` in @@ -345,11 +349,9 @@ are recorded under "Deliberate differences" below, not left to look like gaps. - Pure half in `core/ModelSource.kt` (`ModelSourceTest.kt`), the state reads in `ChatUiStateTest.kt`; the wire block is locked in `AgentDecodeTest.kt`, including the all-nulls shape an unconfigured host reports and the null `modelSourceAt` every unmoved session carries. -- **Divergence:** the web chip carries a hover tooltip naming the self-hosted model; the Android - chip carries the same ☁/🏠 glyph and label but no tooltip β€” a phone has no hover, and the model - name is already the chip's own text. - **Still open:** the 🏠 mark on live and ended session CARDS (see below) β€” inside a session the - compose-bar chip already names the model. + compose-bar chip already names the model. The chip's missing tooltip is a deliberate difference, + recorded above. ## Open (subsequent installments), by screen and priority diff --git a/android/app/src/main/java/com/xerktech/turma/core/ModelSource.kt b/android/app/src/main/java/com/xerktech/turma/core/ModelSource.kt index 472f887d..f024a551 100644 --- a/android/app/src/main/java/com/xerktech/turma/core/ModelSource.kt +++ b/android/app/src/main/java/com/xerktech/turma/core/ModelSource.kt @@ -76,6 +76,18 @@ object ModelSource { return if (session.modelSource == pending.value) null else pending } + /** + * The memo to keep once the POST has come back. + * + * A refusal DROPS it rather than letting it age out: the hub 409s a host + * with no local model, and a chip that keeps claiming a switch that was + * rejected is worse than one that never moved β€” it is the same lie the TTL + * exists to bound, just held for a full minute with the answer already in + * hand. Pure so the rule is pinned by a test; inline in the ViewModel it was + * the one decision a mutation could still delete unnoticed. + */ + fun afterAttempt(pending: Pending?, ok: Boolean): Pending? = if (ok) pending else null + /** * The `modelSource` a spawn should carry, or null to omit it. * diff --git a/android/app/src/main/java/com/xerktech/turma/data/ModelSwitchStore.kt b/android/app/src/main/java/com/xerktech/turma/data/ModelSwitchStore.kt index 2d1fafb46453a2bdc1e1deabdfa155c2916a9145..a99121d42ce571a26e530e91ec8fa0cd5db08722 100644 GIT binary patch delta 28 icmX@fdyaR*E;f;vQUf4RDNZde&dksAOi|*h`R!&lDxDS}p))D+jp% diff --git a/android/app/src/main/java/com/xerktech/turma/vm/ChatViewModel.kt b/android/app/src/main/java/com/xerktech/turma/vm/ChatViewModel.kt index ef48daf8..1cada379 100644 --- a/android/app/src/main/java/com/xerktech/turma/vm/ChatViewModel.kt +++ b/android/app/src/main/java/com/xerktech/turma/vm/ChatViewModel.kt @@ -18,6 +18,7 @@ import com.xerktech.turma.core.entryTruncated import com.xerktech.turma.core.mergeTail import com.xerktech.turma.core.prependHistory import com.xerktech.turma.core.tunnelOnlineOf +import com.xerktech.turma.model.AgentInfo import com.xerktech.turma.model.SessionInfo import com.xerktech.turma.model.TailEntry import com.xerktech.turma.model.TurnStatus @@ -104,6 +105,24 @@ data class ChatUiState( fun canSwitchModelSource(now: Long = System.currentTimeMillis()): Boolean = ModelSource.offered(localModel, modelSource(now)) + + /** + * Everything this screen takes from a fleet beat, in one place. + * + * Extracted from the two callers (the poll collector and the initial seed) + * so the set of carried fields is pinned by a test rather than by whoever + * last resolved a merge on that `copy(...)`. A field silently dropped there + * disables its whole feature β€” [localModel] going missing hides both + * local-model controls forever β€” and nothing else in the suite notices, + * because a Composable's body has no gate at all. + */ + fun fromFleet(agent: AgentInfo?, session: SessionInfo?, host: String): ChatUiState = copy( + session = session, + hostLabel = agent?.device?.ifBlank { host } ?: host, + tunnelOnline = tunnelOnlineOf(agent), + uploadMaxBytes = agent?.uploadMaxBytes ?: 0, + localModel = agent?.localModel, + ) } class ChatViewModel( @@ -189,17 +208,11 @@ class ChatViewModel( container.fleet.state.collect { fleet -> val agent = fleet.agents.firstOrNull { it.key == host } val session = agent?.sessions?.firstOrNull { it.id == sessionId } - val label = agent?.device?.ifBlank { host } ?: host // Retire a memo the heartbeat has caught up with, through the // store β€” the state copy is a mirror, so clearing only that // would let the next emission paint the stale memo back. modelSwitch.value = ModelSource.settle(modelSwitch.value, session) - _state.update { - it.copy(session = session, hostLabel = label, - tunnelOnline = tunnelOnlineOf(agent), - uploadMaxBytes = agent?.uploadMaxBytes ?: 0, - localModel = agent?.localModel) - } + _state.update { it.fromFleet(agent, session, host) } session?.session?.tail?.takeIf { it.isNotEmpty() }?.let { seed -> _state.update { it.copy(entries = mergeTail(it.entries, seed)) } } @@ -210,15 +223,11 @@ class ChatViewModel( private fun seedFromFleet() { val agent = container.fleet.state.value.agents.firstOrNull { it.key == host } val session = agent?.sessions?.firstOrNull { it.id == sessionId } - val label = agent?.device?.ifBlank { host } ?: host val seed = session?.session?.tail ?: emptyList() _state.update { - it.copy(session = session, hostLabel = label, - tunnelOnline = tunnelOnlineOf(agent), - uploadMaxBytes = agent?.uploadMaxBytes ?: 0, - localModel = agent?.localModel, - modelSourcePending = modelSwitch.value, - entries = mergeTail(it.entries, seed)) + it.fromFleet(agent, session, host) + .copy(modelSourcePending = modelSwitch.value, + entries = mergeTail(it.entries, seed)) } } @@ -530,9 +539,11 @@ class ChatViewModel( ) container.fleet.nudge() }.onFailure { e -> - modelSwitch.value = null _messages.tryEmit("βœ— " + (hubErrorMessage(e) ?: "could not switch model")) } + // One place decides what survives the attempt, success or failure, so + // the drop-on-refusal can't be deleted without a test noticing. + modelSwitch.value = ModelSource.afterAttempt(modelSwitch.value, res.isSuccess) } // ---- voice dictation into the draft -------------------------------------- diff --git a/android/app/src/test/java/com/xerktech/turma/core/ModelSourceTest.kt b/android/app/src/test/java/com/xerktech/turma/core/ModelSourceTest.kt index 1450b252..1ae8d017 100644 --- a/android/app/src/test/java/com/xerktech/turma/core/ModelSourceTest.kt +++ b/android/app/src/test/java/com/xerktech/turma/core/ModelSourceTest.kt @@ -50,10 +50,13 @@ class ModelSourceTest { // back and reads as a control that did nothing. assertEquals(ModelSource.LOCAL, ModelSource.current(sess, pending, 1_500)) // ...but it ages out, so a switch that never lands can't pin it on a lie. - assertEquals( - ModelSource.SUBSCRIPTION, - ModelSource.current(sess, pending, 1_000 + ModelSource.SWITCH_SETTLE_MS + 1), - ) + // The boundary is a LITERAL, not `at + SWITCH_SETTLE_MS + 1`: derived + // from the constant it only bounds the TTL from below, and raising it to + // 16.7 hours β€” a chip pinned on a lie for the rest of the day, the exact + // failure the constant exists to prevent β€” would keep the test green. + assertEquals(60_000L, ModelSource.SWITCH_SETTLE_MS) + assertEquals(ModelSource.LOCAL, ModelSource.current(sess, pending, 60_999)) + assertEquals(ModelSource.SUBSCRIPTION, ModelSource.current(sess, pending, 61_001)) } @Test fun `a memo for another session never paints this one`() { @@ -74,6 +77,15 @@ class ModelSourceTest { assertNull(ModelSource.settle(null, SessionInfo(id = "s1"))) } + @Test fun `a refused switch drops the memo instead of letting it age out`() { + val p = ModelSource.Pending("s1", ModelSource.LOCAL, at = 1_000) + // The hub 409s a host with no local model. Holding the memo for a full + // minute with the answer already in hand is the same lie the TTL bounds. + assertEquals(p, ModelSource.afterAttempt(p, ok = true)) + assertNull(ModelSource.afterAttempt(p, ok = false)) + assertNull(ModelSource.afterAttempt(null, ok = true)) + } + @Test fun `a spawn sends the source only when it is local`() { // "subscription" is what a spawn already meant, so omitting it keeps a // bare spawn byte-identical to what it was before the failover existed. diff --git a/android/app/src/test/java/com/xerktech/turma/vm/ChatUiStateTest.kt b/android/app/src/test/java/com/xerktech/turma/vm/ChatUiStateTest.kt index 329b3690..11838436 100644 --- a/android/app/src/test/java/com/xerktech/turma/vm/ChatUiStateTest.kt +++ b/android/app/src/test/java/com/xerktech/turma/vm/ChatUiStateTest.kt @@ -2,10 +2,12 @@ package com.xerktech.turma.vm import com.xerktech.turma.core.ModelSource import com.xerktech.turma.core.Verbosity +import com.xerktech.turma.model.AgentInfo import com.xerktech.turma.model.LocalModelInfo import com.xerktech.turma.model.SessionInfo import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -52,10 +54,9 @@ class ChatUiStateTest { modelSourcePending = ModelSource.Pending("s1", ModelSource.LOCAL, at = 1_000), ) assertEquals(ModelSource.LOCAL, state.modelSource(now = 1_500)) - assertEquals( - ModelSource.SUBSCRIPTION, - state.modelSource(now = 1_000 + ModelSource.SWITCH_SETTLE_MS + 1), - ) + // Literal, not `at + SWITCH_SETTLE_MS + 1` β€” see ModelSourceTest: a + // boundary derived from the constant under test only bounds it below. + assertEquals(ModelSource.SUBSCRIPTION, state.modelSource(now = 61_001)) // Another session's memo must never paint this one. val other = state.copy(modelSourcePending = ModelSource.Pending("s2", ModelSource.LOCAL, 1_000)) assertEquals(ModelSource.SUBSCRIPTION, other.modelSource(now = 1_100)) @@ -66,4 +67,34 @@ class ChatUiStateTest { assertEquals(ModelSource.SUBSCRIPTION, ChatUiState().modelSource(now = 1)) assertFalse(ChatUiState().canSwitchModelSource(now = 1)) } + + @Test + fun `a fleet beat carries every field this screen reads off it`() { + // This is the line a merge resolution silently truncates. Dropping + // `localModel` from it hides both local-model controls forever, and + // without this test the whole suite stays green β€” a Composable's body + // has no gate at all. + val sess = SessionInfo(id = "s1", modelSource = "local") + val agent = AgentInfo( + key = "h1", device = "maxai", online = true, + uploadMaxBytes = 5_000, localModel = configured, sessions = listOf(sess), + ) + val s = ChatUiState().fromFleet(agent, sess, host = "h1") + assertEquals(sess, s.session) + assertEquals("maxai", s.hostLabel) // device name, not the key + assertEquals(5_000L, s.uploadMaxBytes) // gates the πŸ“Ž + assertEquals(configured, s.localModel) // gates BOTH new controls + assertTrue(s.canSwitchModelSource()) + } + + @Test + fun `a beat from a host with no local model clears the capability`() { + // Not merely "leaves it alone": a host that lost its configuration must + // stop offering the switch, and a stale carried-over block would keep it. + val before = ChatUiState(localModel = configured) + val after = before.fromFleet(AgentInfo(key = "h1", online = true), null, host = "h1") + assertNull(after.localModel) + assertEquals("h1", after.hostLabel) // no device name: fall back to the key + assertFalse(after.canSwitchModelSource()) + } } From 96428d82e4e102d8a7e8275117f90a4776ee58b8 Mon Sep 17 00:00:00 2001 From: Malcolm Habeeb Date: Tue, 11 Aug 2026 16:27:53 -0400 Subject: [PATCH 04/19] XERK-246: name every field in the anti-truncation test, and coerce localModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third QA pass. The five earlier fixes all held on device, but the guard I added for the merge-conflict finding did not do what it claimed. - **`fromFleet`'s test asserted four of its five fields and skipped `tunnelOnline`** β€” the one the conflict was actually over. QA deleted it, rebuilt, and watched the chat header say "Β· live" while the hub reported the tunnel down, with the whole suite green. It is worse than a lost warning because the field defaults TRUE: the header then asserts the tunnel is up. Both tests now name every field, and say to add an assert whenever a field is added there. - **`ModelSwitchStore` had no test**, though it is a stated copy of `DraftStore` and `.claude/rules/android.md` names `DraftStoreTest` as the convention for exactly this class. Returning a fresh flow per call β€” i.e. restoring the whole D1 bug β€” kept the suite green. `ModelSwitchStoreTest` mirrors DraftStoreTest's three cases; both mutations are now caught. - **The fixed-model chip dropped a tooltip that carried real information.** The web's says why the chip is inert and how to get the picker back; the run chip's only repeats the model name. That distinction was missing from PARITY.md, which justified dropping both with the run chip's reason. The static chip now carries the web's wording as a contentDescription β€” on a MERGING wrapper, since Text writes its own semantics last and a description in the same modifier chain never reaches the tree β€” and PARITY.md separates the two cases. - **`normalizeLocalModel` coerces the block at hub ingest**, the remedy this repo already documents for `limits` and per-model usage and which `localModel` shipped without. Android decodes /api/agents ATOMICALLY into typed fields, so one host sending `available:"yes"` or a contextTokens past Int threw for the whole array: every other host silently vanished from the phone while the tile still read "N / N online". Verified live β€” a rogue host is now coerced to `{available:false, model:null, contextTokens:null}` and the app shows 4/4. The generalised rule is in CLAUDE.md's heartbeat contract, since it binds the agent, the hub and three clients. Left alone, with reasons: the three memo call sites in ChatViewModel are still only covered by driving the app β€” no test in the project constructs a ViewModel, so closing that means new test infrastructure, not a test. The pure rules behind them are covered. Defects 7 and 8 from the second pass are pre-existing and recorded in qa.md Β§6.2 rather than fixed here. Android 311 tests, hub 928, agent 1258 β€” all green. --- .claude/rules/turma-sessions.md | 5 +- CLAUDE.md | 5 ++ android/PARITY.md | 12 ++-- .../java/com/xerktech/turma/ui/ChatScreen.kt | 31 +++++++--- .../turma/data/ModelSwitchStoreTest.kt | 50 +++++++++++++++++ .../com/xerktech/turma/vm/ChatUiStateTest.kt | 24 +++++--- turma/server.js | 42 ++++++++++++++ turma/tests/server.test.js | 56 +++++++++++++++++++ 8 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 android/app/src/test/java/com/xerktech/turma/data/ModelSwitchStoreTest.kt diff --git a/.claude/rules/turma-sessions.md b/.claude/rules/turma-sessions.md index d358edb9..caa63467 100644 --- a/.claude/rules/turma-sessions.md +++ b/.claude/rules/turma-sessions.md @@ -100,7 +100,10 @@ Split out of `.claude/rules/turma.md` (which covers the rest of the hub UI) to k session is marked (🏠, warn colour) β€” it is a weaker model, and nobody should have to wonder which one wrote a turn. Like the mode switch it paints from a MEMO, never an optimistic write onto `sess`, so a stale beat can't flash the old value back; the memo ages out so a switch that never - lands doesn't pin the chip. Tests: the `model source:` cases in `chat.test.js`. + lands doesn't pin the chip. **`normalizeLocalModel` coerces the block at ingest** β€” the block is + typed on Android and `/api/agents` decodes atomically there, so one host's `available:"yes"` hid + the whole fleet from the phone; see CLAUDE.md's heartbeat contract. Tests: the `model source:` + cases in `chat.test.js`, `normalizeLocalModel` in `server.test.js`. - The compose footer's agent-mode / model selectors are joined by a compact **PR status chip** (`prFooterChip`) when it has one, and a `jira-chip` when the session has a ticket. - The **model selector is accurate** (XERK-33) β€” never a hardcoded menu, and never rewriting the diff --git a/CLAUDE.md b/CLAUDE.md index 0f49127c..82f8e2ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,6 +224,11 @@ Rules spanning more than one component, so no `paths:`-scoped file can carry the it every client). A field older agents don't send must degrade, never break: clients gate on the capability flag the agent reports (`inputMaxChars`, `uploadMaxBytes`, `github.available`, `capacity`), and an absent flag means "that agent can't do it", not "unlimited". + - **Any heartbeat field Android decodes into a TYPED one needs a `normalize*` coercion at hub + ingest** β€” `normalizeUsage`, `normalizeLimits`, `normalizeLocalModel`. `/api/agents` decodes + ATOMICALLY on Android, so one buggy host's wrong-typed field hides the WHOLE fleet from that + phone, silently: the app keeps painting its last good snapshot and still says "N / N online". + Coerce to the "can't tell you" value every client already handles, never to a plausible default. - **A carried-forward feature needs its Android port or a `PARITY.md` line**; `android/PARITY.md` is the living gap tracker, updated whenever a gap closes or knowingly opens. diff --git a/android/PARITY.md b/android/PARITY.md index ebe38727..e8431acb 100644 --- a/android/PARITY.md +++ b/android/PARITY.md @@ -28,10 +28,14 @@ are recorded under "Deliberate differences" below, not left to look like gaps. - **Hub-URL field on Login.** The web is same-origin; a phone app must point at any hub, so Login has an extra Hub-URL field. - **Voice dictation** into the spawn/compose fields β€” a phone-only addition. -- **No tooltip on the "run against" chip** (XERK-246). The web chip carries a hover title naming the - self-hosted model; Android carries the same ☁/🏠 glyph and the same label, but a phone has no - hover and the model name is already the chip's own text, so there is nothing left for a tooltip - to add. +- **No hover tooltips on the two compose-bar model chips** (XERK-246); a phone has no hover, so the + web's `title=` text goes to the accessibility layer or nowhere. + - The **"run against"** chip: nowhere. Its tooltip only names the self-hosted model, which is + already the chip's own text. + - The **fixed-model** chip on a local session: it carries the web's wording as a + `contentDescription` instead. That tooltip is not redundant β€” it explains why the chip is inert + and names the way out β€” so dropping it entirely would leave a dead-looking control beside two + live ones with no account of itself. - **Ticket-detail fields tap-to-change** (XERK-138 follow-up). The web detail panel shows each editable field's value beside a separate "Change" link/control that swaps in a `