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!", )) }