diff --git a/client/VENDORING.md b/client/VENDORING.md index 4f1f59c..0b72449 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `c6c17a6c3a83b6d110a3b94fc6ac3aaf74ceb0c567000ad251048d1c82cf79a4` +**state digest:** `95272e65abc8c422702e70314997e2cbff5fd10866af5ecc8e3b873b30bd3513` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt index 2df14de..80d84ca 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt @@ -427,6 +427,11 @@ fun CIRISApp( // lights, "agent" wording, the WORK-state wait). null = not probed yet. // nodeVersion drives the non-blocking version-mismatch banner. var clientMode by remember { mutableStateOf(null) } + /** + * Is a brain folded here and answering — regardless of whether it is + * configured yet. `null` until the first probe answers; see [setupHasAgent]. + */ + var brainPresent by remember { mutableStateOf(null) } var nodeVersion by remember { mutableStateOf(null) } val isAgentMode = clientMode?.isAgent ?: true // default to agent wording until probed // The landing surface, from the PROBE (CIRISServer#479). Deliberately NOT @@ -725,7 +730,26 @@ fun CIRISApp( // KEYED on the probe (CIRISServer#479): `viewModel {}` caches its instance, // so a VM built before the mode landed would keep the pre-probe answer for // the app's whole life — the exact staleness this conversion exists to end. - val setupHasAgent = clientMode?.isAgent ?: false + // THE WIZARD'S QUESTION IS NOT THE POLLER GATE'S QUESTION. + // + // This was `clientMode?.isAgent ?: false`, and both halves were wrong for + // setup (CIRISClient#21): + // + // isAgent demotes a folded brain that reports itself unconfigured — and + // on a first run it IS unconfigured, which is what the wizard + // exists to fix. So the gate said NODE, the wizard dropped the + // AI step, the brain stayed unconfigured, and the user reached + // chat to silence. The arm's own comment names the circularity: + // it was added because such a brain 503s `addLlmProvider`, "so + // the user could not configure an escape". + // ?: false collapses "not probed yet" into "no brain", so the wizard + // composed its step list on a value that meant unknown. Same + // distinct-zeroes rule as CapabilityWire's null-versus-empty: + // could-not-determine must not become a definite answer. + // + // `brainPresent` is folded && reachable — is there a brain here to + // configure — and null still means nobody has asked. + val setupHasAgent = brainPresent == true val setupViewModel: SetupViewModel = viewModel(key = "setup-$setupHasAgent") { SetupViewModel(apiClient, setupHasAgent) } // Set HA addon mode if running in Home Assistant addon context @@ -1147,6 +1171,9 @@ fun CIRISApp( } } clientMode = mode + // The wizard's question, from the same probe. See setupHasAgent. + brainPresent = probe.brainPresent + platformLog(TAG, "[INFO][gate] brainPresent=${probe.brainPresent} (folded/reachable, independent of readiness)") // Node mode has no 22 cognitive service lights — drive the count // from the gate rather than the hardcoded agent default. startupViewModel.setClientMode(mode) @@ -2349,7 +2376,28 @@ fun CIRISApp( } Screen.Setup -> { - platformLog(TAG, "[DEBUG][Screen.Setup] Rendering setup screen") + // DO NOT COMPOSE THE WIZARD ON AN UNANSWERED PROBE. + // + // `SetupViewModel` is keyed on `setupHasAgent` and + // `isFinalSetupStep` reads it, so the step list is FIXED at + // composition. Composing while the probe is outstanding fixes + // that shape on a default, and the shape is what decides whether + // the AI step exists at all — the log in CIRISClient#21 shows + // the wizard declaring JOIN_FEDERATION final 80 lines before the + // gate resolved. + // + // It is a race, so it hits slow machines and not fast ones, + // which is the worst way for it to reach a user: a finished + // install with no LLM and a chat screen that answers nothing. + // + // Waiting is safe — the probe is bounded by the startup budget + // and StartupScreen is what the user is already looking at. + if (brainPresent == null) { + platformLog(TAG, "[INFO][Screen.Setup] holding — brainPresent not yet probed") + StartupScreen(viewModel = startupViewModel) + return@Box + } + platformLog(TAG, "[DEBUG][Screen.Setup] Rendering setup screen (hasAgent=$setupHasAgent)") SetupScreen( hasAgent = setupHasAgent, viewModel = setupViewModel, diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt index 331cd29..9d8ed5d 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt @@ -51,7 +51,30 @@ enum class ClientMode { * cannot. [undetermined] is a RETRY signal, not a verdict: the caller must * re-probe (bounded) and must NOT latch [mode] as final while it is set. */ -data class ModeProbe(val mode: ClientMode, val undetermined: Boolean) +data class ModeProbe( + val mode: ClientMode, + val undetermined: Boolean, + /** + * IS THERE A BRAIN HERE AT ALL — regardless of whether it is configured yet. + * + * [mode] answers a NARROWER question than it looks like it answers: "should + * the agent surfaces and the AGENT-only pollers run". It deliberately + * demotes a folded brain that reports itself unconfigured (CIRISAgent#1075) + * because such a brain 503s every agent poller — including, per the comment + * on that arm, `addLlmProvider`, "so the user could not configure an escape". + * + * The SETUP WIZARD asks a different question: is there a brain that needs + * configuring? For that, being unconfigured is the REASON TO OFFER THE STEP, + * not to hide it — and using [mode] made it circular. On a folded agent's + * first run the brain is unconfigured by definition, so the gate said NODE, + * so the wizard dropped the AI step, so the brain stayed unconfigured and + * the user reached chat to silence (CIRISClient#21). + * + * `folded && reachable` — a brain that exists and is answering. Nothing + * about its readiness, which is the wizard's job to fix. + */ + val brainPresent: Boolean = false, +) /** The runtime's own answer to "what am I" — `data.role` in `/v1/system/health`. */ const val ROLE_AGENT = "agent" @@ -207,6 +230,8 @@ fun clientModeFrom( clientModeFrom(cognitiveState, serviceCount, brainUnconfigured, role) }, undetermined = agentFolded && !agentReachable && !brainUnconfigured && !declaredAgent, + // Independent of readiness on purpose — see the field doc. + brainPresent = agentFolded && agentReachable, ) } diff --git a/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/WizardShapeTest.kt b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/WizardShapeTest.kt new file mode 100644 index 0000000..9d18b87 --- /dev/null +++ b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/WizardShapeTest.kt @@ -0,0 +1,84 @@ +package ai.ciris.mobile.shared.models + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * CIRISClient#21 — the wizard's question is not the poller gate's question. + * + * The reproduction from the field log: a folded agent's first run reports + * `folded=true, reachable=true` and `setup_required=true`, so the brain is + * unconfigured, so `clientMode` is NODE — and the wizard used that to drop the + * step that configures it. + */ +class WizardShapeTest { + + /** Exactly the first gate line in the issue's log. */ + private fun firstRunFoldedAgent() = clientModeFrom( + cognitiveState = "SETUP", + serviceCount = 0, + agentFolded = true, + agentReachable = true, + brainUnconfigured = true, + role = ROLE_FABRIC_NODE, + ) + + @Test + fun the_poller_gate_still_says_node_and_that_is_correct() { + // Unchanged on purpose: an unconfigured brain 503s every agent poller, + // which is why the demotion exists (CIRISAgent#1075). + assertEquals(ClientMode.NODE, firstRunFoldedAgent().mode) + } + + @Test + fun but_the_wizard_can_see_there_is_a_brain_to_configure() { + // The same probe, the same instant, the answer the wizard needs. + assertTrue( + firstRunFoldedAgent().brainPresent, + "folded && reachable — being unconfigured is why the AI step must be OFFERED", + ) + } + + @Test + fun brain_presence_does_not_depend_on_readiness() { + // The circularity in one assertion: readiness must not gate the step + // that produces readiness. + val unconfigured = firstRunFoldedAgent() + val configured = clientModeFrom( + cognitiveState = "WORK", serviceCount = 22, + agentFolded = true, agentReachable = true, + brainUnconfigured = false, role = ROLE_FABRIC_NODE, + ) + assertEquals(unconfigured.brainPresent, configured.brainPresent) + assertTrue(configured.mode == ClientMode.AGENT && unconfigured.mode == ClientMode.NODE, + "the poller gate differs across these two; brainPresent does not") + } + + @Test + fun a_bare_node_has_no_brain_to_configure() { + // The other direction: no false AI step on a node that carries none. + val bare = clientModeFrom( + cognitiveState = null, serviceCount = 0, + agentFolded = false, agentReachable = false, + brainUnconfigured = false, role = ROLE_FABRIC_NODE, + ) + assertFalse(bare.brainPresent) + assertEquals(ClientMode.NODE, bare.mode) + } + + @Test + fun a_folded_brain_that_is_not_answering_is_not_yet_present() { + // Undetermined: a brain exists but has not spoken. Not a licence to + // compose the agent wizard, and not a claim there is no brain either — + // the caller waits, which is what Screen.Setup now does. + val booting = clientModeFrom( + cognitiveState = null, serviceCount = 0, + agentFolded = true, agentReachable = false, + brainUnconfigured = false, role = ROLE_FABRIC_NODE, + ) + assertTrue(booting.undetermined) + assertFalse(booting.brainPresent, "not answering yet is not present") + } +}