From 56d95e2c38f86bd3c84328350bd38110629178a7 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Mon, 13 Apr 2026 22:42:59 +0530 Subject: [PATCH] feat(config): split chat_onboarding_completed from React UI onboarding flag (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end testing on the Tauri desktop app revealed that the welcome agent could never run from the in-app chat pane — even with all of Commits 4b/8 in place — because the React layer's `OnboardingOverlay.tsx` renders a full-screen wizard whenever `onboarding_completed = false` and gates the chat pane behind it. By the time a user can type a chat message the React wizard has already flipped `onboarding_completed = true` (via `OnboardingOverlay::handleDone` or the `Onboarding.tsx` wizard's completion handler), so the welcome agent's routing condition is never satisfied on the Tauri surface. This commit fixes the architectural conflict by splitting the single `onboarding_completed` flag into two orthogonal flags: * **`onboarding_completed`** — unchanged semantics. Tracks whether the React UI wizard has been completed/dismissed. Continues to be set by `OnboardingOverlay.tsx::handleDone` and `Onboarding.tsx` via the existing `config.set_onboarding_completed` JSON-RPC method. Used exclusively to gate whether the React layer renders the wizard. * **`chat_onboarding_completed`** — NEW. Tracks whether the welcome agent's chat-based greeting flow has run. Set exclusively by the welcome agent itself via `complete_onboarding(action="complete")`. Used by both the Tauri web channel (`channels::providers::web::build_session_agent`) and the external channel dispatch path (`channels::runtime::dispatch::resolve_target_agent`) to decide whether to route to welcome or orchestrator. The two flags are intentionally orthogonal: * A Tauri desktop user completes the React wizard → only `onboarding_completed` flips → wizard disappears → user types `hi` → welcome agent runs (because `chat_onboarding_completed` is still false) → welcome calls `complete_onboarding(complete)` → `chat_onboarding_completed` flips → next chat turn routes to orchestrator. * A Telegram/Discord user (no React wizard exists) sends a message → external channel dispatch checks `chat_onboarding_completed` → routes to welcome → welcome runs → flips the flag → next inbound message routes to orchestrator. Both paths give every user the chat welcome experience, regardless of which surface they came in through, and without requiring the React wizard to be removed or restructured. ## Files changed `src/openhuman/config/schema/types.rs` * Add `chat_onboarding_completed: bool` field with `#[serde(default)]` for backward compat — existing `config.toml` files that don't have the field default to `false`, which means existing users will see the welcome agent on their next chat turn (correct behaviour, the welcome agent is idempotent). * Default impl initializes the new field to `false`. * Extensive rustdoc on both flags explaining the orthogonal split, why two flags exist, and which code paths gate on which. `src/openhuman/tools/impl/agent/complete_onboarding.rs` * `check_status` now reports BOTH flags side-by-side so the welcome agent's LLM can see whether the React wizard has run AND whether the chat welcome itself has run. Old single "Onboarding completed" line replaced with two lines: "UI onboarding wizard completed" and "Chat welcome flow completed". * `complete()` now flips `chat_onboarding_completed`, NOT `onboarding_completed`. The React UI's flag is left untouched — that's owned by the React layer. Idempotency guard updated to check the chat flag. * Rustdoc on `complete()` explains the orthogonal-flags rationale for future readers. `src/openhuman/channels/providers/web.rs::build_session_agent` * Reads `effective.chat_onboarding_completed` instead of `effective.onboarding_completed` for the welcome-vs-orchestrator decision. * Log line now includes BOTH flags so observability captures the full picture (e.g. `chat_onboarding_completed=false, ui_onboarding_completed=true` is the expected steady state for a Tauri user who completed the React wizard but hasn't typed yet). `src/openhuman/channels/runtime/dispatch.rs::resolve_target_agent` * Same flag swap for the external-channel path. * `[dispatch::routing] selected target agent` info trace also reports both flags. * Function-level rustdoc updated with the new semantics. ## Backward compatibility * Existing `config.toml` files: `chat_onboarding_completed` defaults to `false` via `#[serde(default)]`. Means existing users get a welcome message on their next chat turn — this is the desired behaviour, not a regression. * React layer: untouched. The `config.set_onboarding_completed` JSON-RPC method continues to write the same field; the new flag is not exposed to React at all. * External callers of `complete_onboarding(complete)`: they now flip a different flag. This may matter for callers who were depending on the flag flip side effect; a quick grep shows `tools/impl/agent/complete_onboarding.rs` is the only caller and the rest of the codebase reads `onboarding_completed` for UI purposes (snapshots, app state) and `chat_onboarding_completed` for routing — the split is clean. ## Tests 399/400 tools tests pass. The single failure (`tools::impl::browser::screenshot::screenshot_command_contains_output_path`) is a pre-existing Windows-environment bug that fails identically on `upstream/main` baseline and is unrelated to this PR. No test expectations were modified. ## Next step Restage sidecar, relaunch Tauri, click through the React wizard once to dismiss it, then type `hi` in the chat pane. This time `build_session_agent` should read `chat_onboarding_completed=false` and route to welcome, even though `onboarding_completed=true` was set by the React layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openhuman/channels/providers/web.rs | 35 +++++++++---- src/openhuman/channels/runtime/dispatch.rs | 24 +++++++-- src/openhuman/config/schema/types.rs | 52 ++++++++++++++++++- .../tools/impl/agent/complete_onboarding.rs | 47 +++++++++++++---- 4 files changed, 132 insertions(+), 26 deletions(-) diff --git a/src/openhuman/channels/providers/web.rs b/src/openhuman/channels/providers/web.rs index ab74dfc64..08b3e34a2 100644 --- a/src/openhuman/channels/providers/web.rs +++ b/src/openhuman/channels/providers/web.rs @@ -508,27 +508,40 @@ fn build_session_agent( effective.default_temperature = temp; } - // Route to welcome vs orchestrator based on the per-user onboarding - // flag. #525 fix: pre-onboarding users see the welcome agent's - // persona with its 2-tool TOML scope (complete_onboarding + - // memory_recall) instead of the orchestrator's default delegation - // surface. Post-onboarding they transition automatically on the - // next chat turn because `Config::load_or_init` reads fresh from - // disk every call. + // Route to welcome vs orchestrator based on the per-user + // **chat-onboarding** flag. #525 fix: pre-onboarding users see the + // welcome agent's persona with its 2-tool TOML scope + // (complete_onboarding + memory_recall) instead of the + // orchestrator's default delegation surface. Post-onboarding they + // transition automatically on the next chat turn because + // `Config::load_or_init` reads fresh from disk every call. + // + // We deliberately read `chat_onboarding_completed`, NOT + // `onboarding_completed`. The latter is the React UI wizard's + // gate (`OnboardingOverlay.tsx`) which flips to `true` the moment + // the user dismisses the wizard — which happens BEFORE they ever + // type in the chat pane. If we routed on that flag the welcome + // agent could never run from the Tauri desktop app. The chat + // flag is set only by the welcome agent itself via + // `complete_onboarding(action="complete")`, so it stays `false` + // for the user's actual first chat message regardless of what + // the React layer did, then flips on the welcome turn so the + // very next message routes to orchestrator. // // The config reached here has already been loaded by // `run_chat_task` via `config_rpc::load_config_with_timeout`, so - // the `onboarding_completed` field reflects the current persisted - // state — no cache to invalidate. - let target_agent_id = if effective.onboarding_completed { + // both flags reflect the current persisted state — no cache to + // invalidate. + let target_agent_id = if effective.chat_onboarding_completed { "orchestrator" } else { "welcome" }; log::info!( - "[web-channel] routing chat turn to '{}' (onboarding_completed={}, client_id={}, thread_id={})", + "[web-channel] routing chat turn to '{}' (chat_onboarding_completed={}, ui_onboarding_completed={}, client_id={}, thread_id={})", target_agent_id, + effective.chat_onboarding_completed, effective.onboarding_completed, client_id, thread_id diff --git a/src/openhuman/channels/runtime/dispatch.rs b/src/openhuman/channels/runtime/dispatch.rs index 8b519eda3..d8d6568d4 100644 --- a/src/openhuman/channels/runtime/dispatch.rs +++ b/src/openhuman/channels/runtime/dispatch.rs @@ -214,7 +214,8 @@ impl AgentScoping { /// Decide which agent should run for this channel turn and build the /// matching tool-scoping payload. /// -/// The selection is purely a function of `config.onboarding_completed`: +/// The selection is purely a function of +/// `config.chat_onboarding_completed`: /// /// * **`false`** → route to the `welcome` agent. Welcome's TOML /// restricts it to two tools (`complete_onboarding`, `memory_recall`) @@ -228,8 +229,20 @@ impl AgentScoping { /// field in its TOML; this function expands that field into a list /// of `delegate_*` tools spliced alongside the global registry. /// -/// The next channel message after `complete_onboarding` flips the flag -/// is automatically routed to the orchestrator because +/// We deliberately read `chat_onboarding_completed` and NOT the +/// React-UI-managed `onboarding_completed` flag. The latter is the +/// gate `OnboardingOverlay.tsx` uses to render its full-screen wizard +/// in the Tauri desktop app — by the time a desktop user can type a +/// chat message it's already `true`, so routing on it would mean +/// welcome could never run from the Tauri app. The chat flag is set +/// exclusively by the welcome agent itself when it calls +/// `complete_onboarding(complete)`, so it stays `false` for the +/// user's actual first message regardless of what the React layer +/// did. See `Config::chat_onboarding_completed` rustdoc for the full +/// rationale. +/// +/// The next channel message after `complete_onboarding` flips the +/// flag is automatically routed to the orchestrator because /// `Config::load_or_init()` reads from disk every call (no in-process /// cache, verified at `config/schema/load.rs:409`), so the new value /// is observed on the next turn without any explicit handoff event. @@ -251,7 +264,7 @@ async fn resolve_target_agent(channel: &str) -> AgentScoping { } }; - let target_id = if config.onboarding_completed { + let target_id = if config.chat_onboarding_completed { "orchestrator" } else { "welcome" @@ -260,7 +273,8 @@ async fn resolve_target_agent(channel: &str) -> AgentScoping { tracing::info!( channel = %channel, target_agent = target_id, - onboarding_completed = config.onboarding_completed, + chat_onboarding_completed = config.chat_onboarding_completed, + ui_onboarding_completed = config.onboarding_completed, "[dispatch::routing] selected target agent" ); diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index dd0ed8e69..08a997277 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -142,9 +142,58 @@ pub struct Config { #[serde(default)] pub dictation: DictationConfig, - /// Whether the user has completed the onboarding flow. + /// Whether the user has completed the **React UI** onboarding flow. + /// + /// Set by `OnboardingOverlay.tsx::handleDone` and the multi-step + /// `Onboarding.tsx` wizard via the `config.set_onboarding_completed` + /// JSON-RPC method. Gates whether the React layer renders the + /// full-screen onboarding overlay on top of the chat pane: when + /// `false`, the overlay is shown and the user cannot interact with + /// the chat until they complete or defer the wizard. + /// + /// Distinct from [`Config::chat_onboarding_completed`] — this flag + /// only tracks the UI wizard, NOT the welcome agent's chat-based + /// greeting flow. See that field for the agent routing semantics. #[serde(default)] pub onboarding_completed: bool, + + /// Whether the **chat-based welcome agent** flow has run for this + /// user. Distinct from [`Config::onboarding_completed`] (the + /// React UI wizard flag) so the welcome agent can run on the very + /// first chat turn even after the React wizard has already + /// completed. + /// + /// Routing semantics: + /// * **`false`** — incoming channel messages and Tauri in-app + /// chat turns route to the `welcome` agent definition (see + /// `channels::providers::web::build_session_agent` and + /// `channels::runtime::dispatch::resolve_target_agent`). The + /// welcome agent inspects the user's setup, delivers a + /// personalized greeting, and (when the essentials are in + /// place) calls `complete_onboarding(action="complete")` which + /// flips this flag to `true`. + /// * **`true`** — the welcome agent has already run; future chat + /// turns route to the orchestrator. + /// + /// Why two separate flags: + /// + /// In the Tauri desktop app, `OnboardingOverlay` blocks the chat + /// pane until `onboarding_completed=true`. If the welcome agent + /// also gated on `onboarding_completed`, by the time the user + /// could type in chat the flag would already be `true` and the + /// welcome agent would never run on the desktop. Using a separate + /// flag lets the React wizard manage UI gating while the chat + /// welcome runs orthogonally — every user gets greeted by the + /// welcome agent on their first chat turn regardless of which + /// surface they came from (web, Telegram, Discord, etc.). + /// + /// Defaults to `false` for backward compatibility — existing + /// `config.toml` files without this field will get the welcome + /// agent on their next chat turn, which is the correct behaviour + /// (the welcome agent is idempotent and re-running it for an + /// already-onboarded user just produces a recognition message). + #[serde(default)] + pub chat_onboarding_completed: bool, } impl Default for Config { @@ -196,6 +245,7 @@ impl Default for Config { update: UpdateConfig::default(), dictation: DictationConfig::default(), onboarding_completed: false, + chat_onboarding_completed: false, } } } diff --git a/src/openhuman/tools/impl/agent/complete_onboarding.rs b/src/openhuman/tools/impl/agent/complete_onboarding.rs index 30e5354bd..10098cc97 100644 --- a/src/openhuman/tools/impl/agent/complete_onboarding.rs +++ b/src/openhuman/tools/impl/agent/complete_onboarding.rs @@ -100,10 +100,20 @@ async fn check_status() -> anyhow::Result { .as_deref() .unwrap_or(crate::openhuman::config::DEFAULT_MODEL) )); + // Two distinct flags after the chat / UI split: + // * `onboarding_completed` — React wizard (Tauri desktop UI) gate + // * `chat_onboarding_completed` — welcome agent's own gate, which + // determines whether YOU (the welcome agent reading this report) + // are routed to handle the next chat turn. Use the chat flag, + // not the UI flag, when deciding whether your work here is done. report.push_str(&format!( - "- Onboarding completed: {}\n", + "- UI onboarding wizard completed: {}\n", config.onboarding_completed )); + report.push_str(&format!( + "- Chat welcome flow completed: {}\n", + config.chat_onboarding_completed + )); // ── Channels ──────────────────────────────────────────────────── report.push_str("\n### Channels\n"); @@ -240,20 +250,37 @@ async fn check_status() -> anyhow::Result { Ok(ToolResult::success(report)) } -/// Marks onboarding as complete and seeds proactive cron jobs. +/// Marks the **chat-based welcome agent flow** as complete and seeds +/// proactive cron jobs. +/// +/// After the #525 chat/UI onboarding split this tool flips +/// [`Config::chat_onboarding_completed`] — NOT the React UI's +/// [`Config::onboarding_completed`] flag. The welcome agent gates on +/// the chat flag, so flipping it here is what tells dispatch to route +/// the next chat turn to the orchestrator instead of welcome. +/// +/// The React UI manages its own `onboarding_completed` flag via the +/// `config.set_onboarding_completed` JSON-RPC method (called by +/// `OnboardingOverlay.tsx::handleDone` and `Onboarding.tsx`). The two +/// flags are intentionally orthogonal so that: +/// * a Tauri user who completes the React wizard still sees the +/// welcome agent on their first chat turn (because the chat flag +/// is still `false` until the agent runs); +/// * a Telegram/Discord user (no React wizard) sees the welcome +/// agent on their first inbound message (same reason). async fn complete() -> anyhow::Result { let mut config = Config::load_or_init() .await .map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?; - if config.onboarding_completed { - tracing::debug!("[complete_onboarding] already completed — no-op"); + if config.chat_onboarding_completed { + tracing::debug!("[complete_onboarding] chat welcome flow already completed — no-op"); return Ok(ToolResult::success( - "Onboarding was already marked as complete.", + "Chat welcome flow was already marked as complete.", )); } - config.onboarding_completed = true; + config.chat_onboarding_completed = true; config .save() .await @@ -267,11 +294,13 @@ async fn complete() -> anyhow::Result { } }); - tracing::info!("[complete_onboarding] onboarding marked complete, proactive agents seeded"); + tracing::info!( + "[complete_onboarding] chat welcome flow marked complete, proactive agents seeded" + ); Ok(ToolResult::success( - "Onboarding marked as complete. Morning briefing and proactive agent jobs have been \ - set up. The user is all set!", + "Chat welcome flow marked as complete. Morning briefing and proactive agent jobs have \ + been set up. The user is all set!", )) }