Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/VENDORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ai.ciris.mobile.shared.models.ClientMode?>(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<Boolean?>(null) }
var nodeVersion by remember { mutableStateOf<String?>(null) }
val isAgentMode = clientMode?.isAgent ?: true // default to agent wording until probed
// The landing surface, from the PROBE (CIRISServer#479). Deliberately NOT
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading